Files
TREK/server/tests/unit/mcp/tools-budget.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

1436 lines
56 KiB
TypeScript

/**
* Unit tests for MCP budget tools: create_budget_item, update_budget_item,
* delete_budget_item, plus the settlement cases that turn on the currency the
* payment was made in (the rest of the settlement surface lives in
* tools-budget-advanced.test.ts).
*/
import { db as testDb } from '../../../src/db/database';
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
import {
createUser,
createTrip,
createBudgetItem,
createPlace,
createReservation,
addTripMember,
} from '../../helpers/factories';
import { addBudgetItemMember, addBudgetItemPayer } from '../../helpers/factories/budget';
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { setAppSetting } from '../../helpers/factories/settings';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
const realtime = new FakeRealtimeService();
const broadcastMock = realtime.broadcastMock;
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
// Freezing the FX rate of a foreign currency is a real fetch to
// api.frankfurter.dev with a 10 s abort. Fail closed by default; the cases
// that assert a frozen rate install their own rate stub.
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('offline');
}),
);
});
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
vi.unstubAllGlobals();
await orm.close();
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ realtime, userId, withResources: false });
try {
await fn(h);
} finally {
await h.cleanup();
}
}
function errorText(result: Awaited<ReturnType<McpHarness['client']['callTool']>>): string {
const text = (result.content as { type: string; text?: string }[]).find((c) => c.type === 'text');
return text?.text ?? '';
}
function tripWithTwo() {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, other.id);
return { user, other, trip };
}
async function memberRows(itemId: number) {
return (await findRows(orm, BudgetItemMembers, { budgetItem: itemId }, { user: 'asc' })).map((m) => ({
user_id: m.user_id,
amount: m.amount ?? null,
}));
}
/** The stored expense; fails the case when it is gone. */
async function itemRow(itemId: number) {
const row = await findRow(orm, BudgetItems, { id: itemId });
if (!row) throw new Error(`no budget item ${itemId}`);
return row;
}
function itemCount(tripId: number): Promise<number> {
return countRows(orm, BudgetItems, { trip: tripId });
}
/** The stored settlement; fails the case when it is gone. */
async function settlementRow(id: number) {
const row = await findRow(orm, BudgetSettlements, { id });
if (!row) throw new Error(`no settlement ${id}`);
return row;
}
// budget_settlements is not one of the tables the reset clears, and trip ids are
// reused, so a count is only ever compared against the count taken in the same test.
function settlementCount(tripId: number): Promise<number> {
return countRows(orm, BudgetSettlements, { trip: tripId });
}
/** Frankfurter's shape: one entry per quote, the base's own rate omitted. */
function stubRates(rates: Record<string, number>): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
text: async () =>
JSON.stringify(
Object.entries(rates).map(([quote, rate]) => ({ date: '2026-08-28', base: 'EUR', quote, rate })),
),
})),
);
}
// ---------------------------------------------------------------------------
// create_budget_item
// ---------------------------------------------------------------------------
describe('Tool: create_budget_item', () => {
it('creates a budget item with all fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Hotel Paris',
category: 'Accommodation',
total_price: 500,
note: 'Prepaid',
},
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Hotel Paris');
expect(data.item.category).toBe('Accommodation');
expect(data.item.total_price).toBe(500);
expect(data.item.note).toBe('Prepaid');
});
});
it('defaults category to "Other" when not specified', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Misc', total_price: 10 },
});
const data = parseToolResult(result) as any;
expect(data.item.category).toBe('other');
});
});
it('broadcasts budget:created event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Taxi', total_price: 25 },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:created', expect.any(Object));
});
});
// Regression for #1244: a naive create must seed members so the client save-gate
// (participants.size > 0) passes — the entry must be saveable, not member-less.
it('defaults members to the trip owner when member_ids omitted (solo trip)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Dinner', total_price: 40 },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
expect(data.item.persons).toBe(1);
// saveable invariant: client requires participants.size > 0
expect(data.item.members.length).toBeGreaterThan(0);
});
});
it('defaults members to all trip members when member_ids omitted (multi-member)', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Group taxi', total_price: 60 },
});
const data = parseToolResult(result) as any;
const ids = data.item.members.map((m: any) => m.user_id).sort();
expect(ids).toEqual([user.id, member.id].sort());
expect(data.item.persons).toBe(2);
});
});
it('respects an explicit member_ids subset', async () => {
const { user } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'My snack', total_price: 5, member_ids: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
});
});
it('treats an explicit empty member_ids as a planning-only entry (no split)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Estimate', total_price: 100, member_ids: [] },
});
const data = parseToolResult(result) as any;
expect(data.item.members).toEqual([]);
});
});
it('round-trips currency, expense_date, and payers', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Museum',
total_price: 30,
currency: 'EUR',
expense_date: '2026-07-01',
payers: [{ user_id: user.id, amount: 30 }],
},
});
const data = parseToolResult(result) as any;
expect(data.item.currency).toBe('EUR');
expect(data.item.expense_date).toBe('2026-07-01');
expect(data.item.payers.map((p: any) => p.user_id)).toEqual([user.id]);
// total_price derives from payer sum
expect(data.item.total_price).toBe(30);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Hack', total_price: 0 },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'X', total_price: 0 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_budget_item
// ---------------------------------------------------------------------------
describe('Tool: update_budget_item', () => {
it('updates budget item fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Old', category: 'Food', total_price: 50 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'New Name', total_price: 75 },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('New Name');
expect(data.item.total_price).toBe(75);
expect(data.item.category).toBe('Food'); // preserved
});
});
it('broadcasts budget:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:updated', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: 99999, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_budget_item
// ---------------------------------------------------------------------------
describe('Tool: delete_budget_item', () => {
it('deletes an existing budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_budget_item',
arguments: { tripId: trip.id, itemId: item.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(await findRow(orm, BudgetItems, { id: item.id })).toBeNull();
});
});
it('broadcasts budget:deleted event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:deleted', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_budget_item',
arguments: { tripId: trip.id, itemId: 99999 },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_budget_item',
arguments: { tripId: trip.id, itemId: item.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Resource: trek://trips/{tripId}/budget (moved from the legacy resources.ts
// registrar to the DI-discovered BudgetMcp — attached via the nest-mcp registry)
// ---------------------------------------------------------------------------
describe('Resource: trek://trips/{tripId}/budget', () => {
it('returns budget items for a trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation', total_price: 200 });
await withHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
const items = parseResourceResult(result) as { name: string }[];
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Hotel');
});
});
it('returns access denied for unauthorized trip', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
const data = parseResourceResult(result) as { error?: string };
expect(data.error).toBeTruthy();
});
});
});
// ---------------------------------------------------------------------------
// Uneven splits: members carries what each participant owes, the way the REST
// body has since the Costs rework. member_ids alone always meant an equal share.
// ---------------------------------------------------------------------------
describe('Tool: create_budget_item (uneven split)', () => {
it('persists what each member owes', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Dinner',
total_price: 100,
members: [
{ user_id: user.id, amount: 70 },
{ user_id: other.id, amount: 30 },
],
},
});
const data = parseToolResult(result) as any;
expect(await memberRows(data.item.id)).toEqual([
{ user_id: user.id, amount: 70 },
{ user_id: other.id, amount: 30 },
]);
expect((await itemRow(data.item.id)).persons).toBe(2);
});
});
it('refuses a split that does not add up to the total', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Dinner',
total_price: 100,
members: [
{ user_id: user.id, amount: 60 },
{ user_id: other.id, amount: 30 },
],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('does not add up');
expect(await itemCount(trip.id)).toBe(0);
});
});
it('measures the split against the payer sum, not the stated total', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Groceries',
total_price: 100,
payers: [{ user_id: user.id, amount: 90 }],
members: [
{ user_id: user.id, amount: 60 },
{ user_id: other.id, amount: 30 },
],
},
});
const data = parseToolResult(result) as any;
expect((await itemRow(data.item.id)).total_price).toBe(90);
expect((await memberRows(data.item.id)).map((m) => m.amount)).toEqual([60, 30]);
});
});
it('refuses a split that matches the stated total but not what the payers paid', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Groceries',
total_price: 100,
payers: [{ user_id: user.id, amount: 90 }],
members: [
{ user_id: user.id, amount: 70 },
{ user_id: other.id, amount: 30 },
],
},
});
expect(result.isError).toBe(true);
expect(await itemCount(trip.id)).toBe(0);
});
});
it('refuses a member who is not on the trip', async () => {
const { user, trip } = tripWithTwo();
const { user: stranger } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Dinner',
total_price: 100,
members: [
{ user_id: user.id, amount: 50 },
{ user_id: stranger.id, amount: 50 },
],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not on this trip');
expect(await itemCount(trip.id)).toBe(0);
});
});
it('refuses members alongside member_ids', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Dinner',
total_price: 100,
member_ids: [user.id, other.id],
members: [
{ user_id: user.id, amount: 50 },
{ user_id: other.id, amount: 50 },
],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not both');
expect(await itemCount(trip.id)).toBe(0);
});
});
});
describe('Tool: update_budget_item (uneven split)', () => {
async function itemWithEqualSplit(tripId: number, userIds: number[]) {
const item = createBudgetItem(testDb, tripId, { name: 'Dinner', total_price: 100 });
for (const id of userIds) await addBudgetItemMember(orm, item.id, id, { paid: 0, amount: null });
await updateRows(orm, BudgetItems, { id: item.id }, { persons: userIds.length });
return item;
}
it('replaces the equal split with per-member amounts', async () => {
const { user, other, trip } = tripWithTwo();
const item = await itemWithEqualSplit(trip.id, [user.id, other.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id,
itemId: item.id,
members: [
{ user_id: user.id, amount: 40 },
{ user_id: other.id, amount: 60 },
],
},
});
expect(result.isError).toBeFalsy();
expect(await memberRows(item.id)).toEqual([
{ user_id: user.id, amount: 40 },
{ user_id: other.id, amount: 60 },
]);
expect((await itemRow(item.id)).persons).toBe(2);
});
});
it('measures the split against the stored total when the call does not restate it', async () => {
const { user, other, trip } = tripWithTwo();
const item = await itemWithEqualSplit(trip.id, [user.id, other.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id,
itemId: item.id,
members: [
{ user_id: user.id, amount: 40 },
{ user_id: other.id, amount: 50 },
],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('100.00');
expect(await memberRows(item.id)).toEqual([
{ user_id: user.id, amount: null },
{ user_id: other.id, amount: null },
]);
});
});
it('refuses the same member twice', async () => {
const { user, other, trip } = tripWithTwo();
const item = await itemWithEqualSplit(trip.id, [user.id, other.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id,
itemId: item.id,
members: [
{ user_id: user.id, amount: 50 },
{ user_id: user.id, amount: 50 },
],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('twice');
expect((await memberRows(item.id)).map((m) => m.amount)).toEqual([null, null]);
});
});
it('refuses members alongside member_ids', async () => {
const { user, other, trip } = tripWithTwo();
const item = await itemWithEqualSplit(trip.id, [user.id, other.id]);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id,
itemId: item.id,
member_ids: [user.id],
members: [{ user_id: user.id, amount: 100 }],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not both');
expect(await memberRows(item.id)).toHaveLength(2);
});
});
it('reports a missing item before it looks at the split', async () => {
const { user, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: 99999, members: [{ user_id: user.id, amount: 10 }] },
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('Budget item not found.');
});
});
});
// ---------------------------------------------------------------------------
// Currency and date on an existing expense
// ---------------------------------------------------------------------------
describe('Tool: update_budget_item (currency)', () => {
it('changes the currency and freezes the rate at entry time', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
stubRates({ USD: 1.1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, currency: 'USD' },
});
expect(result.isError).toBeFalsy();
const row = await itemRow(item.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
});
});
it('clears the currency back to the trip currency', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
await updateRows(orm, BudgetItems, { id: item.id }, { currency: 'USD', exchange_rate: 1.1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, currency: null },
});
expect(result.isError).toBeFalsy();
expect((await itemRow(item.id)).currency).toBeNull();
});
});
it('records and edits a note on the payment (#2340)', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const created = parseToolResult(
await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20, note: 'Cash' },
}),
) as any;
expect(created.settlement.note).toBe('Cash');
const updated = parseToolResult(
await h.client.callTool({
name: 'update_settlement',
arguments: {
tripId: trip.id,
settlementId: created.settlement.id,
from_user_id: other.id,
to_user_id: user.id,
amount: 20,
note: 'Bank transfer',
},
}),
) as any;
expect(updated.settlement.note).toBe('Bank transfer');
});
});
it('refuses a currency that is not a code', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, currency: 'United States Dollars' },
});
expect(result.isError).toBe(true);
expect((await itemRow(item.id)).currency).toBeNull();
});
});
});
describe('Tool: update_budget_item (expense date)', () => {
it('moves the expense to another date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, expense_date: '2026-09-14' },
});
expect(result.isError).toBeFalsy();
expect((await itemRow(item.id)).expense_date).toBe('2026-09-14');
});
});
it('clears the expense date', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await updateRows(orm, BudgetItems, { id: item.id }, { expense_date: '2026-09-14' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, expense_date: null },
});
expect(result.isError).toBeFalsy();
expect((await itemRow(item.id)).expense_date).toBeNull();
});
});
it('refuses a date that is not a string', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await updateRows(orm, BudgetItems, { id: item.id }, { expense_date: '2026-09-14' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, expense_date: 20260915 },
});
expect(result.isError).toBe(true);
expect((await itemRow(item.id)).expense_date).toBe('2026-09-14');
});
});
});
// ---------------------------------------------------------------------------
// Expenses linked to a place (#1298), the place-side twin of reservation_id
// ---------------------------------------------------------------------------
describe('Budget tools: place link', () => {
it('create_budget_item links the expense to a place on the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Louvre tickets', total_price: 34, place_id: place.id },
});
const data = parseToolResult(result) as any;
expect((await itemRow(data.item.id)).place_id).toBe(place.id);
});
});
it('create_budget_item refuses a place from another trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const elsewhere = createTrip(testDb, user.id);
const place = createPlace(testDb, elsewhere.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Tickets', total_price: 34, place_id: place.id },
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('place_id does not belong to this trip.');
expect(await itemCount(trip.id)).toBe(0);
});
});
it('create_budget_item_with_members links the expense to a place on the trip', async () => {
const { user, other, trip } = tripWithTwo();
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: {
tripId: trip.id,
name: 'Louvre tickets',
total_price: 68,
userIds: [user.id, other.id],
place_id: place.id,
},
});
const data = parseToolResult(result) as any;
expect((await itemRow(data.item.id)).place_id).toBe(place.id);
});
});
it('create_budget_item_with_members refuses a place from another trip', async () => {
const { user, trip } = tripWithTwo();
const elsewhere = createTrip(testDb, user.id);
const place = createPlace(testDb, elsewhere.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item_with_members',
arguments: { tripId: trip.id, name: 'Tickets', total_price: 68, place_id: place.id },
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('place_id does not belong to this trip.');
expect(await itemCount(trip.id)).toBe(0);
});
});
});
// ---------------------------------------------------------------------------
// Linking an existing expense to a booking or a place (#2084), the MCP twin of
// PUT /budget/:id with reservation_id / place_id
// ---------------------------------------------------------------------------
describe('Tool: update_budget_item (links)', () => {
async function links(itemId: number) {
const row = await itemRow(itemId);
return { reservation_id: row.reservation_id ?? null, place_id: row.place_id ?? null };
}
async function priceOf(reservationId: number): Promise<Record<string, unknown>> {
const row = await findRow(orm, Reservations, { id: reservationId });
if (!row) throw new Error(`no reservation ${reservationId}`);
return row.metadata ? JSON.parse(row.metadata) : {};
}
function update(h: McpHarness, args: Record<string, unknown>) {
return h.client.callTool({ name: 'update_budget_item', arguments: args });
}
it('links two expenses to one booking, which then mirrors their sum', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const booking = createReservation(testDb, trip.id, { title: 'Flight' });
const fare = createBudgetItem(testDb, trip.id, { name: 'Fare', total_price: 180 });
const luggage = createBudgetItem(testDb, trip.id, { name: 'Luggage', total_price: 35.5 });
await withHarness(user.id, async (h) => {
const result = await update(h, { tripId: trip.id, itemId: fare.id, reservation_id: booking.id });
expect((parseToolResult(result) as any).item.reservation_id).toBe(booking.id);
expect(await priceOf(booking.id)).toEqual({ price: '180' });
await update(h, { tripId: trip.id, itemId: luggage.id, reservation_id: booking.id });
expect((await links(luggage.id)).reservation_id).toBe(booking.id);
expect(await priceOf(booking.id)).toEqual({ price: '215.5' });
// The booking card updates in every open session, not only the expense row.
expect(broadcastMock).toHaveBeenCalledWith(
String(trip.id),
'reservation:updated',
expect.objectContaining({ reservation: expect.objectContaining({ id: booking.id }) }),
undefined,
);
expect(broadcastMock).toHaveBeenCalledWith(
trip.id,
'budget:updated',
expect.objectContaining({ item: expect.objectContaining({ id: luggage.id }) }),
);
});
});
it('refuses a booking from another trip, in the words the REST route uses, and writes nothing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const elsewhere = createTrip(testDb, user.id);
const foreign = createReservation(testDb, elsewhere.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Fare' });
await withHarness(user.id, async (h) => {
const result = await update(h, { tripId: trip.id, itemId: item.id, reservation_id: foreign.id, name: 'Renamed' });
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('reservation_id does not belong to this trip.');
});
expect(await links(item.id)).toEqual({ reservation_id: null, place_id: null });
expect((await itemRow(item.id)).name).toBe('Fare');
expect(await priceOf(foreign.id)).toEqual({});
expect(broadcastMock).not.toHaveBeenCalled();
});
it('refuses a place from another trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const elsewhere = createTrip(testDb, user.id);
const foreign = createPlace(testDb, elsewhere.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await update(h, { tripId: trip.id, itemId: item.id, place_id: foreign.id });
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('place_id does not belong to this trip.');
});
expect((await links(item.id)).place_id).toBeNull();
});
it('links an expense to a place, and null lets go of it while the expense stays', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
const item = createBudgetItem(testDb, trip.id, { name: 'Tickets', total_price: 34 });
await withHarness(user.id, async (h) => {
await update(h, { tripId: trip.id, itemId: item.id, place_id: place.id });
expect((await links(item.id)).place_id).toBe(place.id);
// An edit that leaves the link out keeps it.
await update(h, { tripId: trip.id, itemId: item.id, name: 'Museum tickets' });
expect((await links(item.id)).place_id).toBe(place.id);
await update(h, { tripId: trip.id, itemId: item.id, place_id: null });
});
expect((await links(item.id)).place_id).toBeNull();
expect((await itemRow(item.id)).total_price).toBe(34);
});
it('null lets go of the booking, keeps the expense and takes its share off the booking price', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const booking = createReservation(testDb, trip.id);
const fare = createBudgetItem(testDb, trip.id, { total_price: 100 });
const extra = createBudgetItem(testDb, trip.id, { total_price: 20 });
await withHarness(user.id, async (h) => {
await update(h, { tripId: trip.id, itemId: fare.id, reservation_id: booking.id });
await update(h, { tripId: trip.id, itemId: extra.id, reservation_id: booking.id });
expect(await priceOf(booking.id)).toEqual({ price: '120' });
await update(h, { tripId: trip.id, itemId: extra.id, reservation_id: null });
expect(await priceOf(booking.id)).toEqual({ price: '100' });
await update(h, { tripId: trip.id, itemId: fare.id, reservation_id: null });
expect(await priceOf(booking.id)).toEqual({});
});
expect(await itemCount(trip.id)).toBe(2);
});
it('moving an expense to another booking resyncs both', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const outbound = createReservation(testDb, trip.id, { title: 'Out' });
const inbound = createReservation(testDb, trip.id, { title: 'Back' });
const item = createBudgetItem(testDb, trip.id, { total_price: 75 });
await withHarness(user.id, async (h) => {
await update(h, { tripId: trip.id, itemId: item.id, reservation_id: outbound.id });
await update(h, { tripId: trip.id, itemId: item.id, reservation_id: inbound.id });
});
expect(await priceOf(outbound.id)).toEqual({});
expect(await priceOf(inbound.id)).toEqual({ price: '75' });
});
it('a new total on a linked expense reaches the booking price', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const booking = createReservation(testDb, trip.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 50 });
await updateRows(orm, BudgetItems, { id: item.id }, { reservation: booking.id });
await withHarness(user.id, async (h) => {
await update(h, { tripId: trip.id, itemId: item.id, total_price: 64.2 });
});
expect(await priceOf(booking.id)).toEqual({ price: '64.2' });
});
it('a currency change alone on a linked expense re-names the booking price', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const booking = createReservation(testDb, trip.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 50 });
await updateRows(orm, BudgetItems, { id: item.id }, { reservation: booking.id });
stubRates({ USD: 1.1 });
await withHarness(user.id, async (h) => {
await update(h, { tripId: trip.id, itemId: item.id, currency: 'USD' });
expect(await priceOf(booking.id)).toEqual({ price: '50', priceCurrency: 'USD' });
await update(h, { tripId: trip.id, itemId: item.id, currency: null });
expect(await priceOf(booking.id)).toEqual({ price: '50' });
});
});
});
// ---------------------------------------------------------------------------
// Settle-up payments made in another currency (the #1445 freeze)
// ---------------------------------------------------------------------------
describe('Tool: create_settlement (currency)', () => {
it('records the payment in the currency it was made in and freezes its rate', async () => {
const { user, other, trip } = tripWithTwo();
stubRates({ USD: 1.1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20, currency: 'USD' },
});
const data = parseToolResult(result) as any;
const row = await settlementRow(data.settlement.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
expect(row.amount).toBe(20);
});
});
it('books the payment in the trip currency when none is given', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20 },
});
const data = parseToolResult(result) as any;
const row = await settlementRow(data.settlement.id);
expect(row.currency).toBeNull();
expect(row.exchange_rate).toBe(1);
});
});
it('refuses a currency that is not a code', async () => {
const { user, other, trip } = tripWithTwo();
const before = await settlementCount(trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_settlement',
arguments: {
tripId: trip.id,
from_user_id: other.id,
to_user_id: user.id,
amount: 20,
currency: 'United States Dollars',
},
});
expect(result.isError).toBe(true);
expect(await settlementCount(trip.id)).toBe(before);
});
});
});
describe('Tool: update_settlement (currency)', () => {
it('changes the currency the payment was made in and freezes its rate', async () => {
const { user, other, trip } = tripWithTwo();
await withHarness(user.id, async (h) => {
const created = parseToolResult(
await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20 },
}),
) as any;
stubRates({ USD: 1.1 });
const result = await h.client.callTool({
name: 'update_settlement',
arguments: {
tripId: trip.id,
settlementId: created.settlement.id,
from_user_id: other.id,
to_user_id: user.id,
amount: 20,
currency: 'USD',
},
});
expect(result.isError).toBeFalsy();
const row = await settlementRow(created.settlement.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
});
});
it('clears the currency back to the trip currency', async () => {
const { user, other, trip } = tripWithTwo();
stubRates({ USD: 1.1 });
await withHarness(user.id, async (h) => {
const created = parseToolResult(
await h.client.callTool({
name: 'create_settlement',
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20, currency: 'USD' },
}),
) as any;
const result = await h.client.callTool({
name: 'update_settlement',
arguments: {
tripId: trip.id,
settlementId: created.settlement.id,
from_user_id: other.id,
to_user_id: user.id,
amount: 20,
currency: null,
},
});
expect(result.isError).toBeFalsy();
expect((await settlementRow(created.settlement.id)).currency).toBeNull();
});
});
});
// ---------------------------------------------------------------------------
// The split has to reconcile against the total that is actually stored
//
// writeItemPayers drops a payer who is not on the trip and then derives
// total_price from what survived, so certifying a split against the payers as
// GIVEN would pass a check the stored row fails. An empty payer list is the
// same trap from the other side: it means "no payers", so the derived total is
// zero, not the previous one.
// ---------------------------------------------------------------------------
describe('Budget tools: a custom split cannot be certified against a total the row will not get', () => {
it('refuses a payer who is not on the trip rather than storing an unbalanced split', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Dinner',
total_price: 100,
members: [{ user_id: owner.id, amount: 100 }],
payers: [
{ user_id: stranger.id, amount: 50 },
{ user_id: owner.id, amount: 50 },
],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not on this trip');
expect(await itemCount(trip.id)).toBe(0);
});
});
it('refuses an empty payer list under a non-zero split, because the stored total becomes zero', async () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, friend.id);
await withHarness(owner.id, async (h) => {
const created = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Taxi', total_price: 100, member_ids: [owner.id, friend.id] },
});
const { item } = parseToolResult(created) as { item: { id: number } };
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: {
tripId: trip.id,
itemId: item.id,
payers: [],
members: [
{ user_id: owner.id, amount: 60 },
{ user_id: friend.id, amount: 40 },
],
},
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('does not add up');
// The item is untouched: the refusal happens before the write.
const row = await itemRow(item.id);
expect(row.total_price).toBe(100);
});
});
it('accepts an empty payer list when the split is empty too', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const created = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Placeholder', total_price: 50, member_ids: [] },
});
const { item } = parseToolResult(created) as { item: { id: number } };
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, payers: [], members: [] },
});
expect(result.isError).toBeFalsy();
const row = await itemRow(item.id);
expect(row.total_price).toBe(0);
});
});
it('still certifies a split against the payer sum when every payer is on the trip', async () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, friend.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: {
tripId: trip.id,
name: 'Hotel',
total_price: 999,
payers: [
{ user_id: owner.id, amount: 70 },
{ user_id: friend.id, amount: 30 },
],
members: [
{ user_id: owner.id, amount: 60 },
{ user_id: friend.id, amount: 40 },
],
},
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
// total_price came from the payers, not from the stated 999.
const row = await itemRow(data.item.id);
expect(row.total_price).toBe(100);
const shares = await memberRows(data.item.id);
expect(shares.map((s) => s.amount)).toEqual([60, 40]);
});
});
});
// ---------------------------------------------------------------------------
// Foreign rows with no exchange rate (the VND/AUD report)
//
// The rate cache is module-scoped and outlives a case, so each case below uses a
// trip currency no other case in this file asks the rates for: AUD is quoted by
// its stub, NZD is left to the failing fetch every case starts with.
// ---------------------------------------------------------------------------
/** A trip in `currency` with one unfrozen VND bill and one unfrozen VND transfer. */
async function tripWithVndRows(currency: string) {
const { user, other, trip } = tripWithTwo();
await updateRows(orm, Trips, { id: trip.id }, { currency });
await deleteRows(orm, BudgetSettlements, { trip: trip.id });
const item = createBudgetItem(testDb, trip.id, { name: 'Pho', total_price: 8920000 });
await updateRows(orm, BudgetItems, { id: item.id }, { currency: 'VND', exchange_rate: 1 });
await addBudgetItemMember(orm, item.id, user.id, { paid: 0 });
await addBudgetItemMember(orm, item.id, other.id, { paid: 0 });
await addBudgetItemPayer(orm, item.id, user.id, 8920000);
const settlementId = await insertRow(orm, BudgetSettlements, {
trip: trip.id,
fromUser: other.id,
toUser: user.id,
amount: 100000,
currency: 'VND',
exchange_rate: 1,
});
return { user, other, trip, item, settlementId };
}
describe('Tool: freeze_budget_rates', () => {
it('refuses a demo user, a stranger and a member without budget_edit, writing nothing', async () => {
const { other, trip, item, settlementId } = await tripWithVndRows('AUD');
const { user: stranger } = createUser(testDb);
const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates');
try {
await withHarness(stranger.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
// budget_edit lowered to the owner, the way the admin permission panel does it.
await setAppSetting(orm, 'perm_budget_edit', 'trip_owner');
await invalidatePermissionsCache();
await withHarness(other.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' });
await invalidatePermissionsCache();
process.env.DEMO_MODE = 'true';
const { user: demo } = createUser(testDb, { email: 'demo@nomad.app' });
addTripMember(testDb, trip.id, demo.id);
await withHarness(demo.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
expect(spy).not.toHaveBeenCalled();
expect((await itemRow(item.id)).exchange_rate).toBe(1);
expect((await settlementRow(settlementId)).exchange_rate).toBe(1);
} finally {
spy.mockRestore();
await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' });
await invalidatePermissionsCache();
}
});
it('calls freezeMissingRates without a quote of its own, freezes the server rate and broadcasts', async () => {
const { user, trip, item, settlementId } = await tripWithVndRows('AUD');
stubRates({ VND: 18241.3 });
const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates');
try {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBeFalsy();
// No rate table travels with the MCP call: only the server's own rate is frozen.
expect(spy.mock.calls).toEqual([[trip.id]]);
const data = parseToolResult(result) as {
items: { id: number }[];
settlements: { id: number }[];
unresolved: string[];
};
expect(data.items.map((i) => i.id)).toEqual([item.id]);
expect(data.settlements.map((s) => s.id)).toEqual([settlementId]);
expect(data.unresolved).toEqual([]);
expect((await itemRow(item.id)).exchange_rate).toBe(18241.3);
expect((await settlementRow(settlementId)).exchange_rate).toBe(18241.3);
expect(broadcastMock).toHaveBeenCalledWith(
trip.id,
'budget:updated',
expect.objectContaining({ item: expect.objectContaining({ id: item.id }) }),
);
expect(broadcastMock).toHaveBeenCalledWith(
trip.id,
'budget:settlement-updated',
expect.objectContaining({ settlement: expect.objectContaining({ id: settlementId }) }),
);
// Everything is frozen now, so a second call finds nothing to heal.
const again = parseToolResult(
await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } }),
);
expect(again).toEqual({ items: [], settlements: [], unresolved: [] });
});
} finally {
spy.mockRestore();
}
});
it('answers with an error and broadcasts nothing when the trip currency changed during the fetch', async () => {
const { user, trip, item, settlementId } = await tripWithVndRows('AUD');
// The service's own answer for that race (BUDGET-SVC-DB-066): null, nothing written.
const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates').mockResolvedValueOnce(null);
try {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'freeze_budget_rates', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('The trip currency changed');
});
expect(spy).toHaveBeenCalledTimes(1);
expect(broadcastMock).not.toHaveBeenCalled();
expect((await itemRow(item.id)).exchange_rate).toBe(1);
expect((await settlementRow(settlementId)).exchange_rate).toBe(1);
} finally {
spy.mockRestore();
}
});
});
describe('Tool: get_settlement_summary (unconverted rows)', () => {
it('returns the currency it answers in and the rows no rate could convert', async () => {
const { user, trip, item, settlementId } = await tripWithVndRows('NZD');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } });
const { summary } = parseToolResult(result) as {
summary: {
currency: string;
balances: unknown[];
flows: unknown[];
unconverted: { item_ids: number[]; settlement_ids: number[]; currencies: string[] };
};
};
expect(summary.currency).toBe('NZD');
expect(summary.unconverted).toEqual({ item_ids: [item.id], settlement_ids: [settlementId], currencies: ['VND'] });
// Left out whole: no balance in VND's order of magnitude, and nothing to settle.
expect(summary.balances).toEqual([]);
expect(summary.flows).toEqual([]);
});
});
});