Files
TREK/server/tests/unit/nest/budget.service.test.ts
T
Maurice 7266ad99ae Restore nest coverage to >=80% after the #1209 dep bump (istanbul provider + branch tests) (#1213)
* fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump

* fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4)

* test(nest): cover controller/service branches to clear the 80% coverage gate
2026-06-16 21:36:39 +02:00

166 lines
7.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the data + side-effect dependencies the wrapper reaches into directly.
const { dbMock } = vi.hoisted(() => {
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
});
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
const { getRates } = vi.hoisted(() => ({ getRates: vi.fn() }));
vi.mock('../../../src/services/exchangeRateService', () => ({ getRates }));
const { budget } = vi.hoisted(() => ({
budget: {
verifyTripAccess: vi.fn(),
listBudgetItems: vi.fn(),
getPerPersonSummary: vi.fn(),
calculateSettlement: vi.fn(),
createBudgetItem: vi.fn(),
updateBudgetItem: vi.fn(),
deleteBudgetItem: vi.fn(),
updateMembers: vi.fn(),
toggleMemberPaid: vi.fn(),
setItemPayers: vi.fn(),
listSettlements: vi.fn(),
createSettlement: vi.fn(),
deleteSettlement: vi.fn(),
reorderBudgetItems: vi.fn(),
reorderBudgetCategories: vi.fn(),
},
}));
vi.mock('../../../src/services/budgetService', () => budget);
import { BudgetService } from '../../../src/nest/budget/budget.service';
function svc() {
return new BudgetService();
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
describe('BudgetService', () => {
it('verifyTripAccess delegates to the legacy service', () => {
budget.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
expect(budget.verifyTripAccess).toHaveBeenCalledWith('5', 2);
});
it('canEdit forwards the ownership flag when the user owns the trip', () => {
checkPermission.mockReturnValue(true);
expect(svc().canEdit({ user_id: 1 } as never, { id: 1, role: 'user' } as never)).toBe(true);
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 1, 1, false);
});
it('canEdit marks the user as a guest when they do not own the trip', () => {
checkPermission.mockReturnValue(false);
expect(svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never)).toBe(false);
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 2, 1, true);
});
it('broadcast forwards to the websocket helper', () => {
svc().broadcast('5', 'budget:created', { item: { id: 1 } }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock');
});
it('list / perPersonSummary delegate', () => {
budget.listBudgetItems.mockReturnValue([{ id: 1 }]);
expect(svc().list('5')).toEqual([{ id: 1 }]);
budget.getPerPersonSummary.mockReturnValue([{ userId: 1 }]);
expect(svc().perPersonSummary('5')).toEqual([{ userId: 1 }]);
});
describe('settlement', () => {
it('upper-cases the explicit base and forwards the rates', async () => {
getRates.mockResolvedValue({ USD: 1.1 });
budget.calculateSettlement.mockReturnValue({ transfers: [] });
await svc().settlement('5', 'usd', 'EUR');
expect(getRates).toHaveBeenCalledWith('USD');
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
});
it('falls back to the trip currency when no base is given', async () => {
getRates.mockResolvedValue(null);
await svc().settlement('5', undefined, 'gbp');
expect(getRates).toHaveBeenCalledWith('GBP');
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
});
it('falls back to EUR when neither base nor trip currency is present', async () => {
getRates.mockResolvedValue(null);
await svc().settlement('5', undefined, '');
expect(getRates).toHaveBeenCalledWith('EUR');
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
});
});
it('create / update / remove / members / paid / payers delegate', () => {
svc().create('5', { name: 'Hotel' } as never);
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' });
svc().update('9', '5', { name: 'X' });
expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' });
svc().remove('9', '5');
expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5');
svc().updateMembers('9', '5', [2, 3]);
expect(budget.updateMembers).toHaveBeenCalledWith('9', '5', [2, 3]);
svc().toggleMemberPaid('9', '5', '2', true);
expect(budget.toggleMemberPaid).toHaveBeenCalledWith('9', '5', '2', true);
svc().setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
expect(budget.setItemPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
});
it('settlement ledger + reorder delegate', () => {
svc().listSettlements('5');
expect(budget.listSettlements).toHaveBeenCalledWith('5');
svc().createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
expect(budget.createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
svc().deleteSettlement('7', '5');
expect(budget.deleteSettlement).toHaveBeenCalledWith('7', '5');
svc().reorderItems('5', [3, 1]);
expect(budget.reorderBudgetItems).toHaveBeenCalledWith('5', [3, 1]);
svc().reorderCategories('5', ['food', 'fun']);
expect(budget.reorderBudgetCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
});
describe('syncReservationPrice', () => {
it('returns early when the reservation is not found', () => {
dbMock._stmt.get.mockReturnValueOnce(undefined);
svc().syncReservationPrice('5', 42, 250, 'sock');
expect(dbMock._stmt.run).not.toHaveBeenCalled();
expect(broadcast).not.toHaveBeenCalled();
});
it('merges into existing metadata and broadcasts reservation:updated', () => {
dbMock._stmt.get
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
svc().syncReservationPrice('5', 42, 250, 'sock');
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' });
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock');
});
it('starts from an empty object when the reservation has no metadata', () => {
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 });
svc().syncReservationPrice('5', 42, 99, undefined);
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
expect(writtenMeta).toEqual({ price: '99' });
});
it('swallows errors so a sync failure never breaks the budget update', () => {
dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); });
expect(() => svc().syncReservationPrice('5', 42, 250, 'sock')).not.toThrow();
expect(broadcast).not.toHaveBeenCalled();
});
});
});