import { Test, TestingModule } from '@nestjs/testing' import { NotFoundException } from '@nestjs/common' import { TimeSlotService } from '../time-slot.service' import { PrismaService } from '../../prisma/prisma.service' import { TimeSlotStatus, TimeSlotSource, BookingStatus, DEFAULT_SLOT_CAPACITY, getDefaultTimeSlots, } from '@mp-pilates/shared' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const makeSlot = (overrides: Record = {}) => ({ id: 'slot-1', date: new Date('2026-04-07T00:00:00Z'), startTime: '09:00', endTime: '10:00', capacity: 2, bookedCount: 0, status: TimeSlotStatus.OPEN, source: TimeSlotSource.TEMPLATE, templateId: 'tpl-1', createdAt: new Date('2026-04-01T00:00:00Z'), updatedAt: new Date('2026-04-01T00:00:00Z'), bookings: [], ...overrides, }) // --------------------------------------------------------------------------- // Mock PrismaService // --------------------------------------------------------------------------- const mockPrisma = { timeSlot: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), createMany: jest.fn(), delete: jest.fn(), }, weekTemplate: { findMany: jest.fn(), deleteMany: jest.fn(), createMany: jest.fn(), }, $transaction: jest.fn(), } /** * Forward $transaction's callback to a tx object that shares the timeSlot * mock implementations, so transactional calls are observable the same way * as top-level ones. */ function bindTransaction() { const mockTx = { timeSlot: { findMany: mockPrisma.timeSlot.findMany, findUnique: mockPrisma.timeSlot.findUnique, create: mockPrisma.timeSlot.create, update: mockPrisma.timeSlot.update, createMany: mockPrisma.timeSlot.createMany, delete: mockPrisma.timeSlot.delete, }, } mockPrisma.$transaction.mockImplementation( async (cb: (tx: typeof mockTx) => unknown) => cb(mockTx), ) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('TimeSlotService', () => { let service: TimeSlotService beforeEach(async () => { jest.clearAllMocks() bindTransaction() const module: TestingModule = await Test.createTestingModule({ providers: [ TimeSlotService, { provide: PrismaService, useValue: mockPrisma }, ], }).compile() service = module.get(TimeSlotService) }) // ------------------------------------------------------------------------- // getAvailableSlots // ------------------------------------------------------------------------- describe('getAvailableSlots', () => { it('returns slots with isBookedByMe=false when user has no booking', async () => { const slot = makeSlot({ bookings: [] }) mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot]) const result = await service.getAvailableSlots('2026-04-07', 'user-1') expect(result).toHaveLength(1) expect(result[0].isBookedByMe).toBe(false) expect(result[0].myBookingId).toBeNull() }) it('marks isBookedByMe=true and sets my booking info when user has a CONFIRMED booking', async () => { const slot = makeSlot({ bookings: [{ id: 'booking-42', status: BookingStatus.CONFIRMED }] }) mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot]) const result = await service.getAvailableSlots('2026-04-07', 'user-1') expect(result[0].isBookedByMe).toBe(true) expect(result[0].myBookingId).toBe('booking-42') expect(result[0].myBookingStatus).toBe(BookingStatus.CONFIRMED) }) it('marks pending confirmation booking as already booked by current user', async () => { const slot = makeSlot({ bookings: [{ id: 'booking-99', status: BookingStatus.PENDING_CONFIRMATION }] }) mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot]) const result = await service.getAvailableSlots('2026-04-07', 'user-1') expect(result[0].isBookedByMe).toBe(true) expect(result[0].myBookingId).toBe('booking-99') expect(result[0].myBookingStatus).toBe(BookingStatus.PENDING_CONFIRMATION) }) it('excludes CLOSED slots from query', async () => { mockPrisma.timeSlot.findMany.mockResolvedValueOnce([]) await service.getAvailableSlots('2026-04-07', 'user-1') const where = (mockPrisma.timeSlot.findMany.mock.calls[0][0] as { where: { status: { not: TimeSlotStatus } } }).where expect(where.status).toEqual({ not: TimeSlotStatus.CLOSED }) }) it('orders results by startTime ascending', async () => { mockPrisma.timeSlot.findMany.mockResolvedValueOnce([]) await service.getAvailableSlots('2026-04-07', 'user-1') const orderBy = (mockPrisma.timeSlot.findMany.mock.calls[0][0] as { orderBy: { startTime: string } }).orderBy expect(orderBy).toEqual({ startTime: 'asc' }) }) it('returns correct date string (YYYY-MM-DD) in response', async () => { const slot = makeSlot({ date: new Date('2026-04-07T00:00:00Z'), bookings: [] }) mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot]) const result = await service.getAvailableSlots('2026-04-07', 'user-1') expect(result[0].date).toBe('2026-04-07') }) it('sets isBookedByMe=false when no userId provided', async () => { const slot = makeSlot({ bookings: [] }) mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot]) // No userId passed const result = await service.getAvailableSlots('2026-04-07') expect(result[0].isBookedByMe).toBe(false) expect(result[0].myBookingId).toBeNull() expect(result[0].myBookingStatus).toBeNull() }) it('maps multiple slots correctly', async () => { const slots = [ makeSlot({ id: 'slot-1', startTime: '09:00', bookings: [{ id: 'bk-1', status: BookingStatus.CONFIRMED }] }), makeSlot({ id: 'slot-2', startTime: '10:00', bookings: [] }), ] mockPrisma.timeSlot.findMany.mockResolvedValueOnce(slots) const result = await service.getAvailableSlots('2026-04-07', 'user-1') expect(result).toHaveLength(2) expect(result[0].isBookedByMe).toBe(true) expect(result[0].myBookingId).toBe('bk-1') expect(result[0].myBookingStatus).toBe(BookingStatus.CONFIRMED) expect(result[1].isBookedByMe).toBe(false) expect(result[1].myBookingId).toBeNull() expect(result[1].myBookingStatus).toBeNull() }) }) // ------------------------------------------------------------------------- // getSlotById // ------------------------------------------------------------------------- describe('getSlotById', () => { it('returns the slot with bookings when found', async () => { const slot = makeSlot({ bookings: [] }) mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(slot) const result = await service.getSlotById('slot-1') expect(result).toMatchObject({ id: slot.id, date: '2026-04-07', startTime: slot.startTime, endTime: slot.endTime, capacity: slot.capacity, bookedCount: slot.bookedCount, status: slot.status, source: slot.source, templateId: slot.templateId, isBookedByMe: false, myBookingId: null, myBookingStatus: null, }) expect(mockPrisma.timeSlot.findUnique).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'slot-1' } }), ) }) it('throws NotFoundException when slot does not exist', async () => { mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(null) await expect(service.getSlotById('no-such-slot')).rejects.toThrow( NotFoundException, ) }) }) // ------------------------------------------------------------------------- // getSchedulePreview // ------------------------------------------------------------------------- describe('getSchedulePreview', () => { const date = '2026-04-07' it('returns OPEN/FULL rows with isPublished: true', async () => { mockPrisma.timeSlot.findMany.mockResolvedValueOnce([ makeSlot({ id: 'slot-1', startTime: '09:00', endTime: '10:00', status: TimeSlotStatus.OPEN }), makeSlot({ id: 'slot-2', startTime: '10:30', endTime: '11:30', status: TimeSlotStatus.FULL }), ]) const result = await service.getSchedulePreview(date) expect(result).toHaveLength(2) expect(result.every((s) => s.isPublished === true)).toBe(true) }) it('HIDES CLOSED rows so the legacy client does not render them as "已发布"', async () => { // Simulates an admin "clear day" — DB now has 13 CLOSED rows. mockPrisma.timeSlot.findMany.mockResolvedValueOnce( Array.from({ length: 13 }, (_, i) => makeSlot({ id: `slot-${i}`, startTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '00' : '30'}`, endTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '30' : '30'}`, status: TimeSlotStatus.CLOSED, }), ), ) const result = await service.getSchedulePreview(date) // Critical for online compatibility: the legacy client must see [] // so it shows "当日暂无排课", not 13 phantom "已发布" slots. expect(result).toEqual([]) }) it('returns the OPEN subset and hides CLOSED when mixed', async () => { mockPrisma.timeSlot.findMany.mockResolvedValueOnce([ makeSlot({ id: 'open-1', status: TimeSlotStatus.OPEN }), makeSlot({ id: 'closed-1', status: TimeSlotStatus.CLOSED, startTime: '10:00', endTime: '11:00' }), makeSlot({ id: 'open-2', status: TimeSlotStatus.OPEN, startTime: '10:30', endTime: '11:30' }), ]) const result = await service.getSchedulePreview(date) expect(result).toHaveLength(2) expect(result.map((s) => s.id).sort()).toEqual(['open-1', 'open-2']) expect(result.find((s) => s.id === 'closed-1')).toBeUndefined() }) it('returns the ghost template when the day has no rows at all', async () => { mockPrisma.timeSlot.findMany.mockResolvedValueOnce([]) const result = await service.getSchedulePreview(date) // Ghost = isPublished false, id null. expect(result.length).toBeGreaterThan(0) expect(result.every((s) => s.isPublished === false && s.id === null)).toBe(true) }) }) // ------------------------------------------------------------------------- // createManualSlot // ------------------------------------------------------------------------- describe('createManualSlot', () => { it('creates a slot with MANUAL source', async () => { const created = makeSlot({ source: TimeSlotSource.MANUAL }) mockPrisma.timeSlot.create.mockResolvedValueOnce(created) await service.createManualSlot({ date: '2026-04-10', startTime: '14:00', endTime: '15:00', }) const data = (mockPrisma.timeSlot.create.mock.calls[0][0] as { data: { source: TimeSlotSource } }).data expect(data.source).toBe(TimeSlotSource.MANUAL) }) it('defaults capacity to DEFAULT_SLOT_CAPACITY when not provided', async () => { mockPrisma.timeSlot.create.mockResolvedValueOnce(makeSlot()) await service.createManualSlot({ date: '2026-04-10', startTime: '14:00', endTime: '15:00', }) const data = (mockPrisma.timeSlot.create.mock.calls[0][0] as { data: { capacity: number } }).data expect(data.capacity).toBe(DEFAULT_SLOT_CAPACITY) }) }) // ------------------------------------------------------------------------- // closeSlot // ------------------------------------------------------------------------- describe('closeSlot', () => { it('sets status to CLOSED', async () => { mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(makeSlot()) mockPrisma.timeSlot.update.mockResolvedValueOnce( makeSlot({ status: TimeSlotStatus.CLOSED }), ) await service.closeSlot('slot-1') expect(mockPrisma.timeSlot.update).toHaveBeenCalledWith( expect.objectContaining({ data: { status: TimeSlotStatus.CLOSED }, }), ) }) it('throws NotFoundException when slot does not exist', async () => { mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(null) await expect(service.closeSlot('ghost')).rejects.toThrow(NotFoundException) }) }) // ------------------------------------------------------------------------- // publishDaySlots — admin schedule management // ------------------------------------------------------------------------- describe('publishDaySlots', () => { const date = '2026-04-07' it('materializes the default template when the day has no rows yet', async () => { // First findMany (existing check) → empty; second findMany (re-read) → materialized rows. const materialized = [ makeSlot({ id: 'slot-A', startTime: '08:00', endTime: '09:00', source: TimeSlotSource.TEMPLATE }), makeSlot({ id: 'slot-B', startTime: '09:30', endTime: '10:30', source: TimeSlotSource.TEMPLATE }), ] mockPrisma.timeSlot.findMany .mockResolvedValueOnce([]) // initial check .mockResolvedValueOnce(materialized) // after materialize mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 }) mockPrisma.timeSlot.findMany.mockResolvedValueOnce(materialized) // final state await service.publishDaySlots({ date, slots: [] }) expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1) const createCall = mockPrisma.timeSlot.createMany.mock.calls[0][0] as { data: Array<{ source: string; status: string; date: Date }> skipDuplicates: boolean } expect(createCall.skipDuplicates).toBe(true) for (const row of createCall.data) { expect(row.source).toBe(TimeSlotSource.TEMPLATE) expect(row.status).toBe(TimeSlotStatus.OPEN) } }) it('CLOSEs (never deletes) orphaned rows when admin publishes empty list', async () => { const existing = [ makeSlot({ id: 'slot-1', bookedCount: 0 }), makeSlot({ id: 'slot-2', bookedCount: 0, startTime: '09:30', endTime: '10:30' }), ] // Initial check + final-state read both return the (now-CLOSED) rows. mockPrisma.timeSlot.findMany .mockResolvedValueOnce(existing) .mockResolvedValueOnce(existing) mockPrisma.timeSlot.update.mockResolvedValue({}) mockPrisma.timeSlot.delete = jest.fn() // safety: ensure delete is never used here await service.publishDaySlots({ date, slots: [] }) expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled() // Each orphan is updated to CLOSED (no bookedCount discrimination). const updateCalls = mockPrisma.timeSlot.update.mock.calls expect(updateCalls.length).toBe(2) for (const call of updateCalls) { const data = (call[0] as { data: { status: string } }).data expect(data.status).toBe(TimeSlotStatus.CLOSED) } }) it('CLOSEs (never deletes) orphaned rows even when they have bookings', async () => { const existing = [ makeSlot({ id: 'slot-1', bookedCount: 3 }), // has bookings ] mockPrisma.timeSlot.findMany .mockResolvedValueOnce(existing) .mockResolvedValueOnce(existing) mockPrisma.timeSlot.update.mockResolvedValue({}) mockPrisma.timeSlot.delete = jest.fn() await service.publishDaySlots({ date, slots: [] }) expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled() const updateCall = mockPrisma.timeSlot.update.mock.calls[0] const data = (updateCall[0] as { data: { status: string } }).data expect(data.status).toBe(TimeSlotStatus.CLOSED) }) it('updates existing slot referenced by existingSlotId and keeps its capacity >= bookedCount', async () => { const existing = [ makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 5 }), ] mockPrisma.timeSlot.findMany .mockResolvedValueOnce(existing) .mockResolvedValueOnce(existing) const updated = makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 4 }) mockPrisma.timeSlot.update.mockResolvedValueOnce(updated) await service.publishDaySlots({ date, slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 2 }], }) const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as { where: { id: string } data: { capacity: number; status?: string } }) expect(updateData.where.id).toBe('slot-1') // capacity clamped up to bookedCount (3), not the requested 2. expect(updateData.data.capacity).toBe(3) // status is NOT touched on update — existing CLOSED rows stay CLOSED // so that re-publishing the day cannot silently undo "rest day" intent. expect(updateData.data.status).toBeUndefined() }) it('preserves CLOSED status when admin re-publishes the day unchanged', async () => { // Scenario: admin cleared the day, then reloaded the page. The page // re-sends all CLOSED rows via existingSlotId. They must remain CLOSED. const existing = [ makeSlot({ id: 'slot-1', status: TimeSlotStatus.CLOSED, bookedCount: 0 }), ] mockPrisma.timeSlot.findMany .mockResolvedValueOnce(existing) .mockResolvedValueOnce(existing) mockPrisma.timeSlot.update.mockResolvedValueOnce(existing[0]) await service.publishDaySlots({ date, slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 1 }], }) const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as { data: { status?: string } }) expect(updateData.data.status).toBeUndefined() }) it('creates new slots for entries without an existingSlotId', async () => { const existing: typeof makeSlot extends (...a: any) => infer R ? R : never = [] as never mockPrisma.timeSlot.findMany .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 }) mockPrisma.timeSlot.create.mockResolvedValueOnce( makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }), ) mockPrisma.timeSlot.findMany.mockResolvedValueOnce([ makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }), ]) await service.publishDaySlots({ date, slots: [{ startTime: '14:00', endTime: '15:00', capacity: 4 }], }) expect(mockPrisma.timeSlot.create).toHaveBeenCalledTimes(1) const createData = (mockPrisma.timeSlot.create.mock.calls[0][0] as { data: { source: string; status: string; capacity: number } }).data expect(createData.source).toBe(TimeSlotSource.MANUAL) expect(createData.status).toBe(TimeSlotStatus.OPEN) expect(createData.capacity).toBe(4) }) it('updates materialized template rows when ghost preview is published without ids', async () => { const defaultSlots = getDefaultTimeSlots() const materialized = defaultSlots.map((slot, index) => makeSlot({ id: `slot-${index}`, startTime: slot.startTime, endTime: slot.endTime, source: TimeSlotSource.TEMPLATE, }), ) mockPrisma.timeSlot.findMany .mockResolvedValueOnce([]) .mockResolvedValueOnce(materialized) .mockResolvedValueOnce(materialized) mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length }) mockPrisma.timeSlot.update.mockImplementation(async (args: { where: { id: string } }) => { const row = materialized.find((s) => s.id === args.where.id)! return { ...row } }) await service.publishDaySlots({ date, slots: defaultSlots.map((slot) => ({ startTime: slot.startTime, endTime: slot.endTime, capacity: DEFAULT_SLOT_CAPACITY, })), }) expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1) expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled() expect(mockPrisma.timeSlot.update).toHaveBeenCalledTimes(defaultSlots.length) }) it('reopens an existing row by time when admin re-adds a slot without existingSlotId', async () => { const existing = [ makeSlot({ id: 'slot-open', startTime: '09:30', endTime: '10:30', status: TimeSlotStatus.OPEN, }), ] mockPrisma.timeSlot.findMany .mockResolvedValueOnce(existing) .mockResolvedValueOnce(existing) mockPrisma.timeSlot.update.mockResolvedValueOnce({ ...existing[0], status: TimeSlotStatus.OPEN, source: TimeSlotSource.MANUAL, capacity: 3, }) await service.publishDaySlots({ date, slots: [{ startTime: '09:30', endTime: '10:30', capacity: 3 }], }) expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled() const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as { data: { status: string; source: string; capacity: number } }).data expect(updateData.status).toBe(TimeSlotStatus.OPEN) expect(updateData.source).toBe(TimeSlotSource.MANUAL) expect(updateData.capacity).toBe(3) }) it('reopens a CLOSED row when admin publishes the same time window as a new slot', async () => { const existing = [ makeSlot({ id: 'slot-closed', startTime: '11:00', endTime: '12:00', status: TimeSlotStatus.CLOSED, }), ] mockPrisma.timeSlot.findMany .mockResolvedValueOnce(existing) .mockResolvedValueOnce(existing) mockPrisma.timeSlot.update.mockResolvedValueOnce({ ...existing[0], status: TimeSlotStatus.OPEN, }) await service.publishDaySlots({ date, slots: [{ startTime: '11:00', endTime: '12:00', capacity: 2 }], }) expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled() const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as { data: { status: string } }).data expect(updateData.status).toBe(TimeSlotStatus.OPEN) }) it('returns the final state of the day ordered by startTime', async () => { const finalState = [ makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }), makeSlot({ id: 'b', startTime: '10:30', endTime: '11:30' }), ] mockPrisma.timeSlot.findMany .mockResolvedValueOnce([]) // initial check .mockResolvedValueOnce([]) // after materialize .mockResolvedValueOnce(finalState) // final state mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 }) const result = await service.publishDaySlots({ date, slots: [] }) expect(result).toHaveLength(2) expect(result[0].startTime).toBe('09:00') expect(result[1].startTime).toBe('10:30') }) }) })