492 lines
18 KiB
TypeScript
492 lines
18 KiB
TypeScript
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: Jan–Mar 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 15–30 (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);
|
||
});
|
||
});
|