Files
TREK/server/tests/unit/nest/budget.controller.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

676 lines
31 KiB
TypeScript

import { BudgetController } from '../../../src/nest/budget/budget.controller';
import type { BudgetService } from '../../../src/nest/budget/budget.service';
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import type { User } from '../../../src/types';
import { HttpException } from '@nestjs/common';
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
import { describe, it, expect, vi } from 'vitest';
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(),
// Both write routes ask this before they write (#2084); null lets them through.
linkRefusal: vi.fn().mockReturnValue(null),
getBudgetItem: vi.fn().mockReturnValue(null),
resyncLinkedPrices: vi.fn(),
resyncReservationPrice: 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', async () => {
const svc = makeService({ list: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial<BudgetService>);
expect(await new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
});
it('GET /summary/per-person + /settlement delegate', async () => {
const settlement = vi.fn().mockResolvedValue({ transfers: [] });
const svc = makeService({
perPersonSummary: vi.fn().mockResolvedValue([{ 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(await 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', async () => {
const settlement = vi.fn().mockReturnValue({ transfers: [] });
const svc = makeService({
verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }),
settlement,
} as Partial<BudgetService>);
await new BudgetController(svc).settlement(user, tripRow, '5', { base: 'GBP' });
expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD', undefined);
});
it('GET /settlement forwards base and base_rate', async () => {
const settlement = vi.fn().mockResolvedValue({ transfers: [] });
const svc = makeService({ settlement } as Partial<BudgetService>);
// The Zod pipe has coerced base_rate to a number by the time the handler runs.
await 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', async () => {
const svc = makeService({ listSettlements: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial<BudgetService>);
expect(await 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('POST and PUT /settlements forward the note, as the MCP tools do (#2340)', async () => {
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, note: 'Cash' };
await new BudgetController(svc).createSettlement(user, '5', body);
expect(createSettlement).toHaveBeenCalledWith('5', expect.objectContaining({ note: 'Cash' }), user.id);
await new BudgetController(svc).updateSettlement(user, '5', '3', body);
expect(updateSettlement).toHaveBeenCalledWith(3, '5', expect.objectContaining({ note: 'Cash' }));
});
it('DELETE /settlements/:id 404 when missing', async () => {
const svc = makeService({ deleteSettlement: vi.fn().mockResolvedValue(false) } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({
status: 404,
body: { error: 'Settlement not found' },
});
});
// Plan 4 Task 8b (U6) — :settlementId is parsed ONCE at the controller gate (toRowId).
it('DELETE /settlements/:id 404 (not 500) on a non-numeric :settlementId', async () => {
const svc = makeService({ deleteSettlement: vi.fn() } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).deleteSettlement(user, '5', 'abc'))).toEqual({
status: 404,
body: { error: 'Settlement not found' },
});
});
it('DELETE /settlements/:id success broadcasts the numeric id', async () => {
const broadcast = vi.fn();
const svc = makeService({
deleteSettlement: vi.fn().mockResolvedValue(true),
broadcast,
} as Partial<BudgetService>);
expect(await 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' },
});
});
// Plan 4 Task 8b (U6) — :settlementId is parsed ONCE at the controller gate (toRowId).
it('PUT /settlements/:id 404 (not 500) on a non-numeric :settlementId', async () => {
const svc = makeService({ updateSettlement: vi.fn() } as Partial<BudgetService>);
expect(
await thrownAsync(() =>
new BudgetController(svc).updateSettlement(user, '5', 'abc', { 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 } });
// Plan 4 Task 8b (U6) — :settlementId is now parsed ONCE at the controller gate (toRowId).
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 linkRefusal = vi.fn().mockReturnValue(null);
const resyncReservationPrice = vi.fn();
const svc = makeService({ create, broadcast, linkRefusal, resyncReservationPrice } as Partial<BudgetService>);
expect(await new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({
item: { id: 9, name: 'Hotel' },
});
expect(linkRefusal).toHaveBeenCalledWith('5', { name: 'Hotel', total_price: 200 });
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock');
// Not linked to a booking, so no booking price to work out.
expect(resyncReservationPrice).not.toHaveBeenCalled();
});
it('an expense created on a booking adds to the price the booking mirrors (#2084)', async () => {
const item = { id: 9, name: 'Seat', total_price: 15, reservation_id: 42 };
const resyncReservationPrice = vi.fn();
const broadcast = vi.fn();
const svc = makeService({
create: vi.fn().mockReturnValue(item),
resyncReservationPrice,
broadcast,
} as Partial<BudgetService>);
expect(
await new BudgetController(svc).create(
user,
'5',
{ name: 'Seat', total_price: 15, reservation_id: 42 },
'sock',
),
).toEqual({ item });
expect(resyncReservationPrice).toHaveBeenCalledWith('5', 42, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item }, 'sock');
});
// #2084: an expense can only point at a booking or a place of its own trip.
// The body and the status are the contract the MCP tool mirrors word for word.
it('400s on a reservation_id from another trip, without writing or broadcasting', async () => {
const create = vi.fn();
const broadcast = vi.fn();
const resyncReservationPrice = vi.fn();
const linkRefusal = vi.fn().mockReturnValue('reservation_id does not belong to this trip.');
const svc = makeService({ create, broadcast, linkRefusal, resyncReservationPrice } as Partial<BudgetService>);
const body = { name: 'Hotel', total_price: 200, reservation_id: 4711 };
expect(await thrownAsync(() => new BudgetController(svc).create(user, '5', body, 'sock'))).toEqual({
status: 400,
body: { error: 'reservation_id does not belong to this trip.' },
});
expect(linkRefusal).toHaveBeenCalledWith('5', body);
expect(create).not.toHaveBeenCalled();
expect(resyncReservationPrice).not.toHaveBeenCalled();
expect(broadcast).not.toHaveBeenCalled();
});
it('400s on a place_id from another trip, without writing', async () => {
const create = vi.fn();
const svc = makeService({
create,
linkRefusal: vi.fn().mockReturnValue('place_id does not belong to this trip.'),
} as Partial<BudgetService>);
expect(
await thrownAsync(() => new BudgetController(svc).create(user, '5', { name: 'Tickets', place_id: 4711 })),
).toEqual({
status: 400,
body: { error: 'place_id does not belong to this trip.' },
});
expect(create).not.toHaveBeenCalled();
});
});
describe('PUT /:id', () => {
it('404 when item missing, without resyncing any booking', async () => {
const resyncLinkedPrices = vi.fn();
const svc = makeService({ update: vi.fn().mockReturnValue(null), resyncLinkedPrices } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
status: 404,
body: { error: 'Budget item not found' },
});
expect(resyncLinkedPrices).not.toHaveBeenCalled();
});
// Plan 4 Task 8b (U6) — :id is parsed ONCE at the controller gate (toRowId).
it('404 (not 500) on a non-numeric :id', async () => {
const svc = makeService({ update: vi.fn() } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', 'abc', { name: 'X' }))).toEqual({
status: 404,
body: { error: 'Budget item not found' },
});
});
// The link check sits above that gate: a body that reaches into another trip
// is a 400 whatever :id says, a malformed id does not turn it into a 404.
it('400s on a reservation_id from another trip even when :id is non-numeric', async () => {
const update = vi.fn();
const getBudgetItem = vi.fn();
const linkRefusal = vi.fn().mockReturnValue('reservation_id does not belong to this trip.');
const svc = makeService({ update, getBudgetItem, linkRefusal } as Partial<BudgetService>);
expect(
await thrownAsync(() => new BudgetController(svc).update(user, '5', 'abc', { reservation_id: 4711 })),
).toEqual({
status: 400,
body: { error: 'reservation_id does not belong to this trip.' },
});
expect(linkRefusal).toHaveBeenCalledWith('5', { reservation_id: 4711 });
expect(getBudgetItem).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it('hands a total change to resyncLinkedPrices without snapshotting the item first', async () => {
const updated = { id: 9, reservation_id: 42, total_price: 250 };
const update = vi.fn().mockReturnValue(updated);
const getBudgetItem = vi.fn();
const resyncLinkedPrices = vi.fn();
const broadcast = vi.fn();
const svc = makeService({ update, getBudgetItem, resyncLinkedPrices, broadcast } as Partial<BudgetService>);
expect(await new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock')).toEqual({
item: updated,
});
// No link in the body, so the booking cannot have changed and the stored row is not read.
expect(getBudgetItem).not.toHaveBeenCalled();
expect(resyncLinkedPrices).toHaveBeenCalledWith('5', undefined, updated, { total_price: 250 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: updated }, 'sock');
});
it('snapshots the item before a re-link, so the booking it leaves is resynced too', async () => {
const calls: string[] = [];
const getBudgetItem = vi.fn().mockImplementation(() => {
calls.push('snapshot');
return { id: 9, reservation_id: 42 };
});
const updated = { id: 9, reservation_id: 43, total_price: 80 };
const update = vi.fn().mockImplementation(() => {
calls.push('write');
return updated;
});
const resyncLinkedPrices = vi.fn();
const svc = makeService({ update, getBudgetItem, resyncLinkedPrices } as Partial<BudgetService>);
await new BudgetController(svc).update(user, '5', '9', { reservation_id: 43 }, 'sock');
expect(getBudgetItem).toHaveBeenCalledWith(9, '5');
expect(calls).toEqual(['snapshot', 'write']);
expect(resyncLinkedPrices).toHaveBeenCalledWith('5', 42, updated, { reservation_id: 43 }, 'sock');
});
it('snapshots on an unlink (reservation_id null) as well', async () => {
const getBudgetItem = vi.fn().mockReturnValue({ id: 9, reservation_id: 42 });
const updated = { id: 9, reservation_id: null, total_price: 80 };
const resyncLinkedPrices = vi.fn();
const svc = makeService({
update: vi.fn().mockReturnValue(updated),
getBudgetItem,
resyncLinkedPrices,
} as Partial<BudgetService>);
expect(await new BudgetController(svc).update(user, '5', '9', { reservation_id: null })).toEqual({
item: updated,
});
expect(resyncLinkedPrices).toHaveBeenCalledWith('5', 42, updated, { reservation_id: null }, undefined);
});
it('400s on a reservation_id from another trip, before reading, writing or resyncing', async () => {
const update = vi.fn();
const getBudgetItem = vi.fn();
const resyncLinkedPrices = vi.fn();
const broadcast = vi.fn();
const linkRefusal = vi.fn().mockReturnValue('reservation_id does not belong to this trip.');
const svc = makeService({
update,
getBudgetItem,
resyncLinkedPrices,
broadcast,
linkRefusal,
} as Partial<BudgetService>);
expect(
await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { reservation_id: 4711 }, 'sock')),
).toEqual({
status: 400,
body: { error: 'reservation_id does not belong to this trip.' },
});
expect(linkRefusal).toHaveBeenCalledWith('5', { reservation_id: 4711 });
expect(getBudgetItem).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expect(resyncLinkedPrices).not.toHaveBeenCalled();
expect(broadcast).not.toHaveBeenCalled();
});
it('400s on a place_id from another trip, without writing', async () => {
const update = vi.fn();
const svc = makeService({
update,
linkRefusal: vi.fn().mockReturnValue('place_id does not belong to this trip.'),
} as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).update(user, '5', '9', { place_id: 4711 }))).toEqual({
status: 400,
body: { error: 'place_id does not belong to this trip.' },
});
expect(update).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', async () => {
const svc = makeService({ updateMembers: vi.fn().mockResolvedValue(null) } as Partial<BudgetService>);
expect(
await thrownAsync(() => 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', async () => {
const updateMembers = vi.fn().mockResolvedValue({ members: [{ user_id: 2 }], item: { persons: 1 } });
const broadcast = vi.fn();
const svc = makeService({ updateMembers, broadcast } as Partial<BudgetService>);
const res = await new BudgetController(svc).updateMembers(user, '5', '9', { user_ids: [2] }, 'sock');
expect(res).toEqual({ members: [{ user_id: 2 }], item: { persons: 1 } });
// Plan 4 Task 8b (U6) — :id is now parsed ONCE at the controller gate (toRowId).
expect(updateMembers).toHaveBeenCalledWith(9, '5', [2]);
expect(broadcast).toHaveBeenCalledWith(
'5',
'budget:members-updated',
{ itemId: 9, members: [{ user_id: 2 }], persons: 1 },
'sock',
);
});
it('404 (not 500) on a non-numeric :id', async () => {
const svc = makeService({ updateMembers: vi.fn() } as Partial<BudgetService>);
expect(
await thrownAsync(() => new BudgetController(svc).updateMembers(user, '5', 'abc', { user_ids: [2] })),
).toEqual({
status: 404,
body: { error: 'Budget item not found' },
});
});
});
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', async () => {
const resyncReservationPrice = vi.fn();
const svc = makeService({
setPayers: vi.fn().mockResolvedValue(null),
resyncReservationPrice,
} as Partial<BudgetService>);
expect(
await thrownAsync(() =>
new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 10 }] }),
),
).toEqual({
status: 404,
body: { error: 'Budget item not found' },
});
expect(resyncReservationPrice).not.toHaveBeenCalled();
});
it('sets payers and broadcasts budget:updated', async () => {
const setPayers = vi.fn().mockResolvedValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] });
const broadcast = vi.fn();
const resyncReservationPrice = vi.fn();
const svc = makeService({ setPayers, broadcast, resyncReservationPrice } as Partial<BudgetService>);
const res = await 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 }] } });
// Plan 4 Task 8b (U6) — :id is now parsed ONCE at the controller gate (toRowId).
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',
);
expect(resyncReservationPrice).not.toHaveBeenCalled();
});
it('resyncs the linked booking, since the payers derive the total it mirrors (#2084)', async () => {
const item = { id: 9, reservation_id: 42, total_price: 30, payers: [{ user_id: 2, amount: 30 }] };
const resyncReservationPrice = vi.fn();
const broadcast = vi.fn();
const svc = makeService({
setPayers: vi.fn().mockReturnValue(item),
resyncReservationPrice,
broadcast,
} as Partial<BudgetService>);
expect(
await new BudgetController(svc).setPayers(user, '5', '9', { payers: [{ user_id: 2, amount: 30 }] }, 'sock'),
).toEqual({ item });
expect(resyncReservationPrice).toHaveBeenCalledWith('5', 42, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item }, 'sock');
});
it('404 (not 500) on a non-numeric :id', async () => {
const svc = makeService({ setPayers: vi.fn() } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).setPayers(user, '5', 'abc', { payers: [] }))).toEqual({
status: 404,
body: { error: 'Budget item not found' },
});
});
});
it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', async () => {
const toggleMemberPaid = vi.fn().mockResolvedValue({ user_id: 2, paid: 1 });
const broadcast = vi.fn();
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
expect(await 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', async () => {
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 0 });
const broadcast = vi.fn();
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
await 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',
);
});
// Plan 4 Task 8b (U6) — :id/:userId are now parsed ONCE at the gate
// (toRowId), but unlike every other handler in this controller the
// legacy route never 404'd an unknown item/member here either: it always
// answered 200 { member } and always broadcast Number(id)/Number(userId)
// regardless. A malformed id preserves that exact shape — the service
// call is skipped (nothing would have matched anyway) and the broadcast
// keeps its NaN fallback, matching what Number('abc') always produced.
it('PUT /:id/members/:userId/paid: a non-numeric :id skips the write but keeps the legacy 200 + broadcast shape', async () => {
const toggleMemberPaid = vi.fn();
const broadcast = vi.fn();
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
const res = await new BudgetController(svc).toggleMemberPaid(user, '5', 'abc', '2', { paid: true }, 'sock');
expect(res).toEqual({ member: null });
expect(toggleMemberPaid).not.toHaveBeenCalled();
expect(broadcast).toHaveBeenCalledWith(
'5',
'budget:member-paid-updated',
{ itemId: NaN, userId: 2, paid: 1 },
'sock',
);
});
it('DELETE /:id 404 when missing, success otherwise', async () => {
const missing = makeService({ remove: vi.fn().mockResolvedValue(false) } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({
status: 404,
body: { error: 'Budget item not found' },
});
const ok = makeService({ remove: vi.fn().mockResolvedValue(true), broadcast: vi.fn() } as Partial<BudgetService>);
expect(await new BudgetController(ok).remove(user, '5', '9')).toEqual({ success: true });
});
// Plan 4 Task 8b (U6) — :id is parsed ONCE at the controller gate (toRowId).
it('DELETE /:id 404 (not 500) on a non-numeric :id', async () => {
const svc = makeService({ remove: vi.fn() } as Partial<BudgetService>);
expect(await thrownAsync(() => new BudgetController(svc).remove(user, '5', 'abc'))).toEqual({
status: 404,
body: { error: 'Budget item not found' },
});
});
it('PUT /reorder/items + /reorder/categories broadcast budget:reordered', async () => {
const reorderItems = vi.fn();
const reorderCategories = vi.fn();
const broadcast = vi.fn();
const svc = makeService({ reorderItems, reorderCategories, broadcast } as Partial<BudgetService>);
expect(await new BudgetController(svc).reorderItems(user, '5', { orderedIds: [3, 1] }, 'sock')).toEqual({
success: true,
});
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1]);
expect(
await new BudgetController(svc).reorderCategories(user, '5', { orderedCategories: ['food', 'fun'] }, 'sock'),
).toEqual({ success: true });
expect(reorderCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
});
});