feat(server): add membership and time-slot modules

Membership: card type CRUD, deduction/restore logic, valid card lookup (15 tests)
TimeSlot: slot generation from week templates, availability query with booking
status, admin management, cleanup tasks (26 tests)
65 total tests passing
This commit is contained in:
richarjiang
2026-04-02 12:24:07 +08:00
parent a1a91f96d8
commit 593a6e5453
16 changed files with 1746 additions and 0 deletions

View File

@@ -0,0 +1,309 @@
import { Test, TestingModule } from '@nestjs/testing'
import { SlotGeneratorService } from '../slot-generator.service'
import { PrismaService } from '../../prisma/prisma.service'
import {
TimeSlotStatus,
TimeSlotSource,
MembershipStatus,
BookingStatus,
} from '@mp-pilates/shared'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Return a Date whose JS getDay() maps to the given ISO weekday (1=Mon…7=Sun) */
function dateForIsoWeekday(isoWeekday: number): Date {
const base = new Date('2026-04-06T00:00:00Z') // Monday
const d = new Date(base)
d.setDate(base.getDate() + (isoWeekday - 1))
return d
}
const makeTemplate = (overrides: Record<string, unknown> = {}) => ({
id: 'tpl-1',
dayOfWeek: 1,
startTime: '09:00',
endTime: '10:00',
capacity: 1,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
// ---------------------------------------------------------------------------
// Mock PrismaService
// ---------------------------------------------------------------------------
const mockPrisma = {
weekTemplate: {
findMany: jest.fn(),
},
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('returns 0 when there are no active templates', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([])
const count = await service.generateSlots(7)
expect(count).toBe(0)
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
})
it('creates correct number of slots from templates', async () => {
// 2 templates, both for Monday (ISO 1)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-1', dayOfWeek: 1, startTime: '09:00', endTime: '10:00' }),
makeTemplate({ id: 'tpl-2', dayOfWeek: 1, startTime: '10:00', endTime: '11:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
// Use 7 days — will hit exactly one Monday
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)
// Both templates should appear in the batch (may include more days)
const mondaySlots = (
data as Array<{ startTime: string; source: TimeSlotSource }>
).filter(
(s) => s.startTime === '09:00' || s.startTime === '10:00',
)
expect(mondaySlots.length).toBeGreaterThanOrEqual(2)
expect(count).toBe(2)
})
it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ dayOfWeek: 1 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
await service.generateSlots(7)
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
skipDuplicates: boolean
}
expect(call.skipDuplicates).toBe(true)
})
it('maps Sunday (JS getDay()=0) to ISO weekday 7', async () => {
// Template for Sunday (ISO 7)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-sun', dayOfWeek: 7, startTime: '08:00', endTime: '09:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
// 7 days will cover exactly one Sunday
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; source: TimeSlotSource }>
}
const sundaySlots = data.filter((s) => s.startTime === '08:00')
expect(sundaySlots.length).toBeGreaterThanOrEqual(1)
expect(sundaySlots[0].source).toBe(TimeSlotSource.TEMPLATE)
expect(count).toBe(1)
})
it('maps Monday (JS getDay()=1) to ISO weekday 1', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-mon', dayOfWeek: 1, startTime: '07:00', endTime: '08:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string }>
}
const mondaySlots = data.filter((s) => s.startTime === '07:00')
expect(mondaySlots.length).toBeGreaterThanOrEqual(1)
})
it('attaches templateId from the matching template', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-xyz', dayOfWeek: 2 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ templateId: string }>
}
const tuesdaySlots = data.filter((s) => s.templateId === 'tpl-xyz')
expect(tuesdaySlots.length).toBeGreaterThanOrEqual(1)
})
})
// -------------------------------------------------------------------------
// 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)
})
})
})