Files
mp-pilates/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts
richarjiang b72357030d fix(server): 修复排课发布唯一约束冲突并更正默认时段模板
发布时按日期+起止时间匹配已有时段,避免幽灵预览重复 create;默认课表改为上午整点与下午 13:30 起每小时至 21:30。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 14:26:43 +08:00

627 lines
23 KiB
TypeScript

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<string, unknown> = {}) => ({
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>(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')
})
})
})