mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
44a12e21fc
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked costs are labelled in the trip currency (every linked row, the link picker and the phone's linked costs), a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch.
484 lines
22 KiB
TypeScript
484 lines
22 KiB
TypeScript
/**
|
|
* Unit tests for MCP budget advanced tools:
|
|
* set_budget_item_members, toggle_budget_member_paid.
|
|
* Resources: trek://trips/{tripId}/budget/per-person, trek://trips/{tripId}/budget/settlement.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: (tripId: any, userId: number) =>
|
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
|
isOwner: (tripId: any, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
|
|
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { BudgetController } from '../../../src/nest/budget/budget.controller';
|
|
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import type { TripAccess } from '../../../src/nest/database/database.service';
|
|
import type { User } from '../../../src/types';
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
delete process.env.DEMO_MODE;
|
|
// get_settlement_summary calls getRates(), which is a real fetch to
|
|
// api.frankfurter.dev with a 10 s abort. One case used to stub this inline;
|
|
// hoisted so a second settlement case cannot quietly reintroduce the call.
|
|
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.unstubAllGlobals();
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
const h = await createMcpHarness({ userId, withResources: false });
|
|
try { await fn(h); } finally { await h.cleanup(); }
|
|
}
|
|
|
|
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
const h = await createMcpHarness({ userId, withResources: true });
|
|
try { await fn(h); } finally { await h.cleanup(); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// set_budget_item_members
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: set_budget_item_members', () => {
|
|
it('sets members and returns a hydrated item with members/payers', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_budget_item_members',
|
|
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
// Regression: returns a hydrated item, not the raw row from updateMembers.
|
|
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
|
|
expect(Array.isArray(data.item.payers)).toBe(true);
|
|
// Quirk fix: the broadcast carries the REST payload shape (the legacy
|
|
// MCP-specific { item } shape was a silent no-op in the client handler).
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.objectContaining({
|
|
itemId: item.id,
|
|
members: expect.arrayContaining([expect.objectContaining({ user_id: user.id })]),
|
|
persons: 1,
|
|
}));
|
|
});
|
|
});
|
|
|
|
it('returns an error for an item not in the 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: 'set_budget_item_members',
|
|
arguments: { tripId: trip.id, itemId: 99999, userIds: [user.id] },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('empty array clears members', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createBudgetItem(testDb, trip.id);
|
|
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id) VALUES (?, ?)').run(item.id, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_budget_item_members',
|
|
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.item).toBeDefined();
|
|
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?').get(item.id) as any;
|
|
expect(remaining.cnt).toBe(0);
|
|
});
|
|
});
|
|
|
|
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: 'set_budget_item_members',
|
|
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
|
});
|
|
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);
|
|
const item = createBudgetItem(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_budget_item_members',
|
|
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// create_budget_item_with_members
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: create_budget_item_with_members', () => {
|
|
it('assigns the given members and returns a hydrated item', 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_with_members',
|
|
arguments: { tripId: trip.id, name: 'Villa', total_price: 800, userIds: [user.id, member.id] },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.item.members.map((m: any) => m.user_id).sort()).toEqual([user.id, member.id].sort());
|
|
expect(data.item.persons).toBe(2);
|
|
});
|
|
});
|
|
|
|
// Regression: omitting userIds previously produced an empty-member (unsaveable) entity.
|
|
it('defaults to all trip members when userIds omitted', 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_with_members',
|
|
arguments: { tripId: trip.id, name: 'Shared cab', total_price: 50 },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.item.members.map((m: any) => m.user_id).sort()).toEqual([user.id, member.id].sort());
|
|
expect(data.item.members.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
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_with_members',
|
|
arguments: { tripId: trip.id, name: 'X', total_price: 1 },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// toggle_budget_member_paid
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: toggle_budget_member_paid', () => {
|
|
it('flips paid flag and broadcasts', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = createBudgetItem(testDb, trip.id, { total_price: 200 });
|
|
// Add member first
|
|
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)').run(item.id, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'toggle_budget_member_paid',
|
|
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.member).toBeDefined();
|
|
// Quirk fix: the broadcast carries the REST payload shape (the legacy
|
|
// MCP-specific { itemId, member } shape was a silent no-op in the client).
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:member-paid-updated', expect.objectContaining({ itemId: item.id, userId: user.id, paid: 1 }));
|
|
});
|
|
});
|
|
|
|
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: 'toggle_budget_member_paid',
|
|
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settlements (settle-up payments)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Settlement tools', () => {
|
|
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 };
|
|
}
|
|
|
|
it('create_settlement records a payment, broadcasts, and is listed', async () => {
|
|
const { user, other, trip } = tripWithTwo();
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_settlement',
|
|
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 42.5 },
|
|
});
|
|
const cData = parseToolResult(created) as any;
|
|
expect(cData.settlement.from_user_id).toBe(other.id);
|
|
expect(cData.settlement.to_user_id).toBe(user.id);
|
|
expect(cData.settlement.amount).toBe(42.5);
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-created', expect.any(Object));
|
|
|
|
const listed = await h.client.callTool({ name: 'list_settlements', arguments: { tripId: trip.id } });
|
|
const lData = parseToolResult(listed) as any;
|
|
expect(lData.settlements).toHaveLength(1);
|
|
expect(lData.settlements[0].id).toBe(cData.settlement.id);
|
|
});
|
|
});
|
|
|
|
it('update_settlement changes the amount; delete_settlement removes it', 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: 10 },
|
|
})) as any;
|
|
const id = created.settlement.id;
|
|
|
|
const updated = parseToolResult(await h.client.callTool({
|
|
name: 'update_settlement',
|
|
arguments: { tripId: trip.id, settlementId: id, from_user_id: other.id, to_user_id: user.id, amount: 25 },
|
|
})) as any;
|
|
expect(updated.settlement.amount).toBe(25);
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-updated', expect.any(Object));
|
|
|
|
const deleted = parseToolResult(await h.client.callTool({
|
|
name: 'delete_settlement',
|
|
arguments: { tripId: trip.id, settlementId: id },
|
|
})) as any;
|
|
expect(deleted.success).toBe(true);
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-deleted', expect.any(Object));
|
|
|
|
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_settlements WHERE trip_id = ?').get(trip.id) as any;
|
|
expect(remaining.cnt).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('update_settlement returns an error when the settlement is missing', async () => {
|
|
const { user, other, trip } = tripWithTwo();
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_settlement',
|
|
arguments: { tripId: trip.id, settlementId: 99999, from_user_id: other.id, to_user_id: user.id, amount: 5 },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('create_settlement is denied for a 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_settlement',
|
|
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: other.id, amount: 5 },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('get_settlement_summary agrees with GET /budget/settlement, unpaid expense included (#2225)', async () => {
|
|
// The two surfaces resolve the trip currency and the rates separately before
|
|
// calling calculateSettlement, so the parity that matters is on the payload.
|
|
// The fixture carries the row the fix is about: a total nobody has paid.
|
|
const { user, other, trip } = tripWithTwo();
|
|
const paid = createBudgetItem(testDb, trip.id, { total_price: 100 });
|
|
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
|
|
.run(paid.id, user.id, paid.id, other.id);
|
|
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
|
|
.run(paid.id, user.id, 100);
|
|
const unpaid = createBudgetItem(testDb, trip.id, { total_price: 40 });
|
|
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)')
|
|
.run(unpaid.id, other.id);
|
|
|
|
const dbService = new DatabaseService(testDb);
|
|
const controller = new BudgetController(
|
|
new BudgetService(dbService, new PermissionsService(dbService), new ExchangeRatesService(), new RealtimeService()),
|
|
);
|
|
const rest = await controller.settlement(
|
|
{ id: user.id } as User,
|
|
{ id: trip.id, user_id: user.id } as TripAccess,
|
|
String(trip.id),
|
|
);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } });
|
|
const data = parseToolResult(result) as { summary: typeof rest };
|
|
expect(data.summary.balances).toEqual(rest.balances);
|
|
expect(data.summary.flows).toEqual(rest.flows);
|
|
// Only the paid 100 settles; the 40 nobody paid is outstanding, not owed.
|
|
expect(rest.balances.map(b => b.balance)).toEqual([50, -50]);
|
|
});
|
|
});
|
|
|
|
it('get_settlement_summary reads a same-day bill in the viewer\'s currency to the cent, like REST (#2525)', async () => {
|
|
// Frankfurter's two quotes for the pair, each rounded, so not exact inverses. The entry
|
|
// rate is frozen from the euro's, and converting back with the dollar's read the
|
|
// 12,345.67 dollars as 12,346.06.
|
|
const quotes: Record<string, Record<string, number>> = { EUR: { EUR: 1, USD: 1.1398 }, USD: { USD: 1, EUR: 0.87732 } };
|
|
const spy = vi.spyOn(ExchangeRatesService.prototype, 'getRates').mockImplementation(async (b: string) => quotes[b] ?? null);
|
|
try {
|
|
const { user, other, trip } = tripWithTwo();
|
|
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
|
|
const item = createBudgetItem(testDb, trip.id, { total_price: 12345.67 });
|
|
testDb.prepare("UPDATE budget_items SET currency = 'USD', exchange_rate = 1.1398 WHERE id = ?").run(item.id);
|
|
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
|
|
.run(item.id, user.id, item.id, other.id);
|
|
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
|
|
.run(item.id, user.id, 12345.67);
|
|
|
|
const dbService = new DatabaseService(testDb);
|
|
const controller = new BudgetController(
|
|
new BudgetService(dbService, new PermissionsService(dbService), new ExchangeRatesService(), new RealtimeService()),
|
|
);
|
|
const rest = await controller.settlement(
|
|
{ id: user.id } as User,
|
|
{ id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess,
|
|
String(trip.id),
|
|
'USD',
|
|
);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } });
|
|
const data = parseToolResult(result) as { summary: typeof rest };
|
|
expect(data.summary).toEqual(rest);
|
|
const mine = data.summary.finalBudgets.find(f => f.user_id === user.id)!;
|
|
expect(mine.expenses).toBe(12345.67);
|
|
expect(mine.sources.fronted).toEqual([{ item_id: item.id, cents: 1234567 }]);
|
|
expect(data.summary.flows.map(f => f.amount)).toEqual([6172.84]);
|
|
});
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('get_settlement_summary returns balances and flows', async () => {
|
|
try {
|
|
const { user, other, trip } = tripWithTwo();
|
|
// user paid 100 for an item split between both → other owes user 50.
|
|
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
|
|
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
|
|
.run(item.id, user.id, item.id, other.id);
|
|
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
|
|
.run(item.id, user.id, 100);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } });
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.summary).toBeDefined();
|
|
expect(Array.isArray(data.summary.balances)).toBe(true);
|
|
expect(Array.isArray(data.summary.flows)).toBe(true);
|
|
});
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-person resource
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Resource: trek://trips/{tripId}/budget/per-person', () => {
|
|
it('returns array for trip with no items', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withResourceHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
|
|
const data = parseResourceResult(result);
|
|
expect(Array.isArray(data)).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);
|
|
await withResourceHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
|
|
const data = parseResourceResult(result) as { error?: string };
|
|
expect(data.error).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settlement resource
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Resource: trek://trips/{tripId}/budget/settlement', () => {
|
|
it('returns settlement object for trip with no items', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withResourceHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/settlement` });
|
|
// The resource may answer with a summary object or a bare array, and the
|
|
// assertion below accepts either, so keep the balances field optional.
|
|
const data = parseResourceResult(result) as { balances?: unknown };
|
|
expect(data).toBeDefined();
|
|
expect(Array.isArray(data.balances) || Array.isArray(data)).toBe(true);
|
|
});
|
|
});
|
|
});
|