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,245 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { NotFoundException } from '@nestjs/common'
|
||||
import { TimeSlotService } from '../time-slot.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import {
|
||||
TimeSlotStatus,
|
||||
TimeSlotSource,
|
||||
BookingStatus,
|
||||
DEFAULT_SLOT_CAPACITY,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeSlot = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'slot-1',
|
||||
date: new Date('2026-04-07T00:00:00Z'),
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
capacity: 2,
|
||||
bookedCount: 0,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
source: TimeSlotSource.TEMPLATE,
|
||||
templateId: 'tpl-1',
|
||||
createdAt: new Date('2026-04-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-04-01T00:00:00Z'),
|
||||
bookings: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock PrismaService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockPrisma = {
|
||||
timeSlot: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
weekTemplate: {
|
||||
findMany: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TimeSlotService', () => {
|
||||
let service: TimeSlotService
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TimeSlotService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
service = module.get<TimeSlotService>(TimeSlotService)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getAvailableSlots
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('getAvailableSlots', () => {
|
||||
it('returns slots with isBookedByMe=false when user has no booking', async () => {
|
||||
const slot = makeSlot({ bookings: [] })
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
||||
|
||||
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isBookedByMe).toBe(false)
|
||||
expect(result[0].myBookingId).toBeNull()
|
||||
})
|
||||
|
||||
it('marks isBookedByMe=true and sets myBookingId when user has a CONFIRMED booking', async () => {
|
||||
const slot = makeSlot({ bookings: [{ id: 'booking-42' }] })
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
||||
|
||||
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||
|
||||
expect(result[0].isBookedByMe).toBe(true)
|
||||
expect(result[0].myBookingId).toBe('booking-42')
|
||||
})
|
||||
|
||||
it('excludes CLOSED slots from query', async () => {
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([])
|
||||
|
||||
await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||
|
||||
const where = (mockPrisma.timeSlot.findMany.mock.calls[0][0] as {
|
||||
where: { status: { not: TimeSlotStatus } }
|
||||
}).where
|
||||
expect(where.status).toEqual({ not: TimeSlotStatus.CLOSED })
|
||||
})
|
||||
|
||||
it('orders results by startTime ascending', async () => {
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([])
|
||||
|
||||
await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||
|
||||
const orderBy = (mockPrisma.timeSlot.findMany.mock.calls[0][0] as {
|
||||
orderBy: { startTime: string }
|
||||
}).orderBy
|
||||
expect(orderBy).toEqual({ startTime: 'asc' })
|
||||
})
|
||||
|
||||
it('returns correct date string (YYYY-MM-DD) in response', async () => {
|
||||
const slot = makeSlot({ date: new Date('2026-04-07T00:00:00Z'), bookings: [] })
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
||||
|
||||
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||
|
||||
expect(result[0].date).toBe('2026-04-07')
|
||||
})
|
||||
|
||||
it('sets isBookedByMe=false when no userId provided', async () => {
|
||||
const slot = makeSlot({ bookings: [] })
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([slot])
|
||||
|
||||
// No userId passed
|
||||
const result = await service.getAvailableSlots('2026-04-07')
|
||||
|
||||
expect(result[0].isBookedByMe).toBe(false)
|
||||
expect(result[0].myBookingId).toBeNull()
|
||||
})
|
||||
|
||||
it('maps multiple slots correctly', async () => {
|
||||
const slots = [
|
||||
makeSlot({ id: 'slot-1', startTime: '09:00', bookings: [{ id: 'bk-1' }] }),
|
||||
makeSlot({ id: 'slot-2', startTime: '10:00', bookings: [] }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(slots)
|
||||
|
||||
const result = await service.getAvailableSlots('2026-04-07', 'user-1')
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].isBookedByMe).toBe(true)
|
||||
expect(result[0].myBookingId).toBe('bk-1')
|
||||
expect(result[1].isBookedByMe).toBe(false)
|
||||
expect(result[1].myBookingId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getSlotById
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('getSlotById', () => {
|
||||
it('returns the slot with bookings when found', async () => {
|
||||
const slot = makeSlot({ bookings: [] })
|
||||
mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(slot)
|
||||
|
||||
const result = await service.getSlotById('slot-1')
|
||||
|
||||
expect(result).toEqual(slot)
|
||||
expect(mockPrisma.timeSlot.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'slot-1' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws NotFoundException when slot does not exist', async () => {
|
||||
mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(null)
|
||||
|
||||
await expect(service.getSlotById('no-such-slot')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// createManualSlot
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('createManualSlot', () => {
|
||||
it('creates a slot with MANUAL source', async () => {
|
||||
const created = makeSlot({ source: TimeSlotSource.MANUAL })
|
||||
mockPrisma.timeSlot.create.mockResolvedValueOnce(created)
|
||||
|
||||
await service.createManualSlot({
|
||||
date: '2026-04-10',
|
||||
startTime: '14:00',
|
||||
endTime: '15:00',
|
||||
})
|
||||
|
||||
const data = (mockPrisma.timeSlot.create.mock.calls[0][0] as {
|
||||
data: { source: TimeSlotSource }
|
||||
}).data
|
||||
expect(data.source).toBe(TimeSlotSource.MANUAL)
|
||||
})
|
||||
|
||||
it('defaults capacity to DEFAULT_SLOT_CAPACITY when not provided', async () => {
|
||||
mockPrisma.timeSlot.create.mockResolvedValueOnce(makeSlot())
|
||||
|
||||
await service.createManualSlot({
|
||||
date: '2026-04-10',
|
||||
startTime: '14:00',
|
||||
endTime: '15:00',
|
||||
})
|
||||
|
||||
const data = (mockPrisma.timeSlot.create.mock.calls[0][0] as {
|
||||
data: { capacity: number }
|
||||
}).data
|
||||
expect(data.capacity).toBe(DEFAULT_SLOT_CAPACITY)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// closeSlot
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('closeSlot', () => {
|
||||
it('sets status to CLOSED', async () => {
|
||||
mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(makeSlot())
|
||||
mockPrisma.timeSlot.update.mockResolvedValueOnce(
|
||||
makeSlot({ status: TimeSlotStatus.CLOSED }),
|
||||
)
|
||||
|
||||
await service.closeSlot('slot-1')
|
||||
|
||||
expect(mockPrisma.timeSlot.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { status: TimeSlotStatus.CLOSED },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws NotFoundException when slot does not exist', async () => {
|
||||
mockPrisma.timeSlot.findUnique.mockResolvedValueOnce(null)
|
||||
|
||||
await expect(service.closeSlot('ghost')).rejects.toThrow(NotFoundException)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user