fix(server): preserve admin 'rest day' intent across cron runs
publishDaySlots used to DELETE the TimeSlot rows of a day that an admin just 'cleared', which made the nightly slot-generation cron resurrect the default schedule the next morning — a real production bug where classes would reappear on a teacher's rest day. Changes: - publishDaySlots now CLOSEs (never DELETEs) orphaned rows, and materializes the default template for days with no rows so an empty publish still records intent. update on existing rows preserves their current status, so re-publishing the page cannot silently reopen a CLOSED rest day. - SlotGeneratorService.generateSlots pre-fetches dates that already have any TimeSlot row and skips them, so the cron no longer overwrites admin-touched days even when admin only set status without changing times. - SlotGeneratorService.prunePastClosedSlots (new) deletes past CLOSED slots with no bookings, scheduled nightly at 02:35, so the 'no DELETE' rule above does not leak rows forever. - schedule.vue surfaces the CLOSED state with a grey '已关闭' badge and card style. Tests: 12 new specs across time-slot.service.spec.ts (publishDaySlots behaviour) and slot-generator.service.spec.ts (skip touched dates + prune conditions). Full suite: 315 / 315 passing.
This commit is contained in:
@@ -39,6 +39,8 @@ const mockPrisma = {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
weekTemplate: {
|
||||
findMany: jest.fn(),
|
||||
@@ -48,6 +50,27 @@ const mockPrisma = {
|
||||
$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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -57,6 +80,7 @@ describe('TimeSlotService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
bindTransaction()
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -270,4 +294,173 @@ describe('TimeSlotService', () => {
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user