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:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user