feat(server): add booking, payment, and scheduler modules

Booking: reservation with atomic transactions, cancellation with refund
logic based on cancelHoursLimit (23 tests)
Payment: WeChat Pay integration (mock), order lifecycle, membership
creation on payment callback (13 tests)
Scheduler: cron tasks for slot generation, cleanup, membership expiry (8 tests)
109 total tests passing across 9 test suites
This commit is contained in:
richarjiang
2026-04-02 12:33:50 +08:00
parent 593a6e5453
commit 994d1f75d5
15 changed files with 2183 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
import { Test, TestingModule } from '@nestjs/testing'
import { Logger } from '@nestjs/common'
import { SchedulerService } from '../scheduler.service'
import { SlotGeneratorService } from '../../time-slot/slot-generator.service'
// ---------------------------------------------------------------------------
// Mock SlotGeneratorService
// ---------------------------------------------------------------------------
const mockSlotGenerator: jest.Mocked<Pick<
SlotGeneratorService,
'generateSlots' | 'cleanupExpiredSlots' | 'checkExpiredMemberships' | 'completeBookings'
>> = {
generateSlots: jest.fn(),
cleanupExpiredSlots: jest.fn(),
checkExpiredMemberships: jest.fn(),
completeBookings: jest.fn(),
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('SchedulerService', () => {
let service: SchedulerService
let logSpy: jest.SpyInstance
let errorSpy: jest.SpyInstance
beforeEach(async () => {
jest.clearAllMocks()
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulerService,
{ provide: SlotGeneratorService, useValue: mockSlotGenerator },
],
}).compile()
service = module.get<SchedulerService>(SchedulerService)
// Spy on the service's own logger instance
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined)
errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined)
})
afterEach(() => {
logSpy.mockRestore()
errorSpy.mockRestore()
})
// -------------------------------------------------------------------------
// handleSlotGeneration
// -------------------------------------------------------------------------
describe('handleSlotGeneration', () => {
it('calls generateSlots(14) and logs the result', async () => {
mockSlotGenerator.generateSlots.mockResolvedValueOnce(7)
await service.handleSlotGeneration()
expect(mockSlotGenerator.generateSlots).toHaveBeenCalledWith(14)
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('7'),
)
})
it('catches errors and logs them without rethrowing', async () => {
mockSlotGenerator.generateSlots.mockRejectedValueOnce(new Error('db error'))
await expect(service.handleSlotGeneration()).resolves.toBeUndefined()
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('handleSlotGeneration'),
expect.any(Error),
)
})
})
// -------------------------------------------------------------------------
// handleCleanupSlots
// -------------------------------------------------------------------------
describe('handleCleanupSlots', () => {
it('calls cleanupExpiredSlots() and logs the result', async () => {
mockSlotGenerator.cleanupExpiredSlots.mockResolvedValueOnce(3)
await service.handleCleanupSlots()
expect(mockSlotGenerator.cleanupExpiredSlots).toHaveBeenCalledTimes(1)
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('3'),
)
})
it('catches errors and logs them without rethrowing', async () => {
mockSlotGenerator.cleanupExpiredSlots.mockRejectedValueOnce(new Error('timeout'))
await expect(service.handleCleanupSlots()).resolves.toBeUndefined()
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('handleCleanupSlots'),
expect.any(Error),
)
})
})
// -------------------------------------------------------------------------
// handleCheckMemberships
// -------------------------------------------------------------------------
describe('handleCheckMemberships', () => {
it('calls checkExpiredMemberships() and logs the result', async () => {
mockSlotGenerator.checkExpiredMemberships.mockResolvedValueOnce(5)
await service.handleCheckMemberships()
expect(mockSlotGenerator.checkExpiredMemberships).toHaveBeenCalledTimes(1)
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('5'),
)
})
it('catches errors and logs them without rethrowing', async () => {
mockSlotGenerator.checkExpiredMemberships.mockRejectedValueOnce(new Error('network'))
await expect(service.handleCheckMemberships()).resolves.toBeUndefined()
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('handleCheckMemberships'),
expect.any(Error),
)
})
})
// -------------------------------------------------------------------------
// handleCompleteBookings
// -------------------------------------------------------------------------
describe('handleCompleteBookings', () => {
it('calls completeBookings() and logs the result', async () => {
mockSlotGenerator.completeBookings.mockResolvedValueOnce(12)
await service.handleCompleteBookings()
expect(mockSlotGenerator.completeBookings).toHaveBeenCalledTimes(1)
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('12'),
)
})
it('catches errors and logs them without rethrowing', async () => {
mockSlotGenerator.completeBookings.mockRejectedValueOnce(new Error('query failed'))
await expect(service.handleCompleteBookings()).resolves.toBeUndefined()
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('handleCompleteBookings'),
expect.any(Error),
)
})
})
})