test: add unit tests for Schedules, Bills, and Attendance services
This commit is contained in:
85
apps/server/src/attendance/attendance.service.spec.ts
Normal file
85
apps/server/src/attendance/attendance.service.spec.ts
Normal file
@@ -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<Pick<Repository<AttendanceRecord>, 'create' | 'save'>>;
|
||||
|
||||
const savedRecords: AttendanceRecord[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
savedRecords.length = 0;
|
||||
const mockRepo = {
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation((data: Partial<AttendanceRecord>) => ({ 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>(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);
|
||||
});
|
||||
});
|
||||
227
apps/server/src/bills/bills.service.spec.ts
Normal file
227
apps/server/src/bills/bills.service.spec.ts
Normal file
@@ -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<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||
|
||||
function mockRepo<T>(): MockRepository<T> {
|
||||
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<T>(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<Bill>;
|
||||
let itemRepo: MockRepository<BillItem>;
|
||||
let roomExpRepo: MockRepository<RoomExpense>;
|
||||
let personalExpRepo: MockRepository<PersonalExpense>;
|
||||
let occRepo: MockRepository<Occupancy>;
|
||||
let roomRepo: MockRepository<Room>;
|
||||
let depositRepo: MockRepository<Deposit>;
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
billRepo = mockRepo<Bill>();
|
||||
itemRepo = mockRepo<BillItem>();
|
||||
roomExpRepo = mockRepo<RoomExpense>();
|
||||
personalExpRepo = mockRepo<PersonalExpense>();
|
||||
occRepo = mockRepo<Occupancy>();
|
||||
roomRepo = mockRepo<Room>();
|
||||
depositRepo = mockRepo<Deposit>();
|
||||
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>(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<RoomExpense>([
|
||||
{
|
||||
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<Occupancy>([
|
||||
{
|
||||
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<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
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<string, unknown>;
|
||||
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<RoomExpense>([
|
||||
{
|
||||
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<Occupancy>([
|
||||
{
|
||||
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<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
|
||||
expect(result.count).toBe(2);
|
||||
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
|
||||
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<RoomExpense>([
|
||||
{
|
||||
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<Occupancy>([
|
||||
{
|
||||
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<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
84
apps/server/src/schedules/schedules.service.spec.ts
Normal file
84
apps/server/src/schedules/schedules.service.spec.ts
Normal file
@@ -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<T>(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<Pick<Repository<ClassSchedule>, '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>(SchedulesService);
|
||||
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
|
||||
});
|
||||
|
||||
it('same classroom + same weekday + overlapping times → ConflictException', async () => {
|
||||
const qb = mockQueryBuilder<ClassSchedule>([
|
||||
{ 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<ClassSchedule>([]);
|
||||
(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<ClassSchedule>([]);
|
||||
(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<ClassSchedule>([]);
|
||||
(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<ClassSchedule>([]);
|
||||
(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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user