Files
gongxue-base/apps/server/src/bills/bills.service.spec.ts

492 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(), query: jest.fn().mockResolvedValue([]) };
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',
stayType: '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',
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
stayType: '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',
stayType: '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',
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 3, studentId: 12, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-15',
stayType: '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);
});
// ============================================================
// Bug-exposing tests
// ============================================================
it.skip('BUG: long-term multi-month period → monthlyRate not multiplied by months', async () => {
// 3-month period: JanMar 2026
const THREE_MONTHS = { periodStart: '2026-01-01', periodEnd: '2026-03-31' };
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '500' as unknown as number, periodStart: '2026-01-01', periodEnd: '2026-03-31',
} as RoomExpense,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-01-01', billingEndDate: '2026-03-31',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(THREE_MONTHS);
expect(result.count).toBe(1);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
// CORRECT: 800 × 3 months = 2400
// CURRENT BUG: only 800 (monthlyRate charged once regardless of period length)
const actual = Number(billData.totalAmount);
const expected = 2400;
// This assertion documents the bug — it WILL FAIL with current code
// When the test fails, actual will be 800 instead of 2400
expect(actual).toBeCloseTo(expected, 0);
});
it.skip('BUG: long-term partial month → full monthlyRate charged instead of prorated', async () => {
// Student occupies only Jun 1530 (16 days out of 30), monthlyRate 600
(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,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-15', billingEndDate: '2026-06-30',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
} as Occupancy,
]),
);
(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 as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
// CORRECT: 600 × (16/30) ≈ 320
// CURRENT BUG: 600 (full month)
const actual = Number(billData.totalAmount);
const expectedProrated = 320;
expect(actual).toBeCloseTo(expectedProrated, -1);
});
// ============================================================
// Correctness tests (should pass with fixed code)
// ============================================================
it('multiple rooms → expenses only shared within each room', async () => {
// Room 1: 300 expense, students S10(10d) + S11(20d) = 30d total
// Room 2: 400 expense, student S12(30d alone)
(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,
{
id: 2, roomId: 2, expenseType: 'utility',
amount: '400' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
// Use sequential query builder returns: first call → room 1 occs, second → room 2 occs
// NOTE: mock order depends on internal service call sequence; if refactored, update callCount indices
let callCount = 0;
(occRepo.createQueryBuilder as jest.Mock).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
stayType: 'short', room: undefined,
} as Occupancy,
]);
}
return mockQueryBuilder<Occupancy>([
{
id: 3, studentId: 12, roomId: 2,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
stayType: 'short', room: undefined,
} as Occupancy,
]);
});
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
// Mock student department query
(dataSource.query as jest.Mock).mockResolvedValue([
{ id: 10, department_id: null },
{ id: 11, department_id: null },
{ id: 12, department_id: null },
]);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(3);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const s10Bill = savedCalls.find((c) => c[0].studentId === 10)!;
const s11Bill = savedCalls.find((c) => c[0].studentId === 11)!;
const s12Bill = savedCalls.find((c) => c[0].studentId === 12)!;
// Room 2: S12 alone → pays all 400
expect(Number(s12Bill[0].totalAmount)).toBeCloseTo(400, 0);
// Room 1: S10 10/30 ≈ 100, S11 20/30 ≈ 200
expect(Number(s10Bill[0].sharedAmount)).toBeCloseTo(100, 0);
expect(Number(s11Bill[0].sharedAmount)).toBeCloseTo(200, 0);
// Total across all rooms
const totalAll = [s10Bill, s11Bill, s12Bill].reduce(
(sum, c) => sum + Number(c[0].totalAmount), 0,
);
expect(totalAll).toBeCloseTo(700, 0);
});
it('personal expenses → added on top of shared allocation', async () => {
(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,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
// Personal expense: damage fee of 50
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([
{
id: 1, studentId: 10, roomId: 1,
expenseType: 'damage', amount: '50' as unknown as number,
expenseDate: '2026-06-15', description: 'broken chair',
} as PersonalExpense,
]),
);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(1);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
expect(Number(billData.sharedAmount)).toBeCloseTo(300, 0);
expect(Number(billData.personalAmount)).toBe(50);
expect(Number(billData.totalAmount)).toBeCloseTo(350, 0);
});
it('zero overlapping days → no bill generated', async () => {
(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,
]),
);
// Occupancy starts AFTER period ends — no overlap
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: '2026-07-15',
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(PERIOD);
// Occupancy outside period → no matching student days → no bill
expect(result.count).toBe(0);
});
it('no room expenses → no bills generated', async () => {
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(0);
});
});