Files
TREK/server/tests/unit/nest/budget.controller.test.ts
T
Maurice aa1f76ad11 fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
2026-09-26 13:19:26 +02:00

307 lines
17 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
import { BudgetController } from '../../../src/nest/budget/budget.controller';
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import type { BudgetService } from '../../../src/nest/budget/budget.service';
import type { User } from '../../../src/types';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const trip = { id: 5, user_id: 1 };
function makeService(overrides: Partial<BudgetService> = {}): BudgetService {
return {
verifyTripAccess: vi.fn().mockReturnValue(trip),
canEdit: vi.fn().mockReturnValue(true),
broadcast: vi.fn(),
syncReservationPrice: vi.fn(),
...overrides,
} as unknown as BudgetService;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try {
fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected the handler to throw');
}
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected the handler to throw');
}
/** The trip row TripAccessGuard resolves and @Trip() hands to the settlement route. */
const tripRow = { id: 5, user_id: 42, currency: 'USD' } as never;
describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => {
it('GET / returns items', () => {
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
});
it('GET /summary/per-person + /settlement delegate', async () => {
const settlement = vi.fn().mockReturnValue({ transfers: [] });
const svc = makeService({
perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]),
settlement,
} as Partial<BudgetService>);
expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
// A trip with no currency set falls back to EUR rather than passing undefined on.
expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5', {})).toEqual({ transfers: [] });
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR', undefined);
});
it('GET /settlement forwards the base query and the trip currency', () => {
const settlement = vi.fn().mockReturnValue({ transfers: [] });
const svc = makeService({
verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }),
settlement,
} as Partial<BudgetService>);
new BudgetController(svc).settlement(user, tripRow, '5', { base: 'GBP' });
expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD', undefined);
});
it('GET /settlement forwards base and base_rate', () => {
const settlement = vi.fn().mockReturnValue({ transfers: [] });
const svc = makeService({ settlement } as Partial<BudgetService>);
// The Zod pipe has coerced base_rate to a number by the time the handler runs.
new BudgetController(svc).settlement(user, tripRow, '5', { base: 'EUR', base_rate: 0.61 });
expect(settlement).toHaveBeenCalledWith('5', 'EUR', 'USD', 0.61);
});
describe('settlements ledger', () => {
it('GET /settlements lists', () => {
const svc = makeService({ listSettlements: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
expect(new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] });
});
// The legacy 'from_user_id, to_user_id and amount are required' 400s are now
// produced by the global ZodValidationPipe (budget.dto.ts) before the handler
// runs — covered by the integration suite, not constructible here.
it('POST /settlements creates and broadcasts (amount 0 is allowed), forwarding the display currency and the settled day', async () => {
const createSettlement = vi.fn().mockResolvedValue({ id: 3, amount: 0 });
const broadcast = vi.fn();
const svc = makeService({ createSettlement, broadcast } as Partial<BudgetService>);
const res = await new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD', settled_at: '2026-01-05' }, 'sock');
expect(res).toEqual({ settlement: { id: 3, amount: 0 } });
expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0, currency: 'USD', settled_at: '2026-01-05' }, user.id);
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock');
});
it('POST and PUT /settlements forward fallback_fx', async () => {
const fallback_fx = { base: 'AUD', rates: { EUR: 0.61 } };
const createSettlement = vi.fn().mockResolvedValue({ id: 3 });
const updateSettlement = vi.fn().mockResolvedValue({ id: 3 });
const svc = makeService({ createSettlement, updateSettlement } as Partial<BudgetService>);
const body = { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'EUR', fallback_fx };
await new BudgetController(svc).createSettlement(user, '5', body);
expect(createSettlement).toHaveBeenCalledWith('5', expect.objectContaining({ currency: 'EUR', fallback_fx }), user.id);
await new BudgetController(svc).updateSettlement(user, '5', '3', body);
expect(updateSettlement).toHaveBeenCalledWith('3', '5', expect.objectContaining({ currency: 'EUR', fallback_fx }));
});
it('DELETE /settlements/:id 404 when missing', () => {
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({
status: 404, body: { error: 'Settlement not found' },
});
});
it('DELETE /settlements/:id success broadcasts the numeric id', () => {
const broadcast = vi.fn();
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(true), broadcast } as Partial<BudgetService>);
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
});
it('PUT /settlements/:id 404 when missing', async () => {
const svc = makeService({ updateSettlement: vi.fn().mockResolvedValue(null) } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
status: 404, body: { error: 'Settlement not found' },
});
});
it('PUT /settlements/:id updates and broadcasts, forwarding the display currency and the settled day', async () => {
const updateSettlement = vi.fn().mockResolvedValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
const broadcast = vi.fn();
const svc = makeService({ updateSettlement, broadcast } as Partial<BudgetService>);
const res = await new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD', settled_at: '2026-01-06' }, 'sock');
expect(res).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15, currency: 'USD', settled_at: '2026-01-06' });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock');
});
});
describe('POST /freeze-rates', () => {
const fallback_fx = { base: 'AUD', rates: { VND: 18241.3 } };
it('forwards fallback_fx and broadcasts per row', async () => {
const healed = {
items: [{ id: 7, exchange_rate: 18241.3 }, { id: 8, exchange_rate: 18241.3 }],
settlements: [{ id: 3, exchange_rate: 18241.3 }],
unresolved: ['XAF'],
};
const freezeMissingRates = vi.fn().mockResolvedValue(healed);
const broadcast = vi.fn();
const svc = makeService({ freezeMissingRates, broadcast } as Partial<BudgetService>);
expect(await new BudgetController(svc).freezeRates(user, '5', { fallback_fx }, 'sock')).toEqual(healed);
expect(freezeMissingRates).toHaveBeenCalledWith('5', fallback_fx);
expect(broadcast.mock.calls).toEqual([
['5', 'budget:updated', { item: healed.items[0] }, 'sock'],
['5', 'budget:updated', { item: healed.items[1] }, 'sock'],
['5', 'budget:settlement-updated', { settlement: healed.settlements[0] }, 'sock'],
]);
});
it('answers 409 and broadcasts nothing when the trip currency changed meanwhile', async () => {
const broadcast = vi.fn();
const svc = makeService({ freezeMissingRates: vi.fn().mockResolvedValue(null), broadcast } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).freezeRates(user, '5', {}))).toEqual({
status: 409, body: { error: 'The trip currency changed. Reload and try again.' },
});
expect(broadcast).not.toHaveBeenCalled();
});
it('requires budget_edit like freeze_budget_rates, and answers 200', () => {
const handler = BudgetController.prototype.freezeRates;
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBe('budget_edit');
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200);
});
});
describe('POST /', () => {
// The legacy 'Name is required' 400 is now produced by the global
// ZodValidationPipe (budgetCreateItemRequestSchema requires name) before
// the handler runs — covered by the integration suite.
it('creates and broadcasts', async () => {
const create = vi.fn().mockReturnValue({ id: 9, name: 'Hotel' });
const broadcast = vi.fn();
const svc = makeService({ create, broadcast } as Partial<BudgetService>);
expect(await new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock');
});
});
describe('PUT /:id', () => {
it('404 when item missing', async () => {
const svc = makeService({ update: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
status: 404, body: { error: 'Budget item not found' },
});
});
it('syncs the reservation price when a linked item changes total_price', async () => {
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: 42, total_price: 250 });
const syncReservationPrice = vi.fn();
const broadcast = vi.fn();
const svc = makeService({ update, syncReservationPrice, broadcast } as Partial<BudgetService>);
await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock');
expect(syncReservationPrice).toHaveBeenCalledWith('5', 42, 250, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, reservation_id: 42, total_price: 250 } }, 'sock');
});
it('does not sync when the item has no linked reservation', async () => {
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: null, total_price: 250 });
const syncReservationPrice = vi.fn();
const svc = makeService({ update, syncReservationPrice } as Partial<BudgetService>);
await new BudgetController(svc).update(user, '5', '9', { total_price: 250 });
expect(syncReservationPrice).not.toHaveBeenCalled();
});
});
describe('PUT /:id/members', () => {
// The legacy 'user_ids must be an array' 400 is now produced by the global
// ZodValidationPipe (budgetUpdateMembersRequestSchema) before the handler runs.
it('404 when the item is missing', () => {
const svc = makeService({ updateMembers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
expect(thrown(() => new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2, 3] }))).toEqual({
status: 404, body: { error: 'Budget item not found' },
});
});
it('updates members and broadcasts persons count', () => {
const updateMembers = vi.fn().mockReturnValue({ members: [{ user_id: 2 }], item: { persons: 1 } });
const broadcast = vi.fn();
const svc = makeService({ updateMembers, broadcast } as Partial<BudgetService>);
const res = new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2] }, 'sock');
expect(res).toEqual({ members: [{ user_id: 2 }], item: { persons: 1 } });
expect(updateMembers).toHaveBeenCalledWith('9', '5', [2]);
expect(broadcast).toHaveBeenCalledWith('5', 'budget:members-updated', { itemId: 9, members: [{ user_id: 2 }], persons: 1 }, 'sock');
});
});
describe('PUT /:id/payers', () => {
// The legacy 'payers must be an array' 400 is now produced by the global
// ZodValidationPipe (budgetUpdatePayersRequestSchema) before the handler runs.
it('404 when the item is missing', () => {
const svc = makeService({ setPayers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
expect(thrown(() => new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }))).toEqual({
status: 404, body: { error: 'Budget item not found' },
});
});
it('sets payers and broadcasts budget:updated', () => {
const setPayers = vi.fn().mockReturnValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] });
const broadcast = vi.fn();
const svc = makeService({ setPayers, broadcast } as Partial<BudgetService>);
const res = new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }, 'sock');
expect(res).toEqual({ item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } });
expect(setPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }, 'sock');
});
});
it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', () => {
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 1 });
const broadcast = vi.fn();
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
expect(new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', { paid: true }, 'sock')).toEqual({ member: { user_id: 2, paid: 1 } });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 1 }, 'sock');
});
it('PUT /:id/members/:userId/paid broadcasts paid: 0 when toggled off', () => {
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 0 });
const broadcast = vi.fn();
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', { paid: false }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 0 }, 'sock');
});
it('DELETE /:id 404 when missing, success otherwise', () => {
const missing = makeService({ remove: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({
status: 404, body: { error: 'Budget item not found' },
});
const ok = makeService({ remove: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial<BudgetService>);
expect(new BudgetController(ok).remove(user, '5', '9')).toEqual({ success: true });
});
it('PUT /reorder/items + /reorder/categories broadcast budget:reordered', () => {
const reorderItems = vi.fn(); const reorderCategories = vi.fn(); const broadcast = vi.fn();
const svc = makeService({ reorderItems, reorderCategories, broadcast } as Partial<BudgetService>);
expect(new BudgetController(svc).reorderItems(user, '5', { orderedIds: [3, 1] }, 'sock')).toEqual({ success: true });
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1]);
expect(new BudgetController(svc).reorderCategories(user, '5', { orderedCategories: ['food', 'fun'] }, 'sock')).toEqual({ success: true });
expect(reorderCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
});
});