import { Test, TestingModule } from '@nestjs/testing' import { BadRequestException, ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common' import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus, UserRole } from '@mp-pilates/shared' import { BookingService } from '../booking.service' import { PrismaService } from '../../prisma/prisma.service' import { MembershipService } from '../../membership/membership.service' import { StudioService } from '../../studio/studio.service' import { SubscriptionMessageService } from '../../user/subscription-message.service' import { InviteService } from '../../invite/invite.service' // ─── Fixtures ────────────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-001' const MOCK_SLOT_ID = 'slot-001' const MOCK_MEMBERSHIP_ID = 'mem-001' const MOCK_BOOKING_ID = 'booking-001' const MEMBER_ACTOR = { id: MOCK_USER_ID, isAdmin: false } const MOCK_ADMIN_ID = 'admin-001' const ADMIN_ACTOR = { id: MOCK_ADMIN_ID, isAdmin: true } const mockTimesCardType = { id: 'ct-times-001', name: '10次卡', type: CardTypeCategory.TIMES, totalTimes: 10, durationDays: 180, price: 150000, originalPrice: null, description: null, isActive: true, sortOrder: 0, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), } const mockDurationCardType = { ...mockTimesCardType, id: 'ct-duration-001', name: '月卡', type: CardTypeCategory.DURATION, totalTimes: null, } const mockOpenSlot = { id: MOCK_SLOT_ID, date: new Date('2099-12-31'), // far future startTime: '09:00', endTime: '10:00', capacity: 5, bookedCount: 0, status: TimeSlotStatus.OPEN, source: 'TEMPLATE', templateId: null, createdAt: new Date(), updatedAt: new Date(), } const mockFullSlot = { ...mockOpenSlot, id: 'slot-full-001', bookedCount: 5, status: TimeSlotStatus.FULL, } const mockActiveMembership = { id: MOCK_MEMBERSHIP_ID, userId: MOCK_USER_ID, cardTypeId: mockTimesCardType.id, remainingTimes: 5, startDate: new Date('2024-01-01'), expireDate: new Date('2099-12-31'), status: MembershipStatus.ACTIVE, createdAt: new Date(), updatedAt: new Date(), cardType: mockTimesCardType, } const mockDurationMembership = { ...mockActiveMembership, id: 'mem-duration-001', expireDate: new Date('2100-01-01'), cardTypeId: mockDurationCardType.id, remainingTimes: null, cardType: mockDurationCardType, } const mockLimitedDurationMembership = { ...mockDurationMembership, id: 'mem-duration-limited-001', remainingTimes: 5, totalTimes: 10, } const mockExpiredMembership = { ...mockActiveMembership, id: 'mem-expired-001', status: MembershipStatus.EXPIRED, } const mockMembershipNoTimes = { ...mockActiveMembership, id: 'mem-no-times-001', remainingTimes: 0, } const mockConfirmedBooking = { id: MOCK_BOOKING_ID, userId: MOCK_USER_ID, timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID, membershipTimesDeducted: true, status: BookingStatus.CONFIRMED, cancelledAt: null, createdAt: new Date(), updatedAt: new Date(), } const mockStudioConfig = { id: 'studio-001', name: 'Test Studio', logo: null, bannerUrl: null, address: '', phone: '', latitude: null, longitude: null, cancelHoursLimit: 2, photos: [], ownerIntroduction: '', ownerCertifications: [], updatedAt: new Date(), } // ─── Mock factory ───────────────────────────────────────────────────────── function buildTxMock(overrides: Record = {}) { return { $queryRaw: jest.fn(), timeSlot: { findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), updateMany: jest.fn(), }, booking: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), }, membership: { findUnique: jest.fn(), update: jest.fn(), }, user: { findUnique: jest.fn(), }, membershipContract: { findFirst: jest.fn().mockResolvedValue(null), }, bookingStatusHistory: { create: jest.fn(), }, bodyPortraitAssessment: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn(), }, ...overrides, } } // ─── Test Suite ──────────────────────────────────────────────────────────── describe('BookingService', () => { let service: BookingService let prisma: jest.Mocked let studioService: jest.Mocked let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock sendAdminBookingCreatedMessage: jest.Mock sendBookingCancelledMessage: jest.Mock sendClassReminderMessage: jest.Mock } let inviteService: { recordQualifiedTrialBooking: jest.Mock } beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ BookingService, { provide: PrismaService, useValue: { $transaction: jest.fn(), booking: { findUnique: jest.fn(), findMany: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), groupBy: jest.fn(), }, timeSlot: { findUnique: jest.fn(), findMany: jest.fn(), update: jest.fn(), }, membership: { findUnique: jest.fn(), update: jest.fn(), }, user: { findUnique: jest.fn(), findMany: jest.fn(), }, }, }, { provide: MembershipService, useValue: { deductMembership: jest.fn(), restoreMembership: jest.fn(), getValidMembership: jest.fn(), }, }, { provide: StudioService, useValue: { getInfo: jest.fn(), }, }, { provide: SubscriptionMessageService, useValue: { sendBookingConfirmedMessage: jest.fn(), sendAdminBookingCreatedMessage: jest.fn(), sendBookingCancelledMessage: jest.fn(), sendClassReminderMessage: jest.fn(), }, }, { provide: InviteService, useValue: { recordQualifiedTrialBooking: jest.fn(), }, }, ], }).compile() service = module.get(BookingService) prisma = module.get(PrismaService) as jest.Mocked studioService = module.get(StudioService) as jest.Mocked subscriptionMessageService = module.get(SubscriptionMessageService) inviteService = module.get(InviteService) }) afterEach(() => jest.clearAllMocks()) describe('monthly period booking boundaries', () => { const planned = { ...mockLimitedDurationMembership, startDate:new Date('2099-12-30T00:00:00Z'),expireDate:new Date('2100-01-29T00:00:00Z'),status:MembershipStatus.SCHEDULED } it('lets a paid future period reserve only its own dates and keeps its quota separate',async()=>{ const tx=buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(planned) tx.booking.create.mockResolvedValue(mockConfirmedBooking) ;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({...mockConfirmedBooking,timeSlot:mockOpenSlot,membership:planned}) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) await service.createBooking(MOCK_USER_ID,{timeSlotId:MOCK_SLOT_ID,membershipId:planned.id}) expect(tx.booking.create).toHaveBeenCalledWith(expect.objectContaining({data:expect.objectContaining({membershipId:planned.id})})) expect(tx.membership.update).not.toHaveBeenCalled() }) it.each(['before','after'])('rejects a course %s the monthly period before writing a booking',async boundary=>{ const tx=buildTxMock() const membership=boundary==='before'?{...planned,startDate:new Date('2100-01-01T00:00:00Z')}:{...planned,expireDate:new Date('2099-12-31T00:00:00Z')} tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(membership) ;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx)) await expect(service.createBooking(MOCK_USER_ID,{timeSlotId:MOCK_SLOT_ID,membershipId:membership.id})).rejects.toThrow('周期') expect(tx.booking.create).not.toHaveBeenCalled() }) it('deducts a reserved future period without activating it ahead of time',async()=>{ const tx=buildTxMock() tx.booking.findUnique.mockResolvedValue({...mockConfirmedBooking,status:BookingStatus.PENDING_CONFIRMATION,timeSlot:mockOpenSlot,membership:planned,membershipId:planned.id}) tx.booking.update.mockResolvedValue(mockConfirmedBooking) ;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({...mockConfirmedBooking,timeSlot:mockOpenSlot,membership:planned}) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null) studioService.getInfo.mockResolvedValue(mockStudioConfig as never) await service.confirmBooking(MOCK_BOOKING_ID,MOCK_ADMIN_ID) expect(tx.membership.update).toHaveBeenCalledWith({where:{id:planned.id},data:{remainingTimes:4,status:MembershipStatus.SCHEDULED}}) }) it('rejects administrative arrangement outside the selected period',async()=>{ const tx=buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.user.findUnique.mockResolvedValue({id:MOCK_USER_ID}) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue({...planned,startDate:new Date('2100-01-01T00:00:00Z')}) ;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID,{userId:MOCK_USER_ID,timeSlotId:MOCK_SLOT_ID,membershipId:planned.id})).rejects.toThrow('周期') expect(tx.timeSlot.updateMany).not.toHaveBeenCalled() }) }) describe('confirmBooking', () => { it('sends booking confirmed subscription message after admin confirmation', async () => { const tx = buildTxMock({ bookingStatusHistory: { create: jest.fn() }, }) tx.booking.findUnique.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, timeSlot: mockOpenSlot, membership: mockActiveMembership, }) tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CONFIRMED, confirmedAt: new Date('2099-12-30T00:00:00Z'), }) tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1, status: TimeSlotStatus.OPEN }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CONFIRMED, confirmedAt: new Date('2099-12-30T00:00:00Z'), timeSlot: mockOpenSlot, membership: mockActiveMembership, }) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' }) studioService.getInfo.mockResolvedValue({ ...mockStudioConfig, name: 'FocusCore Pilates', }) subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true) await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001') expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalledWith({ openid: 'openid-001', bookingId: MOCK_BOOKING_ID, bookingContent: '预约已确认', bookingTime: '2099-12-31 09:00', courseName: 'FocusCore Pilates', bookingEndTime: '2099-12-31 10:00', }) expect(tx.booking.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ membershipTimesDeducted: true }), }), ) }) it('deducts a count-limited DURATION membership on confirmation', async () => { const tx = buildTxMock() tx.booking.findUnique.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockLimitedDurationMembership.id, status: BookingStatus.PENDING_CONFIRMATION, timeSlot: mockOpenSlot, membership: mockLimitedDurationMembership, }) tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockLimitedDurationMembership.id, status: BookingStatus.CONFIRMED, }) tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 }) tx.membership.update.mockResolvedValue({ ...mockLimitedDurationMembership, remainingTimes: 4, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockLimitedDurationMembership.id, status: BookingStatus.CONFIRMED, timeSlot: mockOpenSlot, membership: mockLimitedDurationMembership, }) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' }) studioService.getInfo.mockResolvedValue(mockStudioConfig) subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true) await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001') expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: mockLimitedDurationMembership.id }, data: { remainingTimes: 4, status: MembershipStatus.ACTIVE }, }), ) expect(tx.booking.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ membershipTimesDeducted: true }), }), ) }) it('records no deduction for an unlimited DURATION membership on confirmation', async () => { const tx = buildTxMock() tx.booking.findUnique.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id, status: BookingStatus.PENDING_CONFIRMATION, timeSlot: mockOpenSlot, membership: mockDurationMembership, }) tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id, membershipTimesDeducted: false, status: BookingStatus.CONFIRMED, }) tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id, membershipTimesDeducted: false, status: BookingStatus.CONFIRMED, timeSlot: mockOpenSlot, membership: mockDurationMembership, }) await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001') expect(tx.membership.update).not.toHaveBeenCalled() expect(tx.booking.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ membershipTimesDeducted: false }), }), ) }) }) describe('completeBooking', () => { it('records qualified trial booking after completion', async () => { const tx = buildTxMock({ bookingStatusHistory: { create: jest.fn() }, }) tx.booking.findUnique.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CONFIRMED, timeSlot: mockOpenSlot, }) tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.COMPLETED, completedAt: new Date('2099-12-31T11:00:00Z'), }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.COMPLETED, completedAt: new Date('2099-12-31T11:00:00Z'), timeSlot: mockOpenSlot, membership: { ...mockActiveMembership, cardType: { ...mockTimesCardType, type: CardTypeCategory.TRIAL, }, }, }) await service.completeBooking(MOCK_BOOKING_ID, 'admin-001') expect(tx.booking.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: BookingStatus.COMPLETED, reviewReminderDueAt: expect.any(Date), }), })) expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID) }) }) // ─── createBooking ──────────────────────────────────────────────────────── describe('createBooking', () => { const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID } it('blocks booking until the purchase contract is signed', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.membershipContract.findFirst.mockResolvedValue({ id: 'contract-1' }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow('请先签署会员卡合同') expect(tx.membershipContract.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { membershipId: mockActiveMembership.id, status: 'PENDING_SIGN', requireBeforeBooking: true }, })) expect(tx.booking.create).not.toHaveBeenCalled() }) it('creates booking in pending confirmation status', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) // no duplicate tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) // Mock the re-fetch after transaction ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, timeSlot: mockOpenSlot, membership: mockActiveMembership, }) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) const result = await service.createBooking(MOCK_USER_ID, dto) expect(tx.booking.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: MOCK_USER_ID, timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID, membershipTimesDeducted: false, status: BookingStatus.PENDING_CONFIRMATION, }), }), ) expect(tx.timeSlot.update).not.toHaveBeenCalled() expect(tx.membership.update).not.toHaveBeenCalled() expect(result).toBeDefined() }) it('binds anonymous originAssessmentId to current user on booking create', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'anon-assess', userId: null }) tx.bodyPortraitAssessment.update.mockResolvedValue({ id: 'anon-assess', userId: MOCK_USER_ID }) tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, originAssessmentId: 'anon-assess', status: BookingStatus.PENDING_CONFIRMATION, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, originAssessmentId: 'anon-assess', status: BookingStatus.PENDING_CONFIRMATION, timeSlot: mockOpenSlot, membership: mockActiveMembership, }) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) await service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'anon-assess' }) expect(tx.bodyPortraitAssessment.update).toHaveBeenCalledWith({ where: { id: 'anon-assess' }, data: { userId: MOCK_USER_ID, status: 'CLAIMED', claimedAt: expect.any(Date) }, }) expect(tx.booking.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ originAssessmentId: 'anon-assess', }), }), ) }) it('rejects originAssessmentId belonging to another user', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'other-assess', userId: 'someone-else' }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect( service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'other-assess' }), ).rejects.toThrow('画像报告不属于当前用户') }) it('records booking status history when user creates a booking', async () => { const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 } const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, timeSlot: nearFullSlot, membership: mockActiveMembership, }) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) await service.createBooking(MOCK_USER_ID, dto) expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ toStatus: BookingStatus.PENDING_CONFIRMATION, operatorId: MOCK_USER_ID, }), }), ) }) it('does NOT deduct membership for DURATION card', async () => { const durationDto = { timeSlotId: MOCK_SLOT_ID, membershipId: mockDurationMembership.id } const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockDurationMembership) tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id }) tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id, timeSlot: mockOpenSlot, membership: mockDurationMembership, }) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) await service.createBooking(MOCK_USER_ID, durationDto) // DURATION card: membership.update should NOT be called expect(tx.membership.update).not.toHaveBeenCalled() }) it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes) tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockMembershipNoTimes.id, status: BookingStatus.PENDING_CONFIRMATION, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockMembershipNoTimes.id, status: BookingStatus.PENDING_CONFIRMATION, timeSlot: mockOpenSlot, membership: mockMembershipNoTimes, }) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) await service.createBooking(MOCK_USER_ID, dto) expect(tx.membership.update).not.toHaveBeenCalled() }) it('sends admin booking created subscription message to admins with remaining count', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, timeSlot: mockOpenSlot, membership: mockActiveMembership, }) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([ { openid: 'admin-openid-1' }, ]) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ nickname: 'Alice', phone: '13800000000' }) studioService.getInfo.mockResolvedValue({ ...mockStudioConfig, name: 'FocusCore Pilates', }) subscriptionMessageService.sendAdminBookingCreatedMessage.mockResolvedValue(true) await service.createBooking(MOCK_USER_ID, dto) expect(prisma.user.findMany).toHaveBeenCalledWith({ where: { role: UserRole.ADMIN, adminBookingSubscriptionCount: { gt: 0 }, }, select: { openid: true, }, }) expect(subscriptionMessageService.sendAdminBookingCreatedMessage).toHaveBeenCalledWith({ openid: 'admin-openid-1', bookingId: MOCK_BOOKING_ID, bookingContent: 'Alice已预约', bookingTime: '2099-12-31 09:00', courseName: 'FocusCore Pilates', bookingEndTime: '2099-12-31 10:00', }) }) it('throws BadRequestException when slot is FULL', async () => { const fullDto = { timeSlotId: mockFullSlot.id, membershipId: MOCK_MEMBERSHIP_ID } const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.createBooking(MOCK_USER_ID, fullDto)).rejects.toThrow( BadRequestException, ) }) it('throws ConflictException on duplicate booking (same user + slot)', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(mockConfirmedBooking) // duplicate exists ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( ConflictException, ) }) it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockExpiredMembership) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( BadRequestException, ) }) it('throws NotFoundException when timeSlot does not exist', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(null) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( NotFoundException, ) }) it('throws ForbiddenException when membership belongs to another user', async () => { const otherUserMembership = { ...mockActiveMembership, userId: 'other-user' } const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(otherUserMembership) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( ForbiddenException, ) }) it('reuses a cancelled booking record when booking the same slot again', async () => { const cancelledBooking = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, membershipId: 'mem-old-001', cancelledAt: new Date('2099-12-30T00:00:00Z'), confirmedAt: new Date('2099-12-29T00:00:00Z'), completedAt: null, operatorId: 'admin-001', } const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(cancelledBooking) tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.booking.update.mockResolvedValue({ ...cancelledBooking, membershipId: MOCK_MEMBERSHIP_ID, status: BookingStatus.PENDING_CONFIRMATION, cancelledAt: null, confirmedAt: null, operatorId: null, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, timeSlot: mockOpenSlot, membership: mockActiveMembership, }) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) await service.createBooking(MOCK_USER_ID, dto) expect(tx.booking.create).not.toHaveBeenCalled() expect(tx.booking.update).toHaveBeenCalledWith({ where: { id: MOCK_BOOKING_ID }, data: { membershipId: MOCK_MEMBERSHIP_ID, membershipTimesDeducted: false, status: BookingStatus.PENDING_CONFIRMATION, cancelledAt: null, confirmedAt: null, completedAt: null, operatorId: null, }, }) expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ bookingId: MOCK_BOOKING_ID, fromStatus: BookingStatus.CANCELLED, toStatus: BookingStatus.PENDING_CONFIRMATION, remark: '学员重新发起预约', }), }), ) }) }) // ─── cancelBooking ──────────────────────────────────────────────────────── describe('cancelBooking', () => { // Slot starts 24h from now → within the 2-hour limit → refund eligible const futureDate = new Date(Date.now() + 24 * 3600 * 1000) const futureSlot = { ...mockOpenSlot, date: futureDate, startTime: `${String(futureDate.getUTCHours()).padStart(2, '0')}:${String(futureDate.getUTCMinutes()).padStart(2, '0')}`, } // Slot starts 30 minutes from now → past the 2-hour limit → no refund const imminentDate = new Date(Date.now() + 30 * 60 * 1000) const imminentSlot = { ...mockOpenSlot, id: 'slot-imminent-001', date: imminentDate, startTime: `${String(imminentDate.getUTCHours()).padStart(2, '0')}:${String(imminentDate.getUTCMinutes()).padStart(2, '0')}`, } beforeEach(() => { studioService.getInfo.mockResolvedValue(mockStudioConfig) }) it('cancels booking within limit: decrements bookedCount and refunds membership', async () => { const bookingWithRelations = { ...mockConfirmedBooking, timeSlot: { ...futureSlot, bookedCount: 3 }, membership: mockActiveMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations) const tx = buildTxMock() const cancelledBooking = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() } tx.booking.update.mockResolvedValue(cancelledBooking) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 2 }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.booking.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: BookingStatus.CANCELLED }), }), ) // bookedCount decremented: 3 → 2 expect(tx.timeSlot.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ bookedCount: 2 }), }), ) // Membership restored: 5 → 6 expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 6 }), }), ) expect(result.refunded).toBe(true) }) it('restores a count-limited DURATION membership when cancelled within the limit', async () => { const limitedDurationMembership = { ...mockLimitedDurationMembership, remainingTimes: 4, } const bookingWithRelations = { ...mockConfirmedBooking, membershipId: limitedDurationMembership.id, timeSlot: { ...futureSlot, bookedCount: 1 }, membership: limitedDurationMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, membershipId: limitedDurationMembership.id, status: BookingStatus.CANCELLED, }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) tx.membership.update.mockResolvedValue({ ...limitedDurationMembership, remainingTimes: 5, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: limitedDurationMembership.id }, data: expect.objectContaining({ remainingTimes: 5, status: MembershipStatus.ACTIVE, }), }), ) expect(result.refunded).toBe(true) }) it('does not restore an unlimited DURATION membership when cancelled within the limit', async () => { const bookingWithRelations = { ...mockConfirmedBooking, membershipId: mockDurationMembership.id, membershipTimesDeducted: false, timeSlot: { ...futureSlot, bookedCount: 1 }, membership: mockDurationMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id, status: BookingStatus.CANCELLED, }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.membership.update).not.toHaveBeenCalled() expect(result.refunded).toBe(false) }) it('does not refund an originally unlimited membership after it is changed to counted', async () => { const bookingWithRelations = { ...mockConfirmedBooking, membershipId: mockLimitedDurationMembership.id, membershipTimesDeducted: false, timeSlot: { ...futureSlot, bookedCount: 1 }, membership: { ...mockLimitedDurationMembership, remainingTimes: 10, }, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockLimitedDurationMembership.id, membershipTimesDeducted: false, status: BookingStatus.CANCELLED, }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.membership.update).not.toHaveBeenCalled() expect(result.refunded).toBe(false) }) it('cancels booking past limit: does NOT refund membership', async () => { const bookingWithImminent = { ...mockConfirmedBooking, timeSlot: { ...imminentSlot, bookedCount: 1 }, membership: mockActiveMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithImminent) const tx = buildTxMock() const cancelledBooking = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() } tx.booking.update.mockResolvedValue(cancelledBooking) tx.timeSlot.update.mockResolvedValue({ ...imminentSlot, bookedCount: 0 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(result.refunded).toBe(false) // membership.update must NOT be called expect(tx.membership.update).not.toHaveBeenCalled() }) it('changes slot from FULL to OPEN after cancellation', async () => { const fullSlotWithBooking = { ...mockConfirmedBooking, timeSlot: { ...futureSlot, bookedCount: 5, capacity: 5, status: TimeSlotStatus.FULL }, membership: mockActiveMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(fullSlotWithBooking) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 4, status: TimeSlotStatus.OPEN }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) // slot was FULL → should be restored to OPEN expect(tx.timeSlot.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ bookedCount: 4, status: TimeSlotStatus.OPEN, }), }), ) }) it('restores USED_UP membership back to ACTIVE when cancelled within limit', async () => { const usedUpMembership = { ...mockActiveMembership, remainingTimes: 0, status: MembershipStatus.USED_UP, } const bookingWithUsedUp = { ...mockConfirmedBooking, timeSlot: { ...futureSlot, bookedCount: 1 }, membership: usedUpMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithUsedUp) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) tx.membership.update.mockResolvedValue({ ...usedUpMembership, remainingTimes: 1, status: MembershipStatus.ACTIVE }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 1, status: MembershipStatus.ACTIVE, }), }), ) expect(result.refunded).toBe(true) }) it('throws NotFoundException when booking does not exist', async () => { ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(null) await expect(service.cancelBooking(MEMBER_ACTOR, 'nonexistent')).rejects.toThrow( NotFoundException, ) }) it('throws ForbiddenException when booking belongs to another user', async () => { const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherBooking) await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow( ForbiddenException, ) }) it('throws BadRequestException when booking is already CANCELLED', async () => { const cancelledBooking = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, timeSlot: futureSlot, membership: mockActiveMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking) await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow( BadRequestException, ) }) // ─── Admin actor branch ───────────────────────────────────────────────── it('admin actor can cancel another user\'s booking (skips owner check)', async () => { const otherUserBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: { ...futureSlot, bookedCount: 1 }, membership: mockActiveMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) const result = await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID) expect(result.refunded).toBe(true) expect(tx.booking.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: BookingStatus.CANCELLED }), }), ) }) it('admin actor records 管理员 remark in bookingStatusHistory', async () => { const otherUserBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: { ...futureSlot, bookedCount: 1 }, membership: mockActiveMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID) expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ operatorId: MOCK_ADMIN_ID, remark: '管理员取消预约(超时退款)', }), }), ) }) it('member actor records 学员 remark when cancelling own booking', async () => { const ownBooking = { ...mockConfirmedBooking, timeSlot: { ...futureSlot, bookedCount: 1 }, membership: mockActiveMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ operatorId: MOCK_USER_ID, remark: '学员取消预约(超时退款)', }), }), ) }) it('triggers sendBookingCancelledMessage when a booking is cancelled', async () => { const futureDate = new Date(Date.now() + 86400000 * 2) const futureSlot = { ...mockOpenSlot, date: futureDate, startTime: '14:00', endTime: '15:00' } const ownBooking = { ...mockConfirmedBooking, userId: MOCK_USER_ID, timeSlot: futureSlot, membership: mockActiveMembership, } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: MOCK_USER_ID, openid: 'test-user-openid' } as any) const tx = buildTxMock() tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(subscriptionMessageService.sendBookingCancelledMessage).toHaveBeenCalledWith( expect.objectContaining({ openid: 'test-user-openid', userId: MOCK_USER_ID, bookingId: MOCK_BOOKING_ID, courseName: 'Test Studio', }), ) }) }) // ─── getMyBookings ──────────────────────────────────────────────────────── describe('getMyBookings', () => { it('returns paginated list of bookings for the user', async () => { const bookings = [ { ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: mockActiveMembership }, ] ;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings) ;(prisma.booking.count as jest.Mock).mockResolvedValue(1) const result = await service.getMyBookings(MOCK_USER_ID) expect(result.total).toBe(1) expect(result.page).toBe(1) expect(result.limit).toBe(10) expect(result.data).toHaveLength(1) }) it('filters by status when provided', async () => { ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.booking.count as jest.Mock).mockResolvedValue(0) await service.getMyBookings(MOCK_USER_ID, BookingStatus.CANCELLED) expect(prisma.booking.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: BookingStatus.CANCELLED, }), }), ) }) it('uses default pagination when page/limit not provided', async () => { ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.booking.count as jest.Mock).mockResolvedValue(0) const result = await service.getMyBookings(MOCK_USER_ID) expect(prisma.booking.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 0, take: 10 }), ) expect(result.page).toBe(1) expect(result.limit).toBe(10) }) }) // ─── getUpcomingBookings ────────────────────────────────────────────────── describe('getUpcomingBookings', () => { it('returns confirmed bookings with future dates, ordered by date and startTime', async () => { const upcoming = [ { ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: mockActiveMembership }, ] ;(prisma.booking.findMany as jest.Mock).mockResolvedValue(upcoming) const result = await service.getUpcomingBookings(MOCK_USER_ID) expect(prisma.booking.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ userId: MOCK_USER_ID, status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] }, }), orderBy: [ { timeSlot: { date: 'asc' } }, { timeSlot: { startTime: 'asc' } }, ], }), ) expect(result).toHaveLength(1) }) }) // ─── getAllBookings (admin) ──────────────────────────────────────────────── describe('getAllBookings', () => { it('returns paginated list of all bookings with user info', async () => { const bookings = [ { ...mockConfirmedBooking, user: { id: MOCK_USER_ID, nickname: 'Test User', phone: null }, timeSlot: mockOpenSlot, membership: mockActiveMembership, }, ] ;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([ { status: BookingStatus.CONFIRMED, _count: { _all: 1 } }, ]) ;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings) const result = await service.getAllBookings(1, 10) expect(result.total).toBe(1) expect(result.data[0]).toHaveProperty('user') }) it('filters by status when provided', async () => { ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.booking.count as jest.Mock).mockResolvedValue(0) await service.getAllBookings(1, 10, BookingStatus.CONFIRMED) expect(prisma.booking.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { status: BookingStatus.CONFIRMED }, }), ) }) it('sorts the unfiltered list by status priority and pages across segments', async () => { // 全部视图:待确认 → 已确认 → 已完成 → 已取消,未到归入已完成之后 ;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([ { status: BookingStatus.PENDING_CONFIRMATION, _count: { _all: 1 } }, { status: BookingStatus.CONFIRMED, _count: { _all: 2 } }, { status: BookingStatus.COMPLETED, _count: { _all: 1 } }, { status: BookingStatus.CANCELLED, _count: { _all: 1 } }, ]) const byStatus: Record = { [BookingStatus.PENDING_CONFIRMATION]: [{ id: 'b-pending' }], [BookingStatus.CONFIRMED]: [{ id: 'b-confirmed-1' }, { id: 'b-confirmed-2' }], [BookingStatus.COMPLETED]: [{ id: 'b-completed' }], [BookingStatus.CANCELLED]: [{ id: 'b-cancelled' }], } ;(prisma.booking.findMany as jest.Mock).mockImplementation( (args: { where: { status: string } }) => byStatus[args.where.status] ?? [], ) const page1 = await service.getAllBookings(1, 3) expect(page1.total).toBe(5) expect(page1.data.map((b) => b.id)).toEqual(['b-pending', 'b-confirmed-1', 'b-confirmed-2']) const page2 = await service.getAllBookings(2, 3) expect(page2.data.map((b) => b.id)).toEqual(['b-completed', 'b-cancelled']) // 段内排序:已确认按上课时间正序,其余按创建时间倒序 const confirmedCall = (prisma.booking.findMany as jest.Mock).mock.calls.find( ([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.CONFIRMED, ) expect(confirmedCall[0].orderBy).toEqual([ { timeSlot: { date: 'asc' } }, { timeSlot: { startTime: 'asc' } }, ]) const pendingCall = (prisma.booking.findMany as jest.Mock).mock.calls.find( ([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.PENDING_CONFIRMATION, ) expect(pendingCall[0].orderBy).toEqual({ createdAt: 'desc' }) }) }) describe('getTeachingScheduleByDate', () => { it('returns sorted slots with active students only', async () => { ;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([ { id: 'slot-02', startTime: '11:00', endTime: '12:00', bookedCount: 1, capacity: 1, bookings: [ { id: 'booking-02', status: BookingStatus.CONFIRMED, createdAt: new Date('2026-04-19T01:00:00Z'), user: { id: 'user-02', nickname: '李四', phone: '13800000000' }, }, ], }, { id: 'slot-01', startTime: '09:00', endTime: '10:00', bookedCount: 2, capacity: 2, bookings: [ { id: 'booking-01', status: BookingStatus.PENDING_CONFIRMATION, createdAt: new Date('2026-04-19T00:00:00Z'), user: { id: 'user-01', nickname: '张三', phone: null }, }, ], }, ]) const result = await service.getTeachingScheduleByDate('2099-12-31') expect(prisma.timeSlot.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ bookings: { some: { status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] }, }, }, }), orderBy: [ { startTime: 'asc' }, { endTime: 'asc' }, ], }), ) expect(result).toEqual([ { slotId: 'slot-01', date: '2099-12-31', startTime: '09:00', endTime: '10:00', bookedCount: 2, capacity: 2, students: [ { bookingId: 'booking-01', userId: 'user-01', nickname: '张三', phone: null, status: BookingStatus.PENDING_CONFIRMATION, }, ], }, { slotId: 'slot-02', date: '2099-12-31', startTime: '11:00', endTime: '12:00', bookedCount: 1, capacity: 1, students: [ { bookingId: 'booking-02', userId: 'user-02', nickname: '李四', phone: '13800000000', status: BookingStatus.CONFIRMED, }, ], }, ]) }) it('returns all-status bookings when the date is today', async () => { const now = new Date() const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` ;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([ { id: 'slot-today', startTime: '10:00', endTime: '11:00', bookedCount: 2, capacity: 3, bookings: [ { id: 'booking-completed', status: BookingStatus.COMPLETED, createdAt: new Date(`${today}T00:00:00Z`), user: { id: 'user-1', nickname: '完成', phone: '13800000001' }, }, { id: 'booking-no-show', status: BookingStatus.NO_SHOW, createdAt: new Date(`${today}T00:00:01Z`), user: { id: 'user-2', nickname: '未到', phone: null }, }, { id: 'booking-cancelled', status: BookingStatus.CANCELLED, createdAt: new Date(`${today}T00:00:02Z`), user: { id: 'user-3', nickname: '取消', phone: '13800000003' }, }, { id: 'booking-confirmed', status: BookingStatus.CONFIRMED, createdAt: new Date(`${today}T00:00:03Z`), user: { id: 'user-4', nickname: '确认', phone: null }, }, ], }, ]) const result = await service.getTeachingScheduleByDate(today) // 当日课表:timeSlot 仍用 EXISTS 守卫过滤完全没人预约的空 slot(不带状态条件), // 且 included bookings 不再限制状态。 const callArg = (prisma.timeSlot.findMany as jest.Mock).mock.calls[0][0] expect(callArg.where).toEqual({ date: expect.any(Date), bookings: { some: {} }, }) expect(callArg.include.bookings.where).toBeUndefined() expect(result).toEqual([ { slotId: 'slot-today', date: today, startTime: '10:00', endTime: '11:00', bookedCount: 2, capacity: 3, students: [ { bookingId: 'booking-completed', userId: 'user-1', nickname: '完成', phone: '13800000001', status: BookingStatus.COMPLETED, }, { bookingId: 'booking-no-show', userId: 'user-2', nickname: '未到', phone: null, status: BookingStatus.NO_SHOW, }, { bookingId: 'booking-cancelled', userId: 'user-3', nickname: '取消', phone: '13800000003', status: BookingStatus.CANCELLED, }, { bookingId: 'booking-confirmed', userId: 'user-4', nickname: '确认', phone: null, status: BookingStatus.CONFIRMED, }, ], }, ]) }) it('rejects invalid date input', async () => { await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow( BadRequestException, ) }) }) describe('adminArrangeBooking', () => { const MOCK_ADMIN_ID = 'admin-001' const dto = { userId: MOCK_USER_ID, timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID, } const mockTrialCardType = { ...mockTimesCardType, id: 'ct-trial-001', name: '体验卡', type: CardTypeCategory.TRIAL, totalTimes: 1, } const mockTrialMembership = { ...mockActiveMembership, id: 'mem-trial-001', cardTypeId: mockTrialCardType.id, remainingTimes: 1, cardType: mockTrialCardType, } function stubArrangeSuccess( tx: ReturnType, options?: { membership?: typeof mockActiveMembership | typeof mockDurationMembership | typeof mockLimitedDurationMembership | typeof mockTrialMembership slot?: typeof mockOpenSlot existing?: typeof mockConfirmedBooking | null }, ) { const membership = options?.membership ?? mockActiveMembership const slot = options?.slot ?? mockOpenSlot const existing = options?.existing ?? null const arranged = { ...mockConfirmedBooking, membershipId: membership.id, status: BookingStatus.CONFIRMED, confirmedAt: new Date(), operatorId: MOCK_ADMIN_ID, } tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID }) tx.timeSlot.findUnique .mockResolvedValueOnce(slot) .mockResolvedValueOnce({ ...slot, bookedCount: slot.bookedCount + 1 }) tx.booking.findUnique.mockResolvedValue(existing) tx.membership.findUnique.mockResolvedValue(membership) tx.booking.create.mockResolvedValue(arranged) tx.booking.update.mockResolvedValue(arranged) tx.timeSlot.updateMany.mockResolvedValue({ count: 1 }) tx.timeSlot.update.mockResolvedValue({ ...slot, bookedCount: slot.bookedCount + 1 }) tx.membership.update.mockResolvedValue({ ...membership, remainingTimes: membership.remainingTimes == null ? null : membership.remainingTimes - 1, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...arranged, timeSlot: slot, membership, }) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' }) studioService.getInfo.mockResolvedValue({ ...mockStudioConfig, name: 'FocusCore Pilates', }) subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true) return arranged } it('creates a confirmed times-card booking and deducts one session', async () => { const tx = buildTxMock() stubArrangeSuccess(tx) const result = await service.adminArrangeBooking(MOCK_ADMIN_ID, dto) expect(tx.booking.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: MOCK_USER_ID, timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID, membershipTimesDeducted: true, status: BookingStatus.CONFIRMED, operatorId: MOCK_ADMIN_ID, }), }), ) expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 4, status: MembershipStatus.ACTIVE }), }), ) expect(tx.timeSlot.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: MOCK_SLOT_ID, status: TimeSlotStatus.OPEN, bookedCount: { lt: mockOpenSlot.capacity }, }), data: { bookedCount: { increment: 1 } }, }), ) expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ toStatus: BookingStatus.CONFIRMED, remark: '老师代为安排', operatorId: MOCK_ADMIN_ID, }), }), ) expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalled() expect(subscriptionMessageService.sendAdminBookingCreatedMessage).not.toHaveBeenCalled() expect(result.status).toBe(BookingStatus.CONFIRMED) }) it('does not deduct remaining times for unlimited duration cards', async () => { const tx = buildTxMock() stubArrangeSuccess(tx, { membership: mockDurationMembership }) await service.adminArrangeBooking(MOCK_ADMIN_ID, { ...dto, membershipId: mockDurationMembership.id, }) expect(tx.membership.update).not.toHaveBeenCalled() expect(tx.booking.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ membershipTimesDeducted: false }), }), ) }) it('deducts remaining times for count-limited duration cards', async () => { const tx = buildTxMock() stubArrangeSuccess(tx, { membership: mockLimitedDurationMembership }) await service.adminArrangeBooking(MOCK_ADMIN_ID, { ...dto, membershipId: mockLimitedDurationMembership.id, }) expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: mockLimitedDurationMembership.id }, data: { remainingTimes: 4, status: MembershipStatus.ACTIVE }, }), ) expect(tx.booking.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ membershipTimesDeducted: true }), }), ) }) it('deducts a trial card session', async () => { const tx = buildTxMock() stubArrangeSuccess(tx, { membership: mockTrialMembership }) await service.adminArrangeBooking(MOCK_ADMIN_ID, { ...dto, membershipId: mockTrialMembership.id, }) expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 0, status: MembershipStatus.USED_UP, }), }), ) }) it('rejects when the times card has no remaining sessions', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID }) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow( BadRequestException, ) expect(tx.booking.create).not.toHaveBeenCalled() }) it('rejects when a duration card has expired', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID }) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue({ ...mockDurationMembership, expireDate: new Date('2020-01-01'), }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect( service.adminArrangeBooking(MOCK_ADMIN_ID, { ...dto, membershipId: mockDurationMembership.id, }), ).rejects.toThrow(BadRequestException) }) it('rejects when the time slot is full', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow( BadRequestException, ) expect(tx.membership.findUnique).not.toHaveBeenCalled() }) it('rejects duplicate active bookings for the same slot', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID }) tx.booking.findUnique.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CONFIRMED, }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow( ConflictException, ) }) it('rejects arranging a past time slot', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue({ ...mockOpenSlot, date: new Date('2020-01-01T00:00:00Z'), startTime: '09:00', }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow( BadRequestException, ) }) it('revives a cancelled booking instead of creating a new row', async () => { const tx = buildTxMock() const cancelled = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, } stubArrangeSuccess(tx, { existing: cancelled }) await service.adminArrangeBooking(MOCK_ADMIN_ID, dto) expect(tx.booking.create).not.toHaveBeenCalled() expect(tx.booking.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: cancelled.id }, data: expect.objectContaining({ status: BookingStatus.CONFIRMED, operatorId: MOCK_ADMIN_ID, }), }), ) expect(tx.timeSlot.updateMany).toHaveBeenCalled() expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 4 }), }), ) }) it('rejects when the member does not exist', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.user.findUnique.mockResolvedValue(null) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow( NotFoundException, ) }) it('rejects when the membership belongs to another member', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID }) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue({ ...mockActiveMembership, userId: 'other-user', }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow( ForbiddenException, ) }) it('rejects an expired times card even if remaining sessions exist', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID }) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue({ ...mockActiveMembership, expireDate: new Date('2020-01-01'), }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow( BadRequestException, ) expect(tx.timeSlot.updateMany).not.toHaveBeenCalled() }) it('rejects when occupancy update races and the slot is already full', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID }) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockActiveMembership) tx.timeSlot.updateMany.mockResolvedValue({ count: 0 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow( BadRequestException, ) expect(tx.booking.create).not.toHaveBeenCalled() }) }) describe('getPracticeActivity', () => { afterEach(() => jest.restoreAllMocks()) it('uses China today across UTC midnight and counts scheduled dates without pagination', async () => { jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-07T16:01:00Z')) ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([ { timeSlot: { date: new Date('2026-08-10T00:00:00Z') } }, { timeSlot: { date: new Date('2026-09-08T00:00:00Z') } }, { timeSlot: { date: new Date('2026-09-08T00:00:00Z') } }, ]) const result = await service.getPracticeActivity(MOCK_USER_ID) expect(result.days).toHaveLength(30) expect(result.days[0]).toEqual({ date: '2026-08-10', count: 1 }) expect(result.days[29]).toEqual({ date: '2026-09-08', count: 2 }) expect(result.days[1]).toEqual({ date: '2026-08-11', count: 0 }) expect(prisma.booking.findMany).toHaveBeenCalledWith({ where: { userId: MOCK_USER_ID, status: BookingStatus.COMPLETED, timeSlot: { date: { gte: new Date('2026-08-10T00:00:00Z'), lt: new Date('2026-09-09T00:00:00Z'), } }, }, select: { timeSlot: { select: { date: true } } }, }) }) it('returns every day with zero counts across a leap-year boundary', async () => { jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2024-03-01T01:00:00Z')) ;(prisma.booking.findMany as jest.Mock).mockResolvedValue([]) const result = await service.getPracticeActivity(MOCK_USER_ID) expect(result.days).toHaveLength(30) expect(result.days[0].date).toBe('2024-02-01') expect(result.days[28].date).toBe('2024-02-29') expect(result.days[29].date).toBe('2024-03-01') expect(result.days.every(day => day.count === 0)).toBe(true) }) }) })