import { Test, TestingModule } from '@nestjs/testing' import { NotFoundException } from '@nestjs/common' import { UserService } from '../user.service' import { PrismaService } from '../../prisma/prisma.service' import { MembershipStatus, BookingStatus, UserRole, 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, 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 = { user: { findUnique: jest.fn(), update: jest.fn(), }, subscriptionMessageConsent: { upsert: jest.fn(), findMany: jest.fn(), }, booking: { findMany: 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, subscriptionMessageTemplates: { templates: [ { templateId: 'tmpl-booking-confirmed', scene: SubscriptionMessageScene.BOOKING_CREATED, description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送', }, ], }, 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.subscriptionMessageTemplates.templates).toHaveLength(1) }) 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('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 }, }), ) }) }) })