发布时按日期+起止时间匹配已有时段,避免幽灵预览重复 create;默认课表改为上午整点与下午 13:30 起每小时至 21:30。 Co-authored-by: Cursor <cursoragent@cursor.com>
627 lines
23 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|