Files
TREK/server/tests/unit/nest/budget.service.test.ts
T
Maurice 244eb56e4c fix(budget): confine payers, split members and settlement parties to the trip
knownUserIds asked whether a user exists at all, not whether they are on this
trip, so any id on the instance could be made a payer or a split member. Both
read-backs join users, and the settlement projection carries the avatar through
avatarUrl, which passes an absolute URL straight out — for an OIDC account that
is the picture claim.

Payers and split members drop off-roster ids, as before, only against the right
set. Settlements cannot: they name exactly two parties and both columns are NOT
NULL, so dropping one would either break the constraint or move money to the
wrong person. Both wrappers refuse the write and return null, which lands on the
404 the update path already had. That answer is deliberately the same one a
missing settlement gets, so the endpoint says nothing about whether an id exists
— and it retires the foreign key error that used to surface as a 500 and drew
exactly that distinction.

createSettlement grows the 404 its sibling had; the MCP tool reuses the error
string already at hand. Money rules are untouched: a settlement is a transfer, so
refusing one cannot unbalance the sum, and the cent arithmetic is unchanged.
2026-08-16 23:17:50 +02:00

217 lines
11 KiB
TypeScript

/**
* Seam tests for the folded BudgetService: the permission/broadcast edges, the
* controller-facing composite methods (create/update/settlement wrappers that
* freeze the FX rate before the raw write — incl. the #1445 stored-currency
* thread-through) and the wrapper-only syncReservationPrice SQL. The SQL-heavy
* paths themselves are covered by budget.service.db.test.ts (real :memory: DB)
* and budget.service.calc.test.ts (settlement math over a prepare-stub mock).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the data + side-effect dependencies the service 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 } };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../../src/db/database', () => ({
db: dbMock,
closeDb: () => {},
reinitialize: () => {},
canAccessTrip,
getPlaceWithTags: () => null,
isOwner: () => false,
}));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
const checkPermission = vi.fn(() => true);
const permissionsStub = { checkPermission } as unknown as PermissionsService;
// Constructor-injected ExchangeRatesService stub (as in the pre-fold wrapper).
import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
const getRates = vi.fn();
const exchangeRatesStub = { getRates } as unknown as ExchangeRatesService;
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
function svc() {
return new BudgetService(new DatabaseService(dbConn), permissionsStub, exchangeRatesStub, new RealtimeService());
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
describe('BudgetService', () => {
it('verifyTripAccess resolves through DatabaseService.canAccessTrip', () => {
canAccessTrip.mockReturnValue({ id: 5, user_id: 2 });
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
expect(canAccessTrip).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 resolve through the folded SQL methods', () => {
const s = svc();
const listSpy = vi.spyOn(s, 'listBudgetItems').mockReturnValue([{ id: 1 }] as never);
expect(s.list('5')).toEqual([{ id: 1 }]);
expect(listSpy).toHaveBeenCalledWith('5');
const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockReturnValue([{ userId: 1 }] as never);
expect(s.perPersonSummary('5')).toEqual([{ userId: 1 }]);
expect(summarySpy).toHaveBeenCalledWith('5');
});
describe('settlement', () => {
it('upper-cases the explicit base and forwards the rates', async () => {
const s = svc();
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
getRates.mockResolvedValue({ USD: 1.1 });
await s.settlement('5', 'usd', 'EUR');
expect(getRates).toHaveBeenCalledWith('USD');
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
});
it('falls back to the trip currency when no base is given', async () => {
const s = svc();
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
getRates.mockResolvedValue(null);
await s.settlement('5', undefined, 'gbp');
expect(getRates).toHaveBeenCalledWith('GBP');
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
});
it('falls back to EUR when neither base nor trip currency is present', async () => {
const s = svc();
const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never);
getRates.mockResolvedValue(null);
await s.settlement('5', undefined, '');
expect(getRates).toHaveBeenCalledWith('EUR');
expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
});
});
it('create / update freeze the FX rate before the raw write', async () => {
const s = svc();
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
const createSpy = vi.spyOn(s, 'createBudgetItem').mockReturnValue({ id: 1 } as never);
await s.create('5', { name: 'Hotel' });
expect(freezeSpy).toHaveBeenCalledWith('5', { name: 'Hotel' });
expect(createSpy).toHaveBeenCalledWith('5', { name: 'Hotel' });
const updateSpy = vi.spyOn(s, 'updateBudgetItem').mockReturnValue({ id: 9 } as never);
await s.update('9', '5', { name: 'X' });
// the item id is threaded through so an unchanged-currency edit keeps the frozen rate
expect(freezeSpy).toHaveBeenCalledWith('5', { name: 'X' }, '9');
expect(updateSpy).toHaveBeenCalledWith('9', '5', { name: 'X' });
});
it('remove / setPayers resolve through the folded SQL methods', () => {
const s = svc();
const deleteSpy = vi.spyOn(s, 'deleteBudgetItem').mockReturnValue(true);
expect(s.remove('9', '5')).toBe(true);
expect(deleteSpy).toHaveBeenCalledWith('9', '5');
const payersSpy = vi.spyOn(s, 'setItemPayers').mockReturnValue({ id: 9 } as never);
s.setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
expect(payersSpy).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
});
describe('settlement ledger wrappers (#1445 freeze-then-write)', () => {
// Both wrappers refuse a party who is not on the trip before they freeze
// anything, so the roster lookup has to answer for these to reach the write
// at all. Users 1 and 2 are the two parties every case here settles between.
const rosterHas = (...userIds: number[]) => dbMock._stmt.all.mockReturnValue(userIds.map(user_id => ({ user_id })));
it('createSettlement freezes the FX rate (await) before the raw insert', async () => {
const s = svc();
rosterHas(1, 2);
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
const insertSpy = vi.spyOn(s, 'insertSettlement').mockReturnValue({ id: 7 } as never);
await s.createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
expect(freezeSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 });
expect(insertSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
});
it('updateSettlement threads the stored currency through the freeze', async () => {
const s = svc();
rosterHas(1, 2);
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
// Quirk fix: the stored-currency lookup is a targeted getSettlement, not
// a full listSettlements scan.
const getSpy = vi.spyOn(s, 'getSettlement').mockReturnValue({ id: 7, currency: 'USD' } as never);
const applySpy = vi.spyOn(s, 'applySettlementUpdate').mockReturnValue({ id: 7 } as never);
await s.updateSettlement('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
// the settlement's stored currency is threaded through so an unchanged-currency edit keeps the frozen rate (#1445)
expect(getSpy).toHaveBeenCalledWith('7', '5');
expect(freezeSpy).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' }, undefined, 'USD');
expect(applySpy).toHaveBeenCalledWith('7', '5', { from_user_id: 1, to_user_id: 2, amount: 12, currency: 'USD' });
});
it('refuses a party who is not on the trip, before freezing or writing', async () => {
const s = svc();
rosterHas(1); // user 2 is not on this trip
const freezeSpy = vi.spyOn(s, 'freezeForeignRate').mockResolvedValue();
const insertSpy = vi.spyOn(s, 'insertSettlement').mockReturnValue({ id: 7 } as never);
await expect(s.createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3)).resolves.toBeNull();
expect(freezeSpy).not.toHaveBeenCalled();
expect(insertSpy).not.toHaveBeenCalled();
});
});
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();
});
});
});