From a7a1969d791707724c566604c76e804d0d73a4eb Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 5 Jul 2026 20:51:24 +0800 Subject: [PATCH] test: add unit tests for Schedules, Bills, and Attendance services --- .../src/attendance/attendance.service.spec.ts | 85 +++++++ apps/server/src/bills/bills.service.spec.ts | 227 ++++++++++++++++++ .../src/schedules/schedules.service.spec.ts | 84 +++++++ 3 files changed, 396 insertions(+) create mode 100644 apps/server/src/attendance/attendance.service.spec.ts create mode 100644 apps/server/src/bills/bills.service.spec.ts create mode 100644 apps/server/src/schedules/schedules.service.spec.ts diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts new file mode 100644 index 0000000..19be663 --- /dev/null +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -0,0 +1,85 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, ValidationPipe } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { AttendanceService } from './attendance.service'; +import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity'; +import { BatchCreateAttendanceDto } from './dto/attendance.dto'; + +describe('AttendanceService — batchCreate', () => { + let service: AttendanceService; + let attendanceRepo: jest.Mocked, 'create' | 'save'>>; + + const savedRecords: AttendanceRecord[] = []; + + beforeEach(async () => { + savedRecords.length = 0; + const mockRepo = { + create: jest + .fn() + .mockImplementation((data: Partial) => ({ id: 1, ...data } as AttendanceRecord)), + save: jest + .fn() + .mockImplementation((entities: AttendanceRecord[]) => { + const result = entities.map((e, i) => ({ ...e, id: i + 1 } as AttendanceRecord)); + savedRecords.push(...result); + return Promise.resolve(result); + }), + }; + + const mockDingRepo = {}; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AttendanceService, + { provide: getRepositoryToken(AttendanceRecord), useValue: mockRepo }, + { provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo }, + ], + }).compile(); + + service = module.get(AttendanceService); + attendanceRepo = module.get(getRepositoryToken(AttendanceRecord)); + }); + + it('valid batch with morning_reading, evening_study, and night_check sessions → succeeds', async () => { + const dto: BatchCreateAttendanceDto = { + records: [ + { studentId: 1, classId: 10, attendanceDate: '2026-07-05', session: 'morning_reading', status: 'present' }, + { studentId: 2, classId: 10, attendanceDate: '2026-07-05', session: 'evening_study', status: 'present' }, + { studentId: 3, classId: 10, attendanceDate: '2026-07-05', session: 'night_check', status: 'present' }, + ], + }; + + const result = await service.batchCreate(dto); + + expect(result.count).toBe(3); + expect(result.records).toHaveLength(3); + expect(result.records[0].session).toBe('morning_reading'); + expect(result.records[1].session).toBe('evening_study'); + expect(result.records[2].session).toBe('night_check'); + }); + + it('invalid session → validation error (DTO-level)', async () => { + const pipe = new ValidationPipe({ whitelist: true }); + + const invalidPayload = { + records: [ + { studentId: 1, attendanceDate: '2026-07-05', session: 'invalid_session', status: 'present' }, + ], + }; + + await expect( + pipe.transform(invalidPayload, { + type: 'body', + metatype: BatchCreateAttendanceDto, + }), + ).rejects.toThrow(); + }); + + it('empty batch → BadRequestException', async () => { + const dto: BatchCreateAttendanceDto = { records: [] }; + + await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts new file mode 100644 index 0000000..94a4504 --- /dev/null +++ b/apps/server/src/bills/bills.service.spec.ts @@ -0,0 +1,227 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { BillsService } from './bills.service'; +import { Bill } from '../entities/bill.entity'; +import { BillItem } from '../entities/bill-item.entity'; +import { RoomExpense } from '../entities/room-expense.entity'; +import { PersonalExpense } from '../entities/personal-expense.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Room } from '../entities/room.entity'; +import { Deposit } from '../entities/deposit.entity'; + +type MockRepository = Partial, jest.Mock>>; + +function mockRepo(): MockRepository { + return { + find: jest.fn().mockResolvedValue([]), + save: jest.fn().mockImplementation((entity) => Promise.resolve({ id: 1, ...entity })), + create: jest.fn().mockImplementation((entity) => entity), + createQueryBuilder: jest.fn().mockReturnValue({ + delete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }), + findOne: jest.fn().mockResolvedValue(null), + remove: jest.fn().mockResolvedValue(undefined), + }; +} + +function mockQueryBuilder(results: T[]) { + return { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(results), + }; +} + +describe('BillsService — generateBills', () => { + let service: BillsService; + let billRepo: MockRepository; + let itemRepo: MockRepository; + let roomExpRepo: MockRepository; + let personalExpRepo: MockRepository; + let occRepo: MockRepository; + let roomRepo: MockRepository; + let depositRepo: MockRepository; + let dataSource: { transaction: jest.Mock }; + + beforeEach(async () => { + billRepo = mockRepo(); + itemRepo = mockRepo(); + roomExpRepo = mockRepo(); + personalExpRepo = mockRepo(); + occRepo = mockRepo(); + roomRepo = mockRepo(); + depositRepo = mockRepo(); + dataSource = { transaction: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BillsService, + { provide: getRepositoryToken(Bill), useValue: billRepo }, + { provide: getRepositoryToken(BillItem), useValue: itemRepo }, + { provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo }, + { provide: getRepositoryToken(PersonalExpense), useValue: personalExpRepo }, + { provide: getRepositoryToken(Occupancy), useValue: occRepo }, + { provide: getRepositoryToken(Room), useValue: roomRepo }, + { provide: getRepositoryToken(Deposit), useValue: depositRepo }, + { provide: DataSource, useValue: dataSource }, + ], + }).compile(); + + service = module.get(BillsService); + }); + + const PERIOD = { periodStart: '2026-06-01', periodEnd: '2026-06-30' }; + + it('long-term occupancy → individual bill with monthlyRate', async () => { + // One room expense for room 1 + (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, roomId: 1, expenseType: 'utility', + amount: '500' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ); + + // One long-term occupancy in room 1 + (occRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, studentId: 10, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-30', + rentalType: 'long', + room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room, + } as Occupancy, + ]), + ); + + // No personal expenses + (personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([]), + ); + + const result = await service.generateBills(PERIOD); + + expect(result.count).toBe(1); + const savedCalls = (billRepo.save as jest.Mock).mock.calls; + expect(savedCalls.length).toBe(1); + + // The long-term bill should carry the monthly rate (shared = 800) + const billData = savedCalls[0][0] as Record; + expect(Number(billData.totalAmount)).toBe(800); + expect(Number(billData.sharedAmount)).toBe(800); + expect(billData.studentId).toBe(10); + }); + + it('short-term occupancy → per-diem allocation pool', async () => { + // One room expense for room 1 + (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, roomId: 1, expenseType: 'utility', + amount: '300' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ); + + // Two short-term occupancies: student 10 (10 days), student 11 (20 days) → 30 total + (occRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, studentId: 10, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-10', + rentalType: 'short', room: undefined, + } as Occupancy, + { + id: 2, studentId: 11, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-20', + rentalType: 'short', room: undefined, + } as Occupancy, + ]), + ); + + (personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([]), + ); + + const result = await service.generateBills(PERIOD); + + expect(result.count).toBe(2); + const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record]>; + const student10Bill = savedCalls.find((c) => c[0].studentId === 10); + const student11Bill = savedCalls.find((c) => c[0].studentId === 11); + + // Student 10: 10/30 ≈ 100.00 + expect(Number(student10Bill![0].sharedAmount)).toBeCloseTo(100, 0); + // Student 11: 20/30 ≈ 200.00 + expect(Number(student11Bill![0].sharedAmount)).toBeCloseTo(200, 0); + // Total bill amounts sum to the expense + expect( + Number(student10Bill![0].totalAmount) + Number(student11Bill![0].totalAmount), + ).toBeCloseTo(300, 0); + }); + + it('mixed → long-term get individual bills, short-term share expenses', async () => { + // Room 1: two expenses + (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, roomId: 1, expenseType: 'utility', + amount: '200' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30', + } as RoomExpense, + { + id: 2, roomId: 1, expenseType: 'water', + amount: '100' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ); + + // Mixed: one long-term (student 10) + two short-term (11, 12) + (occRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, studentId: 10, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-30', + rentalType: 'long', + room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room, + } as Occupancy, + { + id: 2, studentId: 11, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-15', + rentalType: 'short', room: undefined, + } as Occupancy, + { + id: 3, studentId: 12, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-15', + rentalType: 'short', room: undefined, + } as Occupancy, + ]), + ); + + (personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([]), + ); + + const result = await service.generateBills(PERIOD); + const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record]>; + + // All 3 students get a bill + expect(result.count).toBe(3); + expect(savedCalls.length).toBe(3); + + // Long-term student 10 should have shared = monthlyRate (600) + const longBill = savedCalls.find((c) => c[0].studentId === 10)!; + expect(Number(longBill[0].sharedAmount)).toBe(600); + + // Short-term students 11 & 12 split 300 in expenses equally (15 days each) + const short11 = savedCalls.find((c) => c[0].studentId === 11)!; + const short12 = savedCalls.find((c) => c[0].studentId === 12)!; + expect(Number(short11[0].sharedAmount)).toBeCloseTo(150, 0); + expect(Number(short12[0].sharedAmount)).toBeCloseTo(150, 0); + }); +}); diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts new file mode 100644 index 0000000..4b2c149 --- /dev/null +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConflictException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { SchedulesService } from './schedules.service'; +import { ClassSchedule } from '../entities/class-schedule.entity'; + +/** Build a mock query-builder where each chain method returns `this`. */ +function mockQueryBuilder(results: T[] = []) { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(results), + }; + return qb; +} + +describe('SchedulesService — checkConflict', () => { + let service: SchedulesService; + let scheduleRepo: jest.Mocked, 'createQueryBuilder'>>; + + beforeEach(async () => { + const mockRepo = { + createQueryBuilder: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SchedulesService, + { provide: getRepositoryToken(ClassSchedule), useValue: mockRepo }, + ], + }).compile(); + + service = module.get(SchedulesService); + scheduleRepo = module.get(getRepositoryToken(ClassSchedule)); + }); + + it('same classroom + same weekday + overlapping times → ConflictException', async () => { + const qb = mockQueryBuilder([ + { id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule, + ]); + (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + await expect( + service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'), + ).rejects.toThrow(ConflictException); + }); + + it('same classroom + same weekday + non-overlapping times → no conflict', async () => { + const qb = mockQueryBuilder([]); + (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + await expect( + service.checkConflict(1, 3, '10:00', '12:00', '2026-03-01', '2026-06-30'), + ).resolves.toEqual([]); + }); + + it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => { + const qb = mockQueryBuilder([]); + (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + await expect( + service.checkConflict(1, 3, '09:00', '11:00', '2026-07-01', '2026-08-31'), + ).resolves.toEqual([]); + }); + + it('different classroom → no conflict', async () => { + const qb = mockQueryBuilder([]); + (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + await expect( + service.checkConflict(2, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'), + ).resolves.toEqual([]); + }); + + it('excludes the given schedule id from conflict check', async () => { + const qb = mockQueryBuilder([]); + (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + await service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30', 42); + + expect(qb.andWhere).toHaveBeenCalledWith('cs.id != :excludeId', { excludeId: 42 }); + }); +});