Files
mp-pilates/packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts
2026-04-15 23:25:09 +08:00

240 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Test, TestingModule } from '@nestjs/testing'
import { SlotGeneratorService } from '../slot-generator.service'
import { PrismaService } from '../../prisma/prisma.service'
import {
TimeSlotStatus,
TimeSlotSource,
MembershipStatus,
BookingStatus,
getDefaultTimeSlots,
} from '@mp-pilates/shared'
// ---------------------------------------------------------------------------
// Mock PrismaService
// ---------------------------------------------------------------------------
const mockPrisma = {
timeSlot: {
createMany: jest.fn(),
updateMany: jest.fn(),
},
membership: {
updateMany: jest.fn(),
},
booking: {
updateMany: jest.fn(),
},
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('SlotGeneratorService', () => {
let service: SlotGeneratorService
beforeEach(async () => {
jest.clearAllMocks()
const module: TestingModule = await Test.createTestingModule({
providers: [
SlotGeneratorService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile()
service = module.get<SlotGeneratorService>(SlotGeneratorService)
})
// -------------------------------------------------------------------------
// generateSlots
// -------------------------------------------------------------------------
describe('generateSlots', () => {
it('creates slots for every day using the default schedule (14 slots per day)', async () => {
const defaultSlots = getDefaultTimeSlots()
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length * 7 })
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data, skipDuplicates } =
mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: unknown[]
skipDuplicates: boolean
}
expect(skipDuplicates).toBe(true)
// 7 days × 14 slots per day = 98
expect(data).toHaveLength(defaultSlots.length * 7)
expect(count).toBe(defaultSlots.length * 7)
})
it('creates 14 slots per day (08:00-22:00 hourly)', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; endTime: string }>
}
expect(data).toHaveLength(14)
expect(data[0].startTime).toBe('08:00')
expect(data[0].endTime).toBe('09:00')
expect(data[13].startTime).toBe('21:00')
expect(data[13].endTime).toBe('22:00')
})
it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
await service.generateSlots(1)
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
skipDuplicates: boolean
}
expect(call.skipDuplicates).toBe(true)
})
it('sets source to TEMPLATE for all generated slots', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ source: TimeSlotSource }>
}
for (const slot of data) {
expect(slot.source).toBe(TimeSlotSource.TEMPLATE)
}
})
})
// -------------------------------------------------------------------------
// cleanupExpiredSlots
// -------------------------------------------------------------------------
describe('cleanupExpiredSlots', () => {
it('marks past OPEN slots as CLOSED', async () => {
mockPrisma.timeSlot.updateMany.mockResolvedValueOnce({ count: 3 })
const count = await service.cleanupExpiredSlots()
expect(count).toBe(3)
expect(mockPrisma.timeSlot.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: TimeSlotStatus.OPEN,
}),
data: { status: TimeSlotStatus.CLOSED },
}),
)
})
it('queries slots with date lt today', async () => {
mockPrisma.timeSlot.updateMany.mockResolvedValueOnce({ count: 0 })
await service.cleanupExpiredSlots()
const where = (mockPrisma.timeSlot.updateMany.mock.calls[0][0] as {
where: { date: { lt: Date } }
}).where
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
// The lt date should be today at midnight (within 1 second of test run)
const diff = Math.abs(where.date.lt.getTime() - today.getTime())
expect(diff).toBeLessThan(1000)
})
})
// -------------------------------------------------------------------------
// checkExpiredMemberships
// -------------------------------------------------------------------------
describe('checkExpiredMemberships', () => {
it('marks expired-date memberships as EXPIRED', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 2 }) // date-expired
.mockResolvedValueOnce({ count: 1 }) // used-up
const total = await service.checkExpiredMemberships()
expect(total).toBe(3)
const firstCall = (
mockPrisma.membership.updateMany.mock.calls[0][0] as {
data: { status: MembershipStatus }
}
).data
expect(firstCall.status).toBe(MembershipStatus.EXPIRED)
})
it('marks zero-remaining memberships as USED_UP', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 0 })
.mockResolvedValueOnce({ count: 4 })
const total = await service.checkExpiredMemberships()
expect(total).toBe(4)
const secondCall = (
mockPrisma.membership.updateMany.mock.calls[1][0] as {
where: { remainingTimes: number }
data: { status: MembershipStatus }
}
)
expect(secondCall.where.remainingTimes).toBe(0)
expect(secondCall.data.status).toBe(MembershipStatus.USED_UP)
})
it('runs both updates in parallel and sums results', async () => {
mockPrisma.membership.updateMany
.mockResolvedValueOnce({ count: 3 })
.mockResolvedValueOnce({ count: 2 })
const total = await service.checkExpiredMemberships()
expect(total).toBe(5)
expect(mockPrisma.membership.updateMany).toHaveBeenCalledTimes(2)
})
})
// -------------------------------------------------------------------------
// completeBookings
// -------------------------------------------------------------------------
describe('completeBookings', () => {
it('marks past CONFIRMED bookings as COMPLETED', async () => {
mockPrisma.booking.updateMany.mockResolvedValueOnce({ count: 5 })
const count = await service.completeBookings()
expect(count).toBe(5)
expect(mockPrisma.booking.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: BookingStatus.CONFIRMED,
}),
data: { status: BookingStatus.COMPLETED },
}),
)
})
it('filters by timeSlot.date lt today', async () => {
mockPrisma.booking.updateMany.mockResolvedValueOnce({ count: 0 })
await service.completeBookings()
const where = (mockPrisma.booking.updateMany.mock.calls[0][0] as {
where: { timeSlot: { date: { lt: Date } } }
}).where
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const diff = Math.abs(where.timeSlot.date.lt.getTime() - today.getTime())
expect(diff).toBeLessThan(1000)
})
})
})