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 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', 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: [], updatedAt: new Date(), } // ─── Mock factory ───────────────────────────────────────────────────────── function buildTxMock(overrides: Record = {}) { return { 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(), }, bookingStatusHistory: { create: 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 } 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(), }, 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(), }, }, { 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('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(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID) }) }) // ─── createBooking ──────────────────────────────────────────────────────── describe('createBooking', () => { const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID } 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('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(MOCK_USER_ID, 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(MOCK_USER_ID, 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(MOCK_USER_ID, 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(MOCK_USER_ID, 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(MOCK_USER_ID, 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(MOCK_USER_ID, 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(MOCK_USER_ID, 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(MOCK_USER_ID, '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(MOCK_USER_ID, 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(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow( BadRequestException, ) }) }) // ─── 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.findMany as jest.Mock).mockResolvedValue(bookings) ;(prisma.booking.count as jest.Mock).mockResolvedValue(1) 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 }, }), ) }) }) 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('2026-04-19') 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: '2026-04-19', 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: '2026-04-19', startTime: '11:00', endTime: '12:00', bookedCount: 1, capacity: 1, students: [ { bookingId: 'booking-02', userId: 'user-02', nickname: '李四', phone: '13800000000', 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) }) }) })