240 lines
7.7 KiB
TypeScript
240 lines
7.7 KiB
TypeScript
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)
|
||
})
|
||
})
|
||
})
|