import { Test, TestingModule } from '@nestjs/testing' import { BadRequestException, ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common' import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } 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' // ─── 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 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, 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(), }, booking: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), }, membership: { findUnique: jest.fn(), update: jest.fn(), }, ...overrides, } } // ─── Test Suite ──────────────────────────────────────────────────────────── describe('BookingService', () => { let service: BookingService let prisma: jest.Mocked let studioService: jest.Mocked 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(), update: jest.fn(), }, membership: { findUnique: jest.fn(), update: jest.fn(), }, }, }, { provide: MembershipService, useValue: { deductMembership: jest.fn(), restoreMembership: jest.fn(), getValidMembership: jest.fn(), }, }, { provide: StudioService, useValue: { getInfo: jest.fn(), }, }, ], }).compile() service = module.get(BookingService) prisma = module.get(PrismaService) as jest.Mocked studioService = module.get(StudioService) as jest.Mocked }) afterEach(() => jest.clearAllMocks()) // ─── createBooking ──────────────────────────────────────────────────────── describe('createBooking', () => { const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID } it('creates booking, increments bookedCount, and deducts membership (TIMES card)', 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) tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) // Mock the re-fetch after transaction ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: mockActiveMembership, }) 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, status: BookingStatus.CONFIRMED, }), }), ) // bookedCount incremented from 0 → 1, still OPEN (capacity 5) expect(tx.timeSlot.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ bookedCount: 1, status: TimeSlotStatus.OPEN }), }), ) // membership deducted from 5 → 4 expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 4, status: MembershipStatus.ACTIVE, }), }), ) expect(result).toBeDefined() }) it('sets slot to FULL when bookedCount reaches capacity', 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) tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL }) tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, timeSlot: { ...nearFullSlot, status: TimeSlotStatus.FULL }, membership: mockActiveMembership, }) await service.createBooking(MOCK_USER_ID, dto) // bookedCount 4+1 = 5 = capacity → FULL expect(tx.timeSlot.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ bookedCount: 5, status: TimeSlotStatus.FULL }), }), ) }) 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, }) await service.createBooking(MOCK_USER_ID, durationDto) // DURATION card: membership.update should NOT be called expect(tx.membership.update).not.toHaveBeenCalled() }) it('marks membership as USED_UP when remainingTimes hits 0', async () => { const lastTimeMembership = { ...mockActiveMembership, remainingTimes: 1 } const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(lastTimeMembership) tx.booking.create.mockResolvedValue(mockConfirmedBooking) tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 }) tx.membership.update.mockResolvedValue({ ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: { ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP }, }) await service.createBooking(MOCK_USER_ID, dto) expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 0, status: MembershipStatus.USED_UP, }), }), ) }) 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 BadRequestException when TIMES membership has 0 remaining', async () => { const tx = buildTxMock() tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot) tx.booking.findUnique.mockResolvedValue(null) tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow( BadRequestException, ) expect(tx.booking.create).not.toHaveBeenCalled() }) 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, ) }) }) // ─── 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('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: 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 }, }), ) }) }) })