import { Test, TestingModule } from '@nestjs/testing' import { BadRequestException, NotFoundException } from '@nestjs/common' import { UserService } from '../user.service' import { UserController } from '../user.controller' import { PrismaService } from '../../prisma/prisma.service' import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory, SubscriptionMessageScene, } from '@mp-pilates/shared' import { ConfigService } from '@nestjs/config' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const makeUser = (overrides: Record = {}) => ({ id: 'user-1', openid: 'openid-1', unionid: null, phone: '13800000000', nickname: 'Alice', avatarUrl: 'https://example.com/avatar.png', role: UserRole.MEMBER, adminBookingSubscriptionCount: 0, lastLoginAt: new Date('2024-06-01T08:00:00Z'), createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), _count: { memberships: 2 }, ...overrides, }) const makeBooking = ( date: Date, startTime: string, endTime: string, status: BookingStatus = BookingStatus.COMPLETED, ) => ({ id: `booking-${Math.random()}`, userId: 'user-1', timeSlotId: `slot-${Math.random()}`, membershipId: `membership-${Math.random()}`, status, cancelledAt: null, createdAt: new Date(), updatedAt: new Date(), timeSlot: { date, startTime, endTime }, }) // --------------------------------------------------------------------------- // Mock PrismaService // --------------------------------------------------------------------------- const mockPrisma = { lessonSupplement: { aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: 0 } }), groupBy: jest.fn().mockResolvedValue([]), }, user: { findUnique: jest.fn(), findMany: jest.fn(), count: jest.fn(), update: jest.fn(), }, subscriptionMessageConsent: { upsert: jest.fn(), findMany: jest.fn(), }, booking: { findMany: jest.fn(), groupBy: jest.fn(), }, membership: { findFirst: jest.fn(), update: jest.fn(), create: jest.fn(), updateMany: jest.fn(), }, } const mockConfigService = { get: jest.fn((key: string, defaultValue = '') => { if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED') return 'tmpl-booking-confirmed' return defaultValue }), } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('UserService', () => { let service: UserService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ UserService, { provide: PrismaService, useValue: mockPrisma }, { provide: ConfigService, useValue: mockConfigService }, ], }).compile() service = module.get(UserService) jest.clearAllMocks() }) // ------------------------------------------------------------------------- // getProfile // ------------------------------------------------------------------------- describe('getProfile', () => { it('returns a UserProfileResponse with activeMembershipCount', async () => { const user = makeUser({ _count: { memberships: 3 } }) mockPrisma.user.findUnique.mockResolvedValue(user) const result = await service.getProfile('user-1') expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { id: 'user-1' }, include: { _count: { select: { memberships: { where: { status: MembershipStatus.ACTIVE } }, }, }, }, }) expect(result).toEqual({ id: 'user-1', phone: '13800000000', nickname: 'Alice', avatarUrl: 'https://example.com/avatar.png', role: UserRole.MEMBER, activeMembershipCount: 3, inviteShareEligible: true, adminBookingSubscriptionCount: 0, subscriptionMessageTemplates: { templates: [ { templateId: 'tmpl-booking-confirmed', scene: SubscriptionMessageScene.BOOKING_CREATED, description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送', usageTarget: 'consent', }, { templateId: 'tmpl-booking-confirmed', scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED, description: '管理员主动增加预约提醒次数,用于接收学员新预约通知', usageTarget: 'counter', }, ], }, createdAt: new Date('2024-01-01T00:00:00Z').toISOString(), }) }) it('throws NotFoundException when user does not exist', async () => { mockPrisma.user.findUnique.mockResolvedValue(null) await expect(service.getProfile('unknown')).rejects.toThrow(NotFoundException) }) }) describe('reportSubscriptionMessageRequests', () => { it('aggregates and returns subscription consent stats', async () => { mockPrisma.subscriptionMessageConsent.upsert.mockResolvedValue(undefined) mockPrisma.subscriptionMessageConsent.findMany.mockResolvedValue([ { userId: 'user-1', templateId: 'tmpl-booking-confirmed', scene: SubscriptionMessageScene.BOOKING_CREATED, totalRequestCount: 2, acceptCount: 1, rejectCount: 1, banCount: 0, filterCount: 0, sentCount: 0, lastResult: 'reject', lastRequestedAt: new Date('2024-01-03T00:00:00Z'), lastSentAt: null, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-03T00:00:00Z'), }, ]) const result = await service.reportSubscriptionMessageRequests('user-1', [ { templateId: 'tmpl-booking-confirmed', scene: SubscriptionMessageScene.BOOKING_CREATED, result: 'reject', }, ]) expect(mockPrisma.subscriptionMessageConsent.upsert).toHaveBeenCalledWith({ where: { userId_templateId_scene: { userId: 'user-1', templateId: 'tmpl-booking-confirmed', scene: SubscriptionMessageScene.BOOKING_CREATED, }, }, create: { userId: 'user-1', templateId: 'tmpl-booking-confirmed', scene: SubscriptionMessageScene.BOOKING_CREATED, totalRequestCount: 1, acceptCount: 0, rejectCount: 1, banCount: 0, filterCount: 0, sentCount: 0, lastResult: 'reject', lastRequestedAt: expect.any(Date), }, update: { totalRequestCount: { increment: 1 }, acceptCount: { increment: 0 }, rejectCount: { increment: 1 }, banCount: { increment: 0 }, filterCount: { increment: 0 }, lastResult: 'reject', lastRequestedAt: expect.any(Date), }, }) expect(result).toEqual([ { userId: 'user-1', templateId: 'tmpl-booking-confirmed', scene: SubscriptionMessageScene.BOOKING_CREATED, totalRequestCount: 2, acceptCount: 1, rejectCount: 1, banCount: 0, filterCount: 0, sentCount: 0, lastResult: 'reject', lastRequestedAt: '2024-01-03T00:00:00.000Z', lastSentAt: null, createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-03T00:00:00.000Z', }, ]) }) }) // ------------------------------------------------------------------------- // updateProfile // ------------------------------------------------------------------------- describe('updateProfile', () => { it('updates nickname and avatarUrl, returns new UserProfileResponse', async () => { const updated = makeUser({ nickname: 'Bob', avatarUrl: 'https://example.com/new.png', _count: { memberships: 1 }, }) mockPrisma.user.update.mockResolvedValue(updated) const result = await service.updateProfile('user-1', { nickname: 'Bob', avatarUrl: 'https://example.com/new.png', }) expect(mockPrisma.user.update).toHaveBeenCalledWith({ where: { id: 'user-1' }, data: { nickname: 'Bob', avatarUrl: 'https://example.com/new.png' }, include: { _count: { select: { memberships: { where: { status: MembershipStatus.ACTIVE } }, }, }, }, }) expect(result.nickname).toBe('Bob') expect(result.avatarUrl).toBe('https://example.com/new.png') expect(result.activeMembershipCount).toBe(1) expect(result.adminBookingSubscriptionCount).toBe(0) expect(result.subscriptionMessageTemplates.templates).toHaveLength(2) }) it('increments admin booking subscription count for admin users', async () => { mockPrisma.user.findUnique.mockResolvedValue(makeUser({ role: UserRole.ADMIN, adminBookingSubscriptionCount: 2, })) mockPrisma.user.update.mockResolvedValue(makeUser({ role: UserRole.ADMIN, adminBookingSubscriptionCount: 3, })) const result = await service.grantAdminBookingSubscriptionCount('user-1') expect(mockPrisma.user.update).toHaveBeenCalledWith({ where: { id: 'user-1' }, data: { adminBookingSubscriptionCount: { increment: 1, }, }, include: { _count: { select: { memberships: { where: { status: MembershipStatus.ACTIVE } }, }, }, }, }) expect(result.adminBookingSubscriptionCount).toBe(3) }) it('only includes provided fields in the update payload', async () => { const updated = makeUser({ nickname: 'Charlie', _count: { memberships: 0 } }) mockPrisma.user.update.mockResolvedValue(updated) await service.updateProfile('user-1', { nickname: 'Charlie' }) const callArgs = mockPrisma.user.update.mock.calls[0][0] expect(callArgs.data).toEqual({ nickname: 'Charlie' }) expect(callArgs.data.avatarUrl).toBeUndefined() }) it('returns an immutable snapshot — the original dto is not mutated', async () => { const updated = makeUser({ _count: { memberships: 0 } }) mockPrisma.user.update.mockResolvedValue(updated) const dto = { nickname: 'Dave' } const originalDto = { ...dto } await service.updateProfile('user-1', dto) expect(dto).toEqual(originalDto) }) }) // ------------------------------------------------------------------------- // getStats // ------------------------------------------------------------------------- describe('getStats', () => { /** * Build a date in the *current* month so the month-filter logic works * regardless of when the test is run. */ const thisYear = new Date().getFullYear() const thisMonth = new Date().getMonth() const dateInMonth = (day: number) => new Date(thisYear, thisMonth, day) const dateLastMonth = new Date(thisYear, thisMonth - 1, 15) it('returns zeroed stats when there are no completed bookings', async () => { mockPrisma.booking.findMany.mockResolvedValue([]) const result = await service.getStats('user-1') expect(result).toEqual({ totalBookings: 0, totalDays: 0, monthBookings: 0, monthDays: 0, monthHours: 0, }) }) it('counts all completed bookings for totalBookings', async () => { mockPrisma.booking.findMany.mockResolvedValue([ makeBooking(dateInMonth(1), '09:00', '10:00'), makeBooking(dateLastMonth, '09:00', '10:00'), ]) const result = await service.getStats('user-1') expect(result.totalBookings).toBe(2) }) it('adds ten active historical classes only to lifetime total', async () => { mockPrisma.booking.findMany.mockResolvedValue([makeBooking(dateInMonth(1), '09:00', '10:00')]) mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } }) expect(await service.getStats('user-1')).toEqual({ totalBookings: 11, totalDays: 1, monthBookings: 1, monthDays: 1, monthHours: 1 }) expect(mockPrisma.lessonSupplement.aggregate).toHaveBeenCalledWith({ where: { userId: 'user-1', revokedAt: null }, _sum: { quantity: true } }) }) it('counts distinct dates for totalDays', async () => { mockPrisma.booking.findMany.mockResolvedValue([ makeBooking(dateInMonth(1), '09:00', '10:00'), makeBooking(dateInMonth(1), '11:00', '12:00'), // same day → still 1 distinct day makeBooking(dateLastMonth, '09:00', '10:00'), ]) const result = await service.getStats('user-1') expect(result.totalDays).toBe(2) // day-in-month(1) + last-month-day }) it('only counts this-month bookings in monthBookings', async () => { mockPrisma.booking.findMany.mockResolvedValue([ makeBooking(dateInMonth(5), '09:00', '10:00'), makeBooking(dateInMonth(10), '09:00', '10:00'), makeBooking(dateLastMonth, '09:00', '10:00'), // excluded ]) const result = await service.getStats('user-1') expect(result.monthBookings).toBe(2) }) it('counts distinct this-month dates for monthDays', async () => { mockPrisma.booking.findMany.mockResolvedValue([ makeBooking(dateInMonth(3), '09:00', '10:00'), makeBooking(dateInMonth(3), '11:00', '12:00'), // same day makeBooking(dateInMonth(7), '09:00', '10:00'), makeBooking(dateLastMonth, '09:00', '10:00'), // excluded ]) const result = await service.getStats('user-1') expect(result.monthDays).toBe(2) }) it('sums hours for monthHours from startTime/endTime', async () => { mockPrisma.booking.findMany.mockResolvedValue([ makeBooking(dateInMonth(1), '09:00', '10:00'), // 1 h makeBooking(dateInMonth(2), '14:00', '15:30'), // 1.5 h makeBooking(dateLastMonth, '09:00', '10:00'), // excluded ]) const result = await service.getStats('user-1') expect(result.monthHours).toBeCloseTo(2.5) }) it('queries only COMPLETED bookings for this user', async () => { mockPrisma.booking.findMany.mockResolvedValue([]) await service.getStats('user-1') expect(mockPrisma.booking.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: 'user-1', status: BookingStatus.COMPLETED }, }), ) }) }) it('batch-adds supplements to member-list completed counts without inflating reservations', async () => { mockPrisma.user.findMany.mockResolvedValue([makeUser({ memberships: [] })]) mockPrisma.user.count.mockResolvedValue(1) mockPrisma.booking.groupBy.mockResolvedValue([{ userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } }]) mockPrisma.lessonSupplement.groupBy.mockResolvedValueOnce([{ userId: 'user-1', _sum: { quantity: 10 } }]) const result = await service.getMembers(1, 20) expect(result.items[0]).toMatchObject({ totalBookings: 3, completedBookings: 13 }) expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } }) }) describe('member filters', () => { it.each([ ['ACTIVE', { memberships: { some: { status: MembershipStatus.ACTIVE } } }], ['NONE', { NOT: { memberships: { some: { status: MembershipStatus.ACTIVE } } } }], ['TIMES', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TIMES } } } }], ['DURATION', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.DURATION } } } }], ['TRIAL', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TRIAL } } } }], [undefined, {}], ])('passes %s through the controller and applies the same filter to list and count', async (filter, where) => { mockPrisma.user.findMany.mockResolvedValue([]) mockPrisma.user.count.mockResolvedValue(0) const controller = new UserController(service) await controller.getMembers('2', '20', undefined, filter as string | undefined) expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ where, skip: 20, take: 20 })) expect(mockPrisma.user.count).toHaveBeenCalledWith({ where }) }) }) describe('getMemberDetail', () => { const cardType = { id: 'ct-1', name: '10次卡', type: CardTypeCategory.TIMES, totalTimes: 10, durationDays: 180, price: 150000, originalPrice: null, description: null, coverUrl: null, isActive: true, sortOrder: 0, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), } const membership = { id: 'mem-1', userId: 'user-1', cardTypeId: 'ct-1', remainingTimes: 6, totalTimes: 10, startDate: new Date('2024-01-01T00:00:00Z'), expireDate: new Date('2099-01-01T00:00:00Z'), status: MembershipStatus.ACTIVE, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), cardType, } it('returns profile, memberships, stats and upcoming bookings', async () => { mockPrisma.user.findUnique.mockResolvedValue({ ...makeUser(), memberships: [membership], }) mockPrisma.booking.groupBy.mockResolvedValue([ { userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } }, { userId: 'user-1', status: BookingStatus.CANCELLED, _count: { id: 1 } }, { userId: 'user-1', status: BookingStatus.NO_SHOW, _count: { id: 1 } }, ]) mockPrisma.booking.findMany.mockResolvedValue([ { id: 'booking-up-1', status: BookingStatus.CONFIRMED, timeSlot: { date: new Date('2099-12-31T00:00:00Z'), startTime: '09:00', endTime: '10:00', }, membership: { cardType: { name: '10次卡' } }, }, ]) mockPrisma.lessonSupplement.aggregate.mockResolvedValueOnce({ _sum: { quantity: 10 } }) const result = await service.getMemberDetail('user-1') expect(result.user.userId).toBe('user-1') expect(result.user.lastLoginAt).toBe('2024-06-01T08:00:00.000Z') expect(result.memberships).toHaveLength(1) expect(result.memberships[0].cardType.name).toBe('10次卡') expect(result.stats).toEqual({ totalBookings: 5, completedBookings: 13, cancelledBookings: 1, noShowBookings: 1, }) expect(result.upcomingBookings).toEqual([ { id: 'booking-up-1', status: BookingStatus.CONFIRMED, date: '2099-12-31', startTime: '09:00', endTime: '10:00', cardName: '10次卡', }, ]) }) it('throws NotFoundException when member does not exist', async () => { mockPrisma.user.findUnique.mockResolvedValue(null) await expect(service.getMemberDetail('missing')).rejects.toThrow(NotFoundException) }) }) describe('updateMemberProfile', () => { it('updates nickname and phone then returns the dossier', async () => { mockPrisma.user.findUnique .mockResolvedValueOnce(makeUser()) .mockResolvedValueOnce({ ...makeUser({ nickname: 'Bob', phone: '13900000000' }), memberships: [] }) mockPrisma.user.update.mockResolvedValue(makeUser({ nickname: 'Bob', phone: '13900000000' })) mockPrisma.booking.groupBy.mockResolvedValue([]) mockPrisma.booking.findMany.mockResolvedValue([]) const result = await service.updateMemberProfile('user-1', { nickname: 'Bob', phone: '13900000000', }) expect(mockPrisma.user.update).toHaveBeenCalledWith({ where: { id: 'user-1' }, data: { nickname: 'Bob', phone: '13900000000' }, }) expect(result.user.nickname).toBe('Bob') expect(result.user.phone).toBe('13900000000') }) }) describe('updateUserMembership', () => { const startDate = '2026-09-01' const expireDate = '2099-10-01' it('snapshots a submitted count when creating a DURATION membership', async () => { mockPrisma.membership.findFirst.mockResolvedValue(null) mockPrisma.membership.create.mockResolvedValue({ id: 'mem-duration-001' }) await service.updateUserMembership('user-1', { cardTypeId: 'card-duration-001', remainingTimes: 10, startDate, expireDate, }) expect(mockPrisma.membership.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: 'user-1', cardTypeId: 'card-duration-001', remainingTimes: 10, totalTimes: 10, status: MembershipStatus.ACTIVE, }), }), ) }) it('preserves the existing count snapshot when the submitted remaining count does not exceed it', async () => { mockPrisma.membership.findFirst.mockResolvedValue({ id: 'mem-duration-001', userId: 'user-1', cardTypeId: 'card-duration-001', remainingTimes: 6, totalTimes: 10, }) mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' }) await service.updateUserMembership('user-1', { membershipId: 'mem-duration-001', cardTypeId: 'card-duration-001', remainingTimes: 8, startDate, expireDate, }) expect(mockPrisma.membership.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'mem-duration-001' }, data: expect.objectContaining({ cardTypeId: 'card-duration-001', remainingTimes: 8, totalTimes: 10, status: MembershipStatus.ACTIVE, }), }), ) }) it('raises the count snapshot when the submitted remaining count exceeds it', async () => { mockPrisma.membership.findFirst.mockResolvedValue({ id: 'mem-duration-001', userId: 'user-1', cardTypeId: 'card-duration-001', remainingTimes: 6, totalTimes: 10, }) mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' }) await service.updateUserMembership('user-1', { membershipId: 'mem-duration-001', cardTypeId: 'card-duration-001', remainingTimes: 12, startDate, expireDate, }) expect(mockPrisma.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 12, totalTimes: 12, }), }), ) }) it('resets the count snapshot when changing to a different card type', async () => { mockPrisma.membership.findFirst.mockResolvedValue({ id: 'mem-duration-001', userId: 'user-1', cardTypeId: 'card-duration-001', remainingTimes: 6, totalTimes: 10, }) mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' }) await service.updateUserMembership('user-1', { membershipId: 'mem-duration-001', cardTypeId: 'card-duration-002', remainingTimes: 8, startDate, expireDate, }) expect(mockPrisma.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ cardTypeId: 'card-duration-002', remainingTimes: 8, totalTimes: 8, }), }), ) }) }) describe('deleteUserMembership', () => { it('expires only the selected membership', async () => { mockPrisma.membership.findFirst.mockResolvedValue({ id: 'mem-1', userId: 'user-1', status: MembershipStatus.ACTIVE, }) mockPrisma.membership.update.mockResolvedValue({ id: 'mem-1', status: MembershipStatus.EXPIRED, }) await service.deleteUserMembership('user-1', 'mem-1') expect(mockPrisma.membership.findFirst).toHaveBeenCalledWith({ where: { id: 'mem-1', userId: 'user-1' }, }) expect(mockPrisma.membership.update).toHaveBeenCalledWith({ where: { id: 'mem-1' }, data: { status: MembershipStatus.EXPIRED }, }) expect(mockPrisma.membership.updateMany).not.toHaveBeenCalled() }) it('rejects when the membership is missing or belongs to another user', async () => { mockPrisma.membership.findFirst.mockResolvedValue(null) await expect(service.deleteUserMembership('user-1', 'mem-other')).rejects.toThrow( NotFoundException, ) expect(mockPrisma.membership.update).not.toHaveBeenCalled() }) it('rejects a missing membershipId', async () => { await expect(service.deleteUserMembership('user-1', '')).rejects.toThrow( BadRequestException, ) expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled() }) }) })