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

596 lines
23 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';
import { WalletsService } from '../wallets/wallets.service';
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>();
let nextBillId = 0;
dataSource = {
transaction: jest.fn(async (callback) => callback({
create: (_entity: unknown, value: unknown) => value,
save: jest.fn(async (value: any) => {
if ('totalAmount' in value && 'studentId' in value) {
const saved = { id: ++nextBillId, ...value };
await (billRepo.save as jest.Mock)(saved);
return saved;
}
await (itemRepo.save as jest.Mock)(value);
return { id: value.id || 1, ...value };
}),
createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
})),
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 },
{ provide: WalletsService, useValue: { debitBill: jest.fn(async (_manager, bill) => bill), refundBill: jest.fn() } },
],
}).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('includes room expenses whose periods are inside the generated bill period', async () => {
const qb = mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'water',
amount: '300' as unknown as number, periodStart: '2026-07-01', periodEnd: '2026-07-31',
} as RoomExpense,
]);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: null as unknown as string,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills({
periodStart: '2026-06-29',
periodEnd: '2026-07-31',
});
expect(result.count).toBe(1);
expect(qb.where).toHaveBeenCalledWith(
'e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd',
{ periodStart: '2026-06-29', periodEnd: '2026-07-31' },
);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
expect(Number(savedCalls[0][0].sharedAmount)).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);
});
});
describe('BillsService — allocation rounding boundary', () => {
it('keeps allocated cents equal to the original expense total', async () => {
const billRepo = mockRepo<Bill>();
const itemRepo = mockRepo<BillItem>();
const roomExpRepo = mockRepo<RoomExpense>();
const personalExpRepo = mockRepo<PersonalExpense>();
const occRepo = mockRepo<Occupancy>();
const roomRepo = mockRepo<Room>();
let nextBillId = 0;
const dataSource = {
query: jest.fn().mockResolvedValue([]),
transaction: jest.fn(async (callback) => callback({
create: (_entity: unknown, value: any) => value,
save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })),
createQueryBuilder: jest.fn(() => ({
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
})),
})),
};
const service = new BillsService(
billRepo as any,
itemRepo as any,
roomExpRepo as any,
personalExpRepo as any,
occRepo as any,
roomRepo as any,
dataSource as any,
{ debitBill: jest.fn(async (_manager, bill) => bill) } as any,
);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<RoomExpense>([
{ id: 1, roomId: 1, expenseType: 'water', amount: 100, periodStart: '2026-06-01', periodEnd: '2026-06-30' } as RoomExpense,
]));
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<Occupancy>([
{ id: 1, roomId: 1, studentId: 1, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
{ id: 2, roomId: 1, studentId: 2, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
{ id: 3, roomId: 1, studentId: 3, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
]));
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<PersonalExpense>([]));
const result = await service.generateBills({ periodStart: '2026-06-01', periodEnd: '2026-06-30' } as any);
expect(result.bills.map((bill) => Number(bill.totalAmount))).toEqual([33.33, 33.33, 33.34]);
expect(result.bills.reduce((sum, bill) => sum + Number(bill.totalAmount), 0)).toBe(100);
});
});