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:
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
13
packages/server/src/scheduler/scheduler.module.ts
Normal file
13
packages/server/src/scheduler/scheduler.module.ts
Normal 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 {}
|
||||
54
packages/server/src/scheduler/scheduler.service.ts
Normal file
54
packages/server/src/scheduler/scheduler.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user