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(), 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); }); 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', 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([]), ); 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', 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([]), ); 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', 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([]), ); 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); }); // ============================================================ // 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([ { 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([ { 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([]), ); const result = await service.generateBills(THREE_MONTHS); expect(result.count).toBe(1); const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record]>; 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([ { 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([ { 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([]), ); const result = await service.generateBills(PERIOD); expect(result.count).toBe(1); const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record]>; 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([ { 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([ { 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([ { 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([]), ); // 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]>; 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([ { 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([ { 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([ { 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]>; 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([ { 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([ { 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([]), ); 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([]), ); (occRepo.createQueryBuilder as jest.Mock).mockReturnValue( mockQueryBuilder([ { 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([]), ); const result = await service.generateBills(PERIOD); expect(result.count).toBe(0); }); });