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),
)
})
})
})

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { TimeSlotModule } from '../time-slot/time-slot.module'
import { SchedulerService } from './scheduler.service'
@Module({
imports: [
ScheduleModule.forRoot(),
TimeSlotModule,
],
providers: [SchedulerService],
})
export class SchedulerModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { SlotGeneratorService } from '../time-slot/slot-generator.service'
@Injectable()
export class SchedulerService {
private readonly logger = new Logger(SchedulerService.name)
constructor(private readonly slotGenerator: SlotGeneratorService) {}
/** 02:00 daily — generate slots 14 days ahead from week templates */
@Cron('0 2 * * *')
async handleSlotGeneration(): Promise<void> {
try {
const count = await this.slotGenerator.generateSlots(14)
this.logger.log(`[handleSlotGeneration] Created ${count} new time slots`)
} catch (err) {
this.logger.error('[handleSlotGeneration] Failed to generate slots', err)
}
}
/** 02:30 daily — close past OPEN slots */
@Cron('30 2 * * *')
async handleCleanupSlots(): Promise<void> {
try {
const count = await this.slotGenerator.cleanupExpiredSlots()
this.logger.log(`[handleCleanupSlots] Closed ${count} expired slots`)
} catch (err) {
this.logger.error('[handleCleanupSlots] Failed to clean up slots', err)
}
}
/** 03:00 daily — expire memberships past their end date or with 0 sessions */
@Cron('0 3 * * *')
async handleCheckMemberships(): Promise<void> {
try {
const count = await this.slotGenerator.checkExpiredMemberships()
this.logger.log(`[handleCheckMemberships] Updated ${count} memberships`)
} catch (err) {
this.logger.error('[handleCheckMemberships] Failed to check memberships', err)
}
}
/** 22:00 daily — mark past CONFIRMED bookings as COMPLETED */
@Cron('0 22 * * *')
async handleCompleteBookings(): Promise<void> {
try {
const count = await this.slotGenerator.completeBookings()
this.logger.log(`[handleCompleteBookings] Completed ${count} bookings`)
} catch (err) {
this.logger.error('[handleCompleteBookings] Failed to complete bookings', err)
}
}
}