test(server): seed the collab, budget, MCP and OAuth token specs through the factories

The collab and budget e2e suites, the MCP integration suite, the budget
MCP tools and the OAuth token repository suite seed and read through the
ORM factories. The collab e2e suite moves off its hand-rolled CREATE
TABLE set onto the migrated snapshot, the way the factories guide asks of
a suite that converts: an entity reads and writes every mapped column,
which a trimmed table does not have. Its users are seeded with the ids
the session cookies are signed for.

The recursive chain statement in the token suite stays raw, marked as the
legacy oracle collectChainIds is held to.
This commit is contained in:
Maurice
2026-10-08 16:26:26 +02:00
parent 2cf853e504
commit a3781ed27b
5 changed files with 506 additions and 477 deletions
+91 -75
View File
@@ -15,19 +15,11 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { Test } from '@nestjs/testing';
import { sessionCookie } from './harness';
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../helpers/db-mock');
const db = createSnapshotTestDb();
return {
db,
closeDb: () => {},
reinitialize: () => {},
canAccessTrip,
getPlaceWithTags: () => null,
isOwner: () => false,
};
// Trip access reads through TripsRepository now; the module only hands out the handle.
return { db, closeDb: () => {}, reinitialize: () => {} };
});
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
@@ -45,7 +37,32 @@ import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.servi
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows';
import { makeUser } from '../helpers/factories/users';
import { addTripMember, makeTrip } from '../helpers/factories/trips';
import { makePlace } from '../helpers/factories/places';
import { makeReservation } from '../helpers/factories/reservations';
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';
let orm: TestOrm;
/** The stored expense; fails the case when it is gone. */
async function budgetRow(id: number) {
const row = await findRow(orm, BudgetItems, { id });
if (!row) throw new Error(`no budget item ${id}`);
return row;
}
/** 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;
}
describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
let server: Server;
@@ -71,18 +88,15 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
}
beforeAll(async () => {
// The temp db carries the real schema (password_hash NOT NULL), so seed the
// auth users directly instead of via the trimmed-DDL seedUser helper.
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
).run();
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (2, 'e2e-peer', 'peer@example.test', 'x', 'user', 0)",
).run();
tripId = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'E2E Trip', 'EUR')").run().lastInsertRowid);
// The temp db carries the real schema, so the auth users go in through the
// factories, pinned to the ids the session cookies are signed for.
orm = await createTestOrm(db);
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'e2e-peer', email: 'peer@example.test', role: 'user', password_version: 0 });
tripId = (await makeTrip(orm, 1, { title: 'E2E Trip', currency: 'EUR' })).id;
// The peer settles up with the owner below, so they have to be on the trip:
// a settlement between people who do not share one is refused.
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, 2)').run(tripId);
await addTripMember(orm, tripId, 2);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
@@ -99,6 +113,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
afterAll(async () => {
await app.close();
await orm.close();
});
it('401 without a session cookie', async () => {
@@ -112,14 +127,14 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
// longer intercepts it. The trip row is seeded once in `beforeAll` (not
// re-seeded per test), so it is removed and restored around this one
// assertion instead.
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
await deleteRows(orm, Trips, { id: tripId });
try {
const res = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
} finally {
db.prepare("INSERT INTO trips (id, user_id, title, currency) VALUES (?, 1, 'E2E Trip', 'EUR')").run(tripId);
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, 2)').run(tripId);
await insertRow(orm, Trips, { id: tripId, user: 1, title: 'E2E Trip', currency: 'EUR' });
await addTripMember(orm, tripId, 2);
}
});
@@ -131,8 +146,8 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
expect(created.status).toBe(201);
expect(created.body.item).toMatchObject({ name: 'Hotel', total_price: 200, category: 'other', members: [], payers: [] });
const row = db.prepare('SELECT name, total_price FROM budget_items WHERE id = ?').get(created.body.item.id);
expect(row).toEqual({ name: 'Hotel', total_price: 200 });
const row = await budgetRow(created.body.item.id);
expect({ name: row.name, total_price: row.total_price }).toEqual({ name: 'Hotel', total_price: 200 });
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
expect(list.status).toBe(200);
@@ -184,8 +199,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
expect(refund.status).toBe(201);
expect(refund.body.item).toMatchObject({ total_price: -30 });
expect(refund.body.item.payers).toEqual([expect.objectContaining({ user_id: 1, amount: -30 })]);
const row = db.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(refund.body.item.id) as { total_price: number };
expect(row.total_price).toBe(-30);
expect((await budgetRow(refund.body.item.id)).total_price).toBe(-30);
const settlement = await request(server)
.get(`/api/trips/${tripId}/budget/settlement`)
@@ -279,8 +293,8 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
expect(res.status).toBe(200);
expect(res.body.settlement).toMatchObject({ id: created.body.settlement.id, from_user_id: 2, to_user_id: 1, amount: 15, settled_at: '2026-01-09' });
const row = db.prepare('SELECT amount, settled_at FROM budget_settlements WHERE id = ?').get(created.body.settlement.id);
expect(row).toEqual({ amount: 15, settled_at: '2026-01-09' });
const row = await settlementRow(created.body.settlement.id);
expect({ amount: row.amount, settled_at: row.settled_at }).toEqual({ amount: 15, settled_at: '2026-01-09' });
});
it('200 on settlement update that clears the day, leaving NULL in the column', async () => {
@@ -297,8 +311,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
expect(res.status).toBe(200);
expect(res.body.settlement.settled_at).toBeNull();
const row = db.prepare('SELECT settled_at FROM budget_settlements WHERE id = ?').get(created.body.settlement.id);
expect(row).toEqual({ settled_at: null });
expect((await settlementRow(created.body.settlement.id)).settled_at).toBeNull();
});
it('keeps a note on a settlement, leaves it alone when omitted and clears it on blank (#2340)', async () => {
@@ -359,24 +372,26 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
let luggageId: number;
let seatId: number;
const metadataOf = (id: number) => {
const row = db.prepare('SELECT metadata FROM reservations WHERE id = ?').get(id) as { metadata: string | null };
const metadataOf = async (id: number) => {
const row = await findRow(orm, Reservations, { id });
if (!row) throw new Error(`no reservation ${id}`);
return row.metadata ? JSON.parse(row.metadata) : null;
};
const linkOf = (id: number) =>
db.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(id) as { reservation_id: number | null } | undefined;
/** The expense's booking link, or undefined once the expense is gone. */
const linkOf = async (id: number) => {
const row = await findRow(orm, BudgetItems, { id });
return row ? { reservation_id: row.reservation_id ?? null } : undefined;
};
const put = (id: number, body: Record<string, unknown>) =>
request(server).put(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1)).send(body);
const create = (body: Record<string, unknown>) =>
request(server).post(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1)).send(body);
beforeAll(async () => {
bookingId = Number(db.prepare(
"INSERT INTO reservations (trip_id, title, type, metadata) VALUES (?, 'Flight', 'flight', ?)",
).run(tripId, JSON.stringify({ seat: '12A' })).lastInsertRowid);
const otherTripId = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'Other Trip', 'EUR')").run().lastInsertRowid);
foreignBookingId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Elsewhere', 'flight')").run(otherTripId).lastInsertRowid);
foreignPlaceId = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'Elsewhere')").run(otherTripId).lastInsertRowid);
bookingId = (await makeReservation(orm, tripId, { title: 'Flight', type: 'flight', metadata: JSON.stringify({ seat: '12A' }) })).id;
const otherTripId = (await makeTrip(orm, 1, { title: 'Other Trip', currency: 'EUR' })).id;
foreignBookingId = (await makeReservation(orm, otherTripId, { title: 'Elsewhere', type: 'flight' })).id;
foreignPlaceId = (await makePlace(orm, otherTripId, { name: 'Elsewhere', lat: null, lng: null, category: null })).id;
});
it('200 on linking two expenses to one booking, which then mirrors their sum', async () => {
@@ -390,11 +405,11 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
const first = await put(fareId, { reservation_id: bookingId });
expect(first.status).toBe(200);
expect(first.body.item).toMatchObject({ id: fareId, reservation_id: bookingId, total_price: 120 });
expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' });
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' });
const second = await put(luggageId, { reservation_id: bookingId });
expect(second.status).toBe(200);
expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '150.5' });
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '150.5' });
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
const linked = (list.body.items as { id: number; reservation_id: number | null }[])
@@ -406,34 +421,34 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
const relink = await put(fareId, { reservation_id: foreignBookingId, name: 'Hijacked' });
expect(relink.status).toBe(400);
expect(relink.body).toEqual({ error: 'reservation_id does not belong to this trip.' });
expect(linkOf(fareId)).toEqual({ reservation_id: bookingId });
expect(db.prepare('SELECT name FROM budget_items WHERE id = ?').get(fareId)).toEqual({ name: 'Fare' });
expect(metadataOf(foreignBookingId)).toBeNull();
expect(await linkOf(fareId)).toEqual({ reservation_id: bookingId });
expect((await budgetRow(fareId)).name).toBe('Fare');
expect(await metadataOf(foreignBookingId)).toBeNull();
const place = await put(fareId, { place_id: foreignPlaceId });
expect(place.status).toBe(400);
expect(place.body).toEqual({ error: 'place_id does not belong to this trip.' });
const before = (db.prepare('SELECT COUNT(*) AS n FROM budget_items').get() as { n: number }).n;
const before = await countRows(orm, BudgetItems);
const created = await create({ name: 'Smuggled', total_price: 5, reservation_id: foreignBookingId });
expect(created.status).toBe(400);
expect(created.body).toEqual({ error: 'reservation_id does not belong to this trip.' });
expect((db.prepare('SELECT COUNT(*) AS n FROM budget_items').get() as { n: number }).n).toBe(before);
expect(await countRows(orm, BudgetItems)).toBe(before);
});
it('400 on a reservation_id that is not a positive integer (Zod pipe envelope)', async () => {
const res = await put(fareId, { reservation_id: 0 });
expect(res.status).toBe(400);
expect(res.body.error).toContain('reservation_id');
expect(linkOf(fareId)).toEqual({ reservation_id: bookingId });
expect(await linkOf(fareId)).toEqual({ reservation_id: bookingId });
});
it('200 on unlinking: the expense stays and the booking price is worked out again', async () => {
const res = await put(luggageId, { reservation_id: null });
expect(res.status).toBe(200);
expect(res.body.item).toMatchObject({ id: luggageId, reservation_id: null, total_price: 30.5 });
expect(linkOf(luggageId)).toEqual({ reservation_id: null });
expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' });
expect(await linkOf(luggageId)).toEqual({ reservation_id: null });
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '120' });
});
it('201 on a second expense created straight onto the booking, which raises its price to the sum', async () => {
@@ -441,7 +456,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
expect(seat.status).toBe(201);
expect(seat.body.item.reservation_id).toBe(bookingId);
seatId = seat.body.item.id;
expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '135' });
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '135' });
});
it('200 on new payers for a linked expense, whose derived total the booking price follows', async () => {
@@ -451,30 +466,30 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
.send({ payers: [{ user_id: 1, amount: 12 }, { user_id: 2, amount: 6 }] });
expect(res.status).toBe(200);
expect(res.body.item).toMatchObject({ id: seatId, total_price: 18 });
expect(metadataOf(bookingId)).toEqual({ seat: '12A', price: '138' });
expect(await metadataOf(bookingId)).toEqual({ seat: '12A', price: '138' });
});
it('200 on a currency-only change of a linked expense, which re-names the booking price', async () => {
const trainId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Train', 'train')").run(tripId).lastInsertRowid);
const trainId = (await makeReservation(orm, tripId, { title: 'Train', type: 'train' })).id;
const ticket = await create({ name: 'Ticket', total_price: 60, reservation_id: trainId });
expect(ticket.status).toBe(201);
// In the trip currency, so the price carries no currency of its own.
expect(metadataOf(trainId)).toEqual({ price: '60' });
expect(await metadataOf(trainId)).toEqual({ price: '60' });
const chf = await put(ticket.body.item.id, { currency: 'CHF' });
expect(chf.status).toBe(200);
expect(chf.body.item).toMatchObject({ currency: 'CHF', total_price: 60 });
expect(metadataOf(trainId)).toEqual({ price: '60', priceCurrency: 'CHF' });
expect(await metadataOf(trainId)).toEqual({ price: '60', priceCurrency: 'CHF' });
const back = await put(ticket.body.item.id, { currency: null });
expect(back.status).toBe(200);
expect(metadataOf(trainId)).toEqual({ price: '60' });
expect(await metadataOf(trainId)).toEqual({ price: '60' });
// An expense with no currency and one naming the trip's own (EUR, in any
// case) are in the same money, so the booking shows their sum.
const bike = await create({ name: 'Bike ticket', total_price: 9.5, currency: 'eur', reservation_id: trainId });
expect(bike.status).toBe(201);
expect(metadataOf(trainId)).toEqual({ price: '69.5', priceCurrency: 'EUR' });
expect(await metadataOf(trainId)).toEqual({ price: '69.5', priceCurrency: 'EUR' });
});
it('deleting the booking removes every expense linked to it, and only those', async () => {
@@ -486,10 +501,10 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(db.prepare('SELECT id FROM reservations WHERE id = ?').get(bookingId)).toBeUndefined();
expect(linkOf(fareId)).toBeUndefined();
expect(linkOf(seatId)).toBeUndefined();
expect(linkOf(unlinkedId)).toEqual({ reservation_id: null });
expect(await findRow(orm, Reservations, { id: bookingId })).toBeNull();
expect(await linkOf(fareId)).toBeUndefined();
expect(await linkOf(seatId)).toBeUndefined();
expect(await linkOf(unlinkedId)).toEqual({ reservation_id: null });
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
const ids = (list.body.items as { id: number }[]).map(i => i.id);
@@ -504,16 +519,16 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
// are the ones the browser lends.
describe('rows no rate can convert', () => {
/** A fresh AUD trip the owner shares with user 2, so these cases never meet the ledger above. */
function audTrip(): number {
const id = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'AUD Trip', 'AUD')").run().lastInsertRowid);
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, 2)').run(id);
async function audTrip(): Promise<number> {
const id = (await makeTrip(orm, 1, { title: 'AUD Trip', currency: 'AUD' })).id;
await addTripMember(orm, id, 2);
return id;
}
const balanceOf = (body: { balances: { user_id: number; balance: number }[] }, uid: number) =>
body.balances.find(b => b.user_id === uid)!.balance;
it('VND/AUD regression: unfrozen VND bill is listed as unconverted, freeze-rates heals it from the browser quote, settlement nets 489.00, second freeze heals nothing', async () => {
const trip = audTrip();
const trip = await audTrip();
const bill = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
@@ -550,11 +565,11 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
.send({ fallback_fx: { base: 'AUD', rates: { VND: 25000 } } });
expect(again.status).toBe(200);
expect(again.body).toEqual({ items: [], settlements: [], unresolved: [] });
expect((db.prepare('SELECT exchange_rate FROM budget_items WHERE id = ?').get(bill.body.item.id) as { exchange_rate: number }).exchange_rate).toBe(18241.3);
expect((await budgetRow(bill.body.item.id)).exchange_rate).toBe(18241.3);
});
it('POST with fallback_fx freezes at entry', async () => {
const trip = audTrip();
const trip = await audTrip();
const res = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
@@ -565,7 +580,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
});
it('POST /settlements in EUR with fallback_fx freezes the transfer', async () => {
const trip = audTrip();
const trip = await audTrip();
const res = await request(server)
.post(`/api/trips/${trip}/budget/settlements`)
.set('Cookie', sessionCookie(1))
@@ -582,7 +597,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
});
it('GET /settlement?base=EUR answers in AUD without base_rate and in EUR with it', async () => {
const trip = audTrip();
const trip = await audTrip();
const dinner = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
@@ -602,7 +617,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
});
it('PUT currency change to VND without a rate stores 1 instead of the USD rate', async () => {
const trip = audTrip();
const trip = await audTrip();
const created = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
@@ -615,12 +630,13 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
.send({ currency: 'VND' });
expect(res.status).toBe(200);
expect(res.body.item).toMatchObject({ currency: 'VND', exchange_rate: 1 });
expect(db.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(created.body.item.id))
const stored = await budgetRow(created.body.item.id);
expect({ currency: stored.currency, exchange_rate: stored.exchange_rate })
.toEqual({ currency: 'VND', exchange_rate: 1 });
});
it('freeze-rates 403 without budget_edit, 400 on malformed fallback_fx, other base heals nothing (unresolved VND)', async () => {
const trip = audTrip();
const trip = await audTrip();
const bill = await request(server)
.post(`/api/trips/${trip}/budget`)
.set('Cookie', sessionCookie(1))
@@ -644,7 +660,7 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
const otherBase = await freeze({ fallback_fx: { base: 'EUR', rates: { VND: 27000 } } });
expect(otherBase.status).toBe(200);
expect(otherBase.body).toEqual({ items: [], settlements: [], unresolved: ['VND'] });
expect((db.prepare('SELECT exchange_rate FROM budget_items WHERE id = ?').get(bill.body.item.id) as { exchange_rate: number }).exchange_rate).toBe(1);
expect((await budgetRow(bill.body.item.id)).exchange_rate).toBe(1);
});
it('400 on base_rate=abc', async () => {
+60 -91
View File
@@ -1,10 +1,12 @@
/**
* Collab module e2e — exercises the migrated /api/trips/:tripId/collab endpoints
* through the real JwtAuthGuard against a temp SQLite db, running CollabService's
* real SQL (DI-injected, no service mock). canAccessTrip, the permission check,
* the WebSocket broadcast and the chat/note notification are mocked; this focuses
* through the real JwtAuthGuard against a real migrated temp SQLite db
* (createSnapshotTestDb(), no hand-rolled CREATE TABLEs), running CollabService's
* real queries (DI-injected, no service mock). The permission check, the
* WebSocket broadcast and the chat/note notification are mocked; this focuses
* on auth, trip-access 404, permission 403, the create-201 status codes, the
* vote/react 200 overrides and the persisted rows.
* vote/react 200 overrides and the persisted rows. Rows are seeded and read
* through the factories in tests/helpers/factories.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import request from 'supertest';
@@ -12,59 +14,12 @@ import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
// The note/message/poll formatters join users for username + avatar.
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
avatar TEXT);`);
// The note/message notifications read the trip title fire-and-forget; the table
// must exist so that query doesn't throw after the test has torn down.
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, user_id INTEGER, currency TEXT);');
// TripAccessGuard now reads TripsRepository.findAccessible directly
// (Plan 3c Task 0b), a real join against trip_members.
tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);');
// CollabService's real SQL (DI-injected, no mock) — the collab tables as the
// real schema + migrations shape them (website on notes, deleted on messages,
// note_id on trip_files).
tmp.exec(`CREATE TABLE collab_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, category TEXT DEFAULT 'General', title TEXT NOT NULL, content TEXT,
color TEXT DEFAULT '#6366f1', pinned INTEGER DEFAULT 0, website TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE collab_links (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE collab_polls (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, question TEXT NOT NULL, options TEXT NOT NULL, multiple INTEGER DEFAULT 0,
closed INTEGER DEFAULT 0, deadline TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE collab_poll_votes (id INTEGER PRIMARY KEY AUTOINCREMENT, poll_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, option_index INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(poll_id, user_id, option_index));`);
tmp.exec(`CREATE TABLE collab_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, text TEXT NOT NULL, reply_to INTEGER, deleted INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE collab_message_reactions (id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL, user_id INTEGER NOT NULL, emoji TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
note_id INTEGER, message_id INTEGER, filename TEXT NOT NULL, original_name TEXT NOT NULL,
file_size INTEGER, mime_type TEXT, uploaded_by INTEGER, deleted_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
return { db: tmp };
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db, canAccessTrip, getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
@@ -77,8 +32,21 @@ import { CollabModule } from '../../src/nest/collab/collab.module';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { RateLimitService } from '../../src/nest/common/rate-limit.service';
import { db } from '../../src/db/database';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { createTestMikroOrmModule } from '../helpers/test-orm';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { makeUser } from '../helpers/factories/users';
import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows';
import { CollabLinks } from '../../src/db/entities/CollabLinks.entity';
import { CollabMessageReactions } from '../../src/db/entities/CollabMessageReactions.entity';
import { CollabMessages } from '../../src/db/entities/CollabMessages.entity';
import { CollabNotes } from '../../src/db/entities/CollabNotes.entity';
import { CollabPollVotes } from '../../src/db/entities/CollabPollVotes.entity';
import { CollabPolls } from '../../src/db/entities/CollabPolls.entity';
import { TripFiles } from '../../src/db/entities/TripFiles.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
let orm: TestOrm;
describe('Collab e2e (real auth guard + temp SQLite)', () => {
let server: Server;
@@ -98,31 +66,33 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
orm = await createTestOrm(db);
// Pinned id: sessionCookie(1) signs for exactly this user.
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 });
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(() => {
beforeEach(async () => {
// Plan 3c Task 0b: TripAccessGuard reads TripsRepository.findAccessible
// directly now, a real query — `canAccessTrip.mockReturnValue(...)` no
// longer intercepts it, so trip 5's real row is (re-)seeded every test
// instead, owned by user 1, matching what the mock used to fake.
db.prepare('DELETE FROM trips WHERE id = 5').run();
db.prepare("INSERT INTO trips (id, title, user_id) VALUES (5, 'Trip', 1)").run();
// directly now, a real query, so trip 5's real row is (re-)seeded every
// test, owned by user 1.
await deleteRows(orm, Trips, { id: 5 });
await insertRow(orm, Trips, { id: 5, title: 'Trip', user: 1 });
checkPermission.mockReturnValue(true);
db.prepare('DELETE FROM collab_message_reactions').run();
db.prepare('DELETE FROM collab_poll_votes').run();
db.prepare('DELETE FROM collab_messages').run();
db.prepare('DELETE FROM collab_polls').run();
db.prepare('DELETE FROM trip_files').run();
db.prepare('DELETE FROM collab_notes').run();
db.prepare('DELETE FROM collab_links').run();
await deleteRows(orm, CollabMessageReactions);
await deleteRows(orm, CollabPollVotes);
await deleteRows(orm, CollabMessages);
await deleteRows(orm, CollabPolls);
await deleteRows(orm, TripFiles);
await deleteRows(orm, CollabNotes);
await deleteRows(orm, CollabLinks);
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('401 without a session cookie', async () => {
@@ -130,7 +100,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
});
it('200 list notes for an accessible trip', async () => {
db.prepare("INSERT INTO collab_notes (id, trip_id, user_id, title) VALUES (1, 5, 1, 'N')").run();
await insertRow(orm, CollabNotes, { id: 1, trip: 5, user: 1, title: 'N' });
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.notes).toHaveLength(1);
@@ -138,7 +108,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
});
it('404 when the trip is not accessible', async () => {
db.prepare('DELETE FROM trips WHERE id = 5').run();
await deleteRows(orm, Trips, { id: 5 });
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
@@ -148,8 +118,8 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
expect(res.status).toBe(201);
expect(res.body.note).toMatchObject({ title: 'N', category: 'General', color: '#6366f1', pinned: 0 });
const row = db.prepare('SELECT * FROM collab_notes WHERE trip_id = 5').get() as { title: string };
expect(row.title).toBe('N');
const row = await findRow(orm, CollabNotes, { trip: 5 });
expect(row?.title).toBe('N');
});
it('403 on note create without permission', async () => {
@@ -160,37 +130,36 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
});
it('200 on poll vote (not 201), vote persisted', async () => {
db.prepare('INSERT INTO collab_polls (id, trip_id, user_id, question, options) VALUES (7, 5, 1, ?, ?)')
.run('Q?', JSON.stringify(['A', 'B']));
await insertRow(orm, CollabPolls, { id: 7, trip: 5, user: 1, question: 'Q?', options: JSON.stringify(['A', 'B']) });
const res = await request(server).post('/api/trips/5/collab/polls/7/vote').set('Cookie', sessionCookie(1)).send({ option_index: 0 });
expect(res.status).toBe(200);
expect(res.body.poll).toMatchObject({ id: 7, is_closed: false });
expect(res.body.poll.options[0].voters).toHaveLength(1);
const vote = db.prepare('SELECT * FROM collab_poll_votes WHERE poll_id = 7').get() as { option_index: number };
expect(vote.option_index).toBe(0);
const vote = await findRow(orm, CollabPollVotes, { poll: 7 });
expect(vote?.option_index).toBe(0);
});
it('201 on message create, row persisted', async () => {
const res = await request(server).post('/api/trips/5/collab/messages').set('Cookie', sessionCookie(1)).send({ text: 'hi' });
expect(res.status).toBe(201);
expect(res.body.message).toMatchObject({ text: 'hi', trip_id: 5, user_id: 1 });
const row = db.prepare('SELECT * FROM collab_messages WHERE trip_id = 5').get() as { text: string };
expect(row.text).toBe('hi');
const row = await findRow(orm, CollabMessages, { trip: 5 });
expect(row?.text).toBe('hi');
});
it('200 on react (not 201), reaction persisted and toggled', async () => {
db.prepare("INSERT INTO collab_messages (id, trip_id, user_id, text) VALUES (3, 5, 1, 'react me')").run();
await insertRow(orm, CollabMessages, { id: 3, trip: 5, user: 1, text: 'react me' });
const res = await request(server).post('/api/trips/5/collab/messages/3/react').set('Cookie', sessionCookie(1)).send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toHaveLength(1);
expect(res.body.reactions[0]).toMatchObject({ emoji: '👍', count: 1 });
expect(db.prepare('SELECT COUNT(*) as c FROM collab_message_reactions WHERE message_id = 3').get()).toEqual({ c: 1 });
expect(await countRows(orm, CollabMessageReactions, { message: 3 })).toBe(1);
});
// The advisory this route was reported under: it answered anyone with a session,
// for any trip id, and drove an outbound fetch from that.
it('404 on link-preview for a trip the caller cannot reach', async () => {
db.prepare('DELETE FROM trips WHERE id = 5').run();
await deleteRows(orm, Trips, { id: 5 });
const res = await request(server)
.get('/api/trips/5/collab/link-preview?url=https://example.com/')
.set('Cookie', sessionCookie(1));
@@ -248,7 +217,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
});
it('404 when the trip is not accessible', async () => {
db.prepare('DELETE FROM trips WHERE id = 5').run();
await deleteRows(orm, Trips, { id: 5 });
const res = await request(server).get('/api/trips/5/collab/links').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
});
@@ -258,7 +227,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
.send({ title: 'Ferry', url: 'https://example.com/ferry' });
expect(res.status).toBe(201);
expect(res.body.link).toMatchObject({ title: 'Ferry', url: 'https://example.com/ferry' });
const row = db.prepare('SELECT * FROM collab_links WHERE trip_id = 5').get() as { title: string; user_id: number };
const row = await findRow(orm, CollabLinks, { trip: 5 });
expect(row).toMatchObject({ title: 'Ferry', user_id: 1 });
});
@@ -275,15 +244,15 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
});
it('200 on list, pinned first', async () => {
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url, pinned) VALUES (1, 5, 1, 'Plain', 'https://a.test', 0)").run();
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url, pinned) VALUES (2, 5, 1, 'Pinned', 'https://b.test', 1)").run();
await insertRow(orm, CollabLinks, { id: 1, trip: 5, user: 1, title: 'Plain', url: 'https://a.test', pinned: 0 });
await insertRow(orm, CollabLinks, { id: 2, trip: 5, user: 1, title: 'Pinned', url: 'https://b.test', pinned: 1 });
const res = await request(server).get('/api/trips/5/collab/links').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.links.map((l: { title: string }) => l.title)).toEqual(['Pinned', 'Plain']);
});
it('200 on update and the pin lands in the row', async () => {
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
await insertRow(orm, CollabLinks, { id: 1, trip: 5, user: 1, title: 'Plain', url: 'https://a.test' });
const res = await request(server).put('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1)).send({ pinned: true });
expect(res.status).toBe(200);
expect(res.body.link).toMatchObject({ id: 1, pinned: 1 });
@@ -295,14 +264,14 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
});
it('200 on delete and the row is gone', async () => {
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
await insertRow(orm, CollabLinks, { id: 1, trip: 5, user: 1, title: 'Plain', url: 'https://a.test' });
const res = await request(server).delete('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(db.prepare('SELECT COUNT(*) c FROM collab_links').get()).toEqual({ c: 0 });
expect(await countRows(orm, CollabLinks)).toBe(0);
});
it('403 on delete without collab_edit', async () => {
db.prepare("INSERT INTO collab_links (id, trip_id, user_id, title, url) VALUES (1, 5, 1, 'Plain', 'https://a.test')").run();
await insertRow(orm, CollabLinks, { id: 1, trip: 5, user: 1, title: 'Plain', url: 'https://a.test' });
checkPermission.mockReturnValue(false);
expect((await request(server).delete('/api/trips/5/collab/links/1').set('Cookie', sessionCookie(1))).status).toBe(403);
});
+34 -36
View File
@@ -47,6 +47,9 @@ import { OauthClients } from '../../src/db/entities/OauthClients.entity';
import { OauthTokens } from '../../src/db/entities/OauthTokens.entity';
import { OauthConsents } from '../../src/db/entities/OauthConsents.entity';
import { MikroORM } from '@mikro-orm/core';
import { McpTokens } from '../../src/db/entities/McpTokens.entity';
import { countRows, deleteRows, findRow, findRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
let oauthSvc: OauthService;
@@ -123,7 +126,7 @@ describe('MCP session init', () => {
const token = generateToken(user.id);
// Enable MCP addon in test DB
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const res = await request(app)
.post('/mcp')
@@ -138,7 +141,7 @@ describe('MCP session init', () => {
const { user } = createUser(testDb);
const token = generateToken(user.id);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const res = await request(app)
.delete('/mcp')
@@ -148,7 +151,7 @@ describe('MCP session init', () => {
});
it('MCP-004 — POST /mcp with invalid JWT returns 401 (when addon enabled)', async () => {
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const res = await request(app)
.post('/mcp')
@@ -162,7 +165,7 @@ describe('MCP API token auth', () => {
it('MCP-002 — POST /mcp with valid trek_ API token authenticates successfully', async () => {
const { user } = createUser(testDb);
const { rawToken } = createMcpToken(testDb, user.id);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const res = await request(app)
.post('/mcp')
@@ -175,9 +178,9 @@ describe('MCP API token auth', () => {
it('MCP-002 — last_used_at is updated on token use', async () => {
const { user } = createUser(testDb);
const { rawToken, id: tokenId } = createMcpToken(testDb, user.id);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const before = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at;
const before = (await findRow(t, McpTokens, { id: tokenId }))?.last_used_at;
await request(app)
.post('/mcp')
@@ -185,13 +188,13 @@ describe('MCP API token auth', () => {
.set('Accept', 'application/json, text/event-stream')
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
const after = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at;
const after = (await findRow(t, McpTokens, { id: tokenId }))?.last_used_at;
expect(after).not.toBeNull();
expect(after).not.toBe(before);
});
it('MCP — POST /mcp with unknown trek_ token returns 401', async () => {
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const res = await request(app)
.post('/mcp')
@@ -201,7 +204,7 @@ describe('MCP API token auth', () => {
});
it('MCP — POST /mcp with no Authorization header returns 401', async () => {
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const res = await request(app)
.post('/mcp')
@@ -226,7 +229,7 @@ describe('MCP session management', () => {
it('MCP-003 — at the session cap, the coldest session is evicted rather than the request refused', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const sessionsForUser = () => [...sessions.values()].filter((s) => s.userId === user.id).length;
@@ -247,7 +250,7 @@ describe('MCP session management', () => {
it('MCP-006 — initializes that race past the cap check are trimmed when they register', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const sessionsForUser = () => [...sessions.values()].filter((s) => s.userId === user.id).length;
@@ -284,7 +287,7 @@ describe('MCP session management', () => {
it('MCP — session resumption with valid mcp-session-id', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const sessionId = await createSession(user.id);
const token = generateToken(user.id);
@@ -300,7 +303,7 @@ describe('MCP session management', () => {
it('MCP — session belongs to different user returns 403', async () => {
const { user: user1 } = createUser(testDb);
const { user: user2 } = createUser(testDb);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const sessionId = await createSession(user1.id);
const token2 = generateToken(user2.id);
@@ -315,7 +318,7 @@ describe('MCP session management', () => {
it('MCP — a session-less non-initialize POST is rejected without registering a session', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const token = generateToken(user.id);
const before = sessions.size;
@@ -333,7 +336,7 @@ describe('MCP session management', () => {
it('MCP — initialize response exposes Mcp-Session-Id to browser-context clients', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const token = generateToken(user.id);
const res = await request(app)
@@ -353,7 +356,7 @@ describe('MCP session management', () => {
it('MCP — GET without mcp-session-id returns 400', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const token = generateToken(user.id);
const res = await request(app)
@@ -376,8 +379,8 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
return res.headers['mcp-session-id'] as string;
}
beforeEach(() => {
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
beforeEach(async () => {
await setAddonEnabled(t, 'mcp', true);
});
// The source is process-level, so a test that installs one must not leak it
@@ -387,7 +390,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
});
it('MCP-P01 — addon off answers 403 with the exact legacy body', async () => {
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', false);
const res = await request(app).post('/mcp').send(initBody);
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'MCP is not enabled' });
@@ -537,7 +540,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
const { user } = createUser(testDb);
const { accessToken, clientId } = await mintOauthToken(user.id, MCP_AUDIENCE);
const sessionId = await createSession(accessToken);
testDb.prepare("DELETE FROM audit_log WHERE action = 'mcp.tool_call'").run();
await deleteRows(t, AuditLog, { action: 'mcp.tool_call' });
const call = await request(app)
.post('/mcp')
@@ -547,20 +550,18 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
.send({ jsonrpc: '2.0', method: 'tools/call', id: 3, params: { name: 'list_trips', arguments: {} } });
expect(call.status).toBe(200);
const rows = testDb.prepare(
"SELECT user_id, action, resource, details FROM audit_log WHERE action = 'mcp.tool_call'",
).all() as Array<{ user_id: number; action: string; resource: string; details: string }>;
const rows = await findRows(t, AuditLog, { action: 'mcp.tool_call' });
expect(rows).toHaveLength(1);
expect(rows[0].user_id).toBe(user.id);
expect(rows[0].resource).toBe('list_trips');
expect(JSON.parse(rows[0].details)).toEqual({ clientId });
expect(JSON.parse(String(rows[0].details))).toEqual({ clientId });
});
it('MCP-P14 — tools/list and resource reads write no mcp.tool_call rows', async () => {
const { user } = createUser(testDb);
const token = generateToken(user.id);
const sessionId = await createSession(token);
testDb.prepare("DELETE FROM audit_log WHERE action = 'mcp.tool_call'").run();
await deleteRows(t, AuditLog, { action: 'mcp.tool_call' });
const list = await request(app)
.post('/mcp')
@@ -578,8 +579,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
.send({ jsonrpc: '2.0', method: 'resources/list', id: 3, params: {} });
expect(read.status).toBe(200);
const rows = testDb.prepare("SELECT id FROM audit_log WHERE action = 'mcp.tool_call'").all();
expect(rows).toHaveLength(0);
expect(await countRows(t, AuditLog, { action: 'mcp.tool_call' })).toBe(0);
});
// ── plugin-contributed tools ──
@@ -655,7 +655,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
setPluginMcpToolSource(() => [dynamicTool('plugin_demo_echo', 'contributed')]);
const { accessToken, clientId } = await mintOauthToken(user.id, MCP_AUDIENCE, ['trips:read', 'plugins:use']);
const sessionId = await createSession(accessToken);
testDb.prepare("DELETE FROM audit_log WHERE action = 'mcp.tool_call'").run();
await deleteRows(t, AuditLog, { action: 'mcp.tool_call' });
const call = await request(app)
.post('/mcp')
@@ -667,13 +667,11 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
// The audit seam is exactly why the source lives on McpAttachOptions rather
// than the host calling server.registerTool() itself.
const rows = testDb.prepare(
"SELECT user_id, resource, details FROM audit_log WHERE action = 'mcp.tool_call'",
).all() as Array<{ user_id: number; resource: string; details: string }>;
const rows = await findRows(t, AuditLog, { action: 'mcp.tool_call' });
expect(rows).toHaveLength(1);
expect(rows[0].user_id).toBe(user.id);
expect(rows[0].resource).toBe('plugin_demo_echo');
expect(JSON.parse(rows[0].details)).toEqual({ clientId });
expect(JSON.parse(String(rows[0].details))).toEqual({ clientId });
});
it('MCP-P18 — a token without plugins:use is never shown a plugin tool', async () => {
@@ -736,7 +734,7 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
describe('MCP rate limiting', () => {
it('MCP-005 — requests below limit succeed', async () => {
const { user } = createUser(testDb);
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const token = generateToken(user.id);
// Set a very low rate limit via env for this test
@@ -786,10 +784,10 @@ describe('MCP bearer-token verification runs inside the HTTP request context (Pl
});
it('MCP-CTX-002: a repository read inside verifyMcpToken (the trek_ bearer-token branch) succeeds — the /mcp route already forks a request context before the auth step runs', async () => {
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
await setAddonEnabled(t, 'mcp', true);
const { user } = createUser(testDb);
const { rawToken, id: tokenId } = createMcpToken(testDb, user.id);
const before = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at;
const before = (await findRow(t, McpTokens, { id: tokenId }))?.last_used_at;
expect(before).toBeNull();
const res = await request(app)
@@ -803,7 +801,7 @@ describe('MCP bearer-token verification runs inside the HTTP request context (Pl
// `findUserByHashAndKind` (TK13) then `touchLastUsedByHash` (TK14) —
// `last_used_at` moving is only possible if both reached the DB, which
// MCP-CTX-001 proves the ORM refuses outside a forked request context.
const after = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at;
const after = (await findRow(t, McpTokens, { id: tokenId }))?.last_used_at;
expect(after).not.toBeNull();
expect(after).not.toBe(before);
});
@@ -4,6 +4,9 @@ import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { OauthTokens } from '../../../../src/db/entities/OauthTokens.entity';
import { OauthClients } from '../../../../src/db/entities/OauthClients.entity';
import { OauthConsents } from '../../../../src/db/entities/OauthConsents.entity';
import { deleteRows, findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows';
import type { OauthTokensRepository } from '../../../../src/db/repositories/OauthTokens.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
@@ -18,45 +21,58 @@ beforeAll(async () => {
tokens = t.repo(OauthTokens);
uow = new UnitOfWork(t.em);
});
beforeEach(() => {
beforeEach(async () => {
resetTestDb(testDb);
t.clear();
testDb.exec('DELETE FROM oauth_tokens');
testDb.exec('DELETE FROM oauth_consents');
testDb.exec('DELETE FROM oauth_clients');
await deleteRows(t, OauthTokens);
await deleteRows(t, OauthConsents);
await deleteRows(t, OauthClients);
});
afterAll(async () => { await t.close(); testDb.close(); });
function seedClient(userId: number, clientId: string, name = 'Client'): void {
testDb.prepare(
`INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, '[]', '[]')`,
).run(`row-${clientId}`, userId, name, clientId, 'hash');
async function seedClient(userId: number, clientId: string, name = 'Client'): Promise<void> {
await insertRow(t, OauthClients, {
id: `row-${clientId}`,
user: userId,
name,
client_id: clientId,
client_secret_hash: 'hash',
redirect_uris: '[]',
allowed_scopes: '[]',
});
}
function seedToken(
/** The stored token row; fails the case when it is gone. */
async function tokenRow(id: number) {
const row = await findRow(t, OauthTokens, { id });
if (!row) throw new Error(`no token ${id}`);
return row;
}
async function revokedAtOf(id: number) {
return (await tokenRow(id)).revoked_at;
}
async function seedToken(
overrides: Partial<{
id: number; clientId: string; userId: number; accessHash: string; refreshHash: string; scopes: string;
audience: string | null; accessExpiresAt: string; refreshExpiresAt: string; revokedAt: string | null; parentId: number | null;
}> = {},
): number {
const info = testDb.prepare(
`INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, revoked_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
overrides.clientId,
overrides.userId,
overrides.accessHash ?? `acc-${Math.random().toString(36).slice(2)}`,
overrides.refreshHash ?? `ref-${Math.random().toString(36).slice(2)}`,
overrides.scopes ?? '["trips:read"]',
overrides.audience ?? null,
overrides.accessExpiresAt ?? new Date(Date.now() + 3600_000).toISOString(),
overrides.refreshExpiresAt ?? new Date(Date.now() + 30 * 24 * 3600_000).toISOString(),
overrides.revokedAt ?? null,
overrides.parentId ?? null,
);
const id = Number(info.lastInsertRowid);
): Promise<number> {
const id = await insertRow(t, OauthTokens, {
client: overrides.clientId as string,
user: overrides.userId as number,
access_token_hash: overrides.accessHash ?? `acc-${Math.random().toString(36).slice(2)}`,
refresh_token_hash: overrides.refreshHash ?? `ref-${Math.random().toString(36).slice(2)}`,
scopes: overrides.scopes ?? '["trips:read"]',
audience: overrides.audience ?? null,
access_token_expires_at: overrides.accessExpiresAt ?? new Date(Date.now() + 3600_000).toISOString(),
refresh_token_expires_at: overrides.refreshExpiresAt ?? new Date(Date.now() + 30 * 24 * 3600_000).toISOString(),
revoked_at: overrides.revokedAt ?? null,
parentToken: overrides.parentId ?? null,
});
if (overrides.id !== undefined) {
testDb.prepare('UPDATE oauth_tokens SET id = ? WHERE id = ?').run(overrides.id, id);
await updateRows(t, OauthTokens, { id }, { id: overrides.id });
return overrides.id;
}
return id;
@@ -66,7 +82,7 @@ describe('OauthTokensRepository', () => {
describe('insertToken (OA13/OA14)', () => {
it('OAUTHTOKREPO-001: writes exactly the given columns', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-1');
await seedClient(user.id, 'proto-1');
const accessExpiry = new Date(Date.now() + 3600_000).toISOString();
const refreshExpiry = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
await tokens.insertToken({
@@ -80,7 +96,7 @@ describe('OauthTokensRepository', () => {
refresh_token_expires_at: refreshExpiry,
parent_token_id: null,
});
const row = testDb.prepare('SELECT * FROM oauth_tokens WHERE access_token_hash = ?').get('acchash') as Record<string, unknown>;
const row = await findRow(t, OauthTokens, { access_token_hash: 'acchash' });
expect(row).toMatchObject({
client_id: 'proto-1',
user_id: user.id,
@@ -95,54 +111,54 @@ describe('OauthTokensRepository', () => {
it('OAUTHTOKREPO-002: parent_token_id is written when given (rotation)', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-2');
const parentId = seedToken({ clientId: 'proto-2', userId: user.id });
await seedClient(user.id, 'proto-2');
const parentId = await seedToken({ clientId: 'proto-2', userId: user.id });
await tokens.insertToken({
client_id: 'proto-2', user_id: user.id, access_token_hash: 'a2', refresh_token_hash: 'r2', scopes: '[]', audience: null,
access_token_expires_at: new Date().toISOString(), refresh_token_expires_at: new Date().toISOString(), parent_token_id: parentId,
});
const row = testDb.prepare('SELECT parent_token_id FROM oauth_tokens WHERE access_token_hash = ?').get('a2') as { parent_token_id: number };
expect(row.parent_token_id).toBe(parentId);
const row = await findRow(t, OauthTokens, { access_token_hash: 'a2' });
expect(row?.parent_token_id).toBe(parentId);
});
});
describe('revokeAllForClient (OA8) — the two spellings of "now"', () => {
it("OAUTHTOKREPO-003: revokeAllForClient (legacy datetime('now')) and revokeById (legacy CURRENT_TIMESTAMP) store byte-identical text", async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-3');
const idA = seedToken({ clientId: 'proto-3', userId: user.id });
const idB = seedToken({ clientId: 'proto-3', userId: user.id });
await seedClient(user.id, 'proto-3');
const idA = await seedToken({ clientId: 'proto-3', userId: user.id });
const idB = await seedToken({ clientId: 'proto-3', userId: user.id });
await tokens.revokeAllForClient('proto-3'); // legacy: datetime('now')
await tokens.revokeById(idB); // legacy: CURRENT_TIMESTAMP
const rowA = testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idA) as { revoked_at: string };
const rowB = testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idB) as { revoked_at: string };
const rowA = await tokenRow(idA);
const rowB = await tokenRow(idB);
expect(rowA.revoked_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(rowB.revoked_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it('OAUTHTOKREPO-004: revokeAllForClient only touches un-revoked rows of that client', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-4');
seedClient(user.id, 'proto-4b');
const idOther = seedToken({ clientId: 'proto-4b', userId: user.id });
const idAlready = seedToken({ clientId: 'proto-4', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idLive = seedToken({ clientId: 'proto-4', userId: user.id });
await seedClient(user.id, 'proto-4');
await seedClient(user.id, 'proto-4b');
const idOther = await seedToken({ clientId: 'proto-4b', userId: user.id });
const idAlready = await seedToken({ clientId: 'proto-4', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idLive = await seedToken({ clientId: 'proto-4', userId: user.id });
await tokens.revokeAllForClient('proto-4');
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idOther) as { revoked_at: string | null }).revoked_at).toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idAlready) as { revoked_at: string }).revoked_at).toBe('2020-01-01 00:00:00');
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idLive) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((await revokedAtOf(idOther))).toBeNull();
expect((await revokedAtOf(idAlready))).toBe('2020-01-01 00:00:00');
expect((await revokedAtOf(idLive))).not.toBeNull();
});
});
describe('findByAccessTokenHashWithUser (OA16)', () => {
it('OAUTHTOKREPO-005: joins the token owner\'s username/email/role', async () => {
const { user } = createUser(testDb, { username: 'bearer-user', email: 'bearer@example.test' });
seedClient(user.id, 'proto-5');
seedToken({ clientId: 'proto-5', userId: user.id, accessHash: 'bearer-hash', scopes: '["trips:read"]', audience: 'aud' });
await seedClient(user.id, 'proto-5');
await seedToken({ clientId: 'proto-5', userId: user.id, accessHash: 'bearer-hash', scopes: '["trips:read"]', audience: 'aud' });
const row = await tokens.findByAccessTokenHashWithUser('bearer-hash');
expect(row).toMatchObject({
@@ -159,9 +175,9 @@ describe('OauthTokensRepository', () => {
describe('findParent (OA17)', () => {
it('OAUTHTOKREPO-007: returns id + parent_token_id', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-7');
const rootId = seedToken({ clientId: 'proto-7', userId: user.id });
const childId = seedToken({ clientId: 'proto-7', userId: user.id, parentId: rootId });
await seedClient(user.id, 'proto-7');
const rootId = await seedToken({ clientId: 'proto-7', userId: user.id });
const childId = await seedToken({ clientId: 'proto-7', userId: user.id, parentId: rootId });
expect(await tokens.findParent(childId)).toEqual({ id: childId, parent_token_id: rootId });
expect(await tokens.findParent(rootId)).toEqual({ id: rootId, parent_token_id: null });
});
@@ -174,15 +190,16 @@ describe('OauthTokensRepository', () => {
describe('collectChainIds (OA18) — recursive CTE, proven against the legacy raw statement', () => {
it('OAUTHTOKREPO-008: a 4-level chain is fully collected, a sibling branch is excluded — matches the legacy WITH RECURSIVE statement exactly', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-8');
const id1 = seedToken({ clientId: 'proto-8', userId: user.id });
const id2 = seedToken({ clientId: 'proto-8', userId: user.id, parentId: id1 });
const id3 = seedToken({ clientId: 'proto-8', userId: user.id, parentId: id2 });
const id4 = seedToken({ clientId: 'proto-8', userId: user.id, parentId: id3 });
await seedClient(user.id, 'proto-8');
const id1 = await seedToken({ clientId: 'proto-8', userId: user.id });
const id2 = await seedToken({ clientId: 'proto-8', userId: user.id, parentId: id1 });
const id3 = await seedToken({ clientId: 'proto-8', userId: user.id, parentId: id2 });
const id4 = await seedToken({ clientId: 'proto-8', userId: user.id, parentId: id3 });
// A sibling branch off a DIFFERENT root — must not be collected.
const sibRoot = seedToken({ clientId: 'proto-8', userId: user.id });
const sibChild = seedToken({ clientId: 'proto-8', userId: user.id, parentId: sibRoot });
const sibRoot = await seedToken({ clientId: 'proto-8', userId: user.id });
const sibChild = await seedToken({ clientId: 'proto-8', userId: user.id, parentId: sibRoot });
// test-sql-allow: the legacy recursive statement is the oracle the repository read is held to.
const legacyRows = testDb.prepare(`
WITH RECURSIVE chain(id) AS (
SELECT id FROM oauth_tokens WHERE id = ?
@@ -203,8 +220,8 @@ describe('OauthTokensRepository', () => {
it('OAUTHTOKREPO-009: a token with no children collects only itself', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-9');
const id = seedToken({ clientId: 'proto-9', userId: user.id });
await seedClient(user.id, 'proto-9');
const id = await seedToken({ clientId: 'proto-9', userId: user.id });
expect(await tokens.collectChainIds(id)).toEqual([id]);
});
});
@@ -212,18 +229,18 @@ describe('OauthTokensRepository', () => {
describe('revokeByIds (OA19) — the $in revoke', () => {
it('OAUTHTOKREPO-010: revokes exactly the given ids, only if still live', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-10');
const idLive1 = seedToken({ clientId: 'proto-10', userId: user.id });
const idLive2 = seedToken({ clientId: 'proto-10', userId: user.id });
const idAlready = seedToken({ clientId: 'proto-10', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idUntouched = seedToken({ clientId: 'proto-10', userId: user.id });
await seedClient(user.id, 'proto-10');
const idLive1 = await seedToken({ clientId: 'proto-10', userId: user.id });
const idLive2 = await seedToken({ clientId: 'proto-10', userId: user.id });
const idAlready = await seedToken({ clientId: 'proto-10', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idUntouched = await seedToken({ clientId: 'proto-10', userId: user.id });
await tokens.revokeByIds([idLive1, idLive2, idAlready]);
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idLive1) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idLive2) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idAlready) as { revoked_at: string }).revoked_at).toBe('2020-01-01 00:00:00'); // untouched, not re-stamped
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idUntouched) as { revoked_at: string | null }).revoked_at).toBeNull();
expect((await revokedAtOf(idLive1))).not.toBeNull();
expect((await revokedAtOf(idLive2))).not.toBeNull();
expect((await revokedAtOf(idAlready))).toBe('2020-01-01 00:00:00'); // untouched, not re-stamped
expect((await revokedAtOf(idUntouched))).toBeNull();
});
it('OAUTHTOKREPO-011: an empty array is a no-op (matches the legacy `if (ids.length > 0)` guard)', async () => {
@@ -234,9 +251,9 @@ describe('OauthTokensRepository', () => {
describe('collectChainIds + revokeByIds inside a transaction — proves it joins the OPEN transaction', () => {
it('OAUTHTOKREPO-012: revoking a chain inside a uow.transactional that then ROLLS BACK revokes nothing', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-12');
const rootId = seedToken({ clientId: 'proto-12', userId: user.id });
const childId = seedToken({ clientId: 'proto-12', userId: user.id, parentId: rootId });
await seedClient(user.id, 'proto-12');
const rootId = await seedToken({ clientId: 'proto-12', userId: user.id });
const childId = await seedToken({ clientId: 'proto-12', userId: user.id, parentId: rootId });
let caught: unknown;
try {
@@ -251,15 +268,15 @@ describe('OauthTokensRepository', () => {
} catch (e) { caught = e; }
expect((caught as Error).message).toBe('force rollback');
const rows = testDb.prepare('SELECT id, revoked_at FROM oauth_tokens WHERE id IN (?, ?)').all(rootId, childId) as Array<{ id: number; revoked_at: string | null }>;
const rows = await findRows(t, OauthTokens, { id: { $in: [rootId, childId] } });
expect(rows.every((r) => r.revoked_at === null)).toBe(true);
});
it('OAUTHTOKREPO-013: the same sequence, committed, actually revokes the chain', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-13');
const rootId = seedToken({ clientId: 'proto-13', userId: user.id });
const childId = seedToken({ clientId: 'proto-13', userId: user.id, parentId: rootId });
await seedClient(user.id, 'proto-13');
const rootId = await seedToken({ clientId: 'proto-13', userId: user.id });
const childId = await seedToken({ clientId: 'proto-13', userId: user.id, parentId: rootId });
await withRequestContext(t.orm, async () => {
await uow.transactional(async () => {
@@ -268,7 +285,7 @@ describe('OauthTokensRepository', () => {
});
});
const rows = testDb.prepare('SELECT id, revoked_at FROM oauth_tokens WHERE id IN (?, ?)').all(rootId, childId) as Array<{ id: number; revoked_at: string | null }>;
const rows = await findRows(t, OauthTokens, { id: { $in: [rootId, childId] } });
expect(rows.every((r) => r.revoked_at !== null)).toBe(true);
});
});
@@ -276,17 +293,17 @@ describe('OauthTokensRepository', () => {
describe('findSuccessorAlive (OA20)', () => {
it('OAUTHTOKREPO-014: a revoked parent with a live child returns the child', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-14');
const parentId = seedToken({ clientId: 'proto-14', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const childId = seedToken({ clientId: 'proto-14', userId: user.id, parentId });
await seedClient(user.id, 'proto-14');
const parentId = await seedToken({ clientId: 'proto-14', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const childId = await seedToken({ clientId: 'proto-14', userId: user.id, parentId });
expect(await tokens.findSuccessorAlive(parentId)).toEqual({ id: childId });
});
it('OAUTHTOKREPO-015: no live child returns null', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-15');
const parentId = seedToken({ clientId: 'proto-15', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
seedToken({ clientId: 'proto-15', userId: user.id, parentId, revokedAt: '2020-01-01 00:00:01' });
await seedClient(user.id, 'proto-15');
const parentId = await seedToken({ clientId: 'proto-15', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
await seedToken({ clientId: 'proto-15', userId: user.id, parentId, revokedAt: '2020-01-01 00:00:01' });
expect(await tokens.findSuccessorAlive(parentId)).toBeNull();
});
});
@@ -294,9 +311,9 @@ describe('OauthTokensRepository', () => {
describe('findByRefreshTokenHash (OA22)', () => {
it('OAUTHTOKREPO-016: returns every column the legacy statement selects, FK scalars read from the raw column value', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-16');
const parentId = seedToken({ clientId: 'proto-16', userId: user.id });
const id = seedToken({ clientId: 'proto-16', userId: user.id, refreshHash: 'refresh-16', parentId, audience: 'aud-16' });
await seedClient(user.id, 'proto-16');
const parentId = await seedToken({ clientId: 'proto-16', userId: user.id });
const id = await seedToken({ clientId: 'proto-16', userId: user.id, refreshHash: 'refresh-16', parentId, audience: 'aud-16' });
const row = await tokens.findByRefreshTokenHash('refresh-16');
expect(row).toEqual({
@@ -313,38 +330,38 @@ describe('OauthTokensRepository', () => {
describe('revokeById (OA23/OA28/OA33)', () => {
it('OAUTHTOKREPO-018: revokes the given id only', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-18');
const idA = seedToken({ clientId: 'proto-18', userId: user.id });
const idB = seedToken({ clientId: 'proto-18', userId: user.id });
await seedClient(user.id, 'proto-18');
const idA = await seedToken({ clientId: 'proto-18', userId: user.id });
const idB = await seedToken({ clientId: 'proto-18', userId: user.id });
await tokens.revokeById(idA);
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idA) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idB) as { revoked_at: string | null }).revoked_at).toBeNull();
expect((await revokedAtOf(idA))).not.toBeNull();
expect((await revokedAtOf(idB))).toBeNull();
});
});
describe('findByAccessOrRefreshHashAndClient / revokeByAccessOrRefreshHashAndClient (OA24/OA25)', () => {
it('OAUTHTOKREPO-019: finds by access hash OR refresh hash, scoped to the client', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-19');
seedToken({ clientId: 'proto-19', userId: user.id, accessHash: 'acc-19', refreshHash: 'ref-19' });
await seedClient(user.id, 'proto-19');
await seedToken({ clientId: 'proto-19', userId: user.id, accessHash: 'acc-19', refreshHash: 'ref-19' });
expect(await tokens.findByAccessOrRefreshHashAndClient('acc-19', 'proto-19')).toEqual({ user_id: user.id });
expect(await tokens.findByAccessOrRefreshHashAndClient('ref-19', 'proto-19')).toEqual({ user_id: user.id });
});
it('OAUTHTOKREPO-020: does not match a token belonging to a different client', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-20a');
seedClient(user.id, 'proto-20b');
seedToken({ clientId: 'proto-20a', userId: user.id, accessHash: 'acc-20' });
await seedClient(user.id, 'proto-20a');
await seedClient(user.id, 'proto-20b');
await seedToken({ clientId: 'proto-20a', userId: user.id, accessHash: 'acc-20' });
expect(await tokens.findByAccessOrRefreshHashAndClient('acc-20', 'proto-20b')).toBeNull();
});
it('OAUTHTOKREPO-021: revokeByAccessOrRefreshHashAndClient revokes by either hash, scoped to the client', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-21');
const id = seedToken({ clientId: 'proto-21', userId: user.id, accessHash: 'acc-21', refreshHash: 'ref-21' });
await seedClient(user.id, 'proto-21');
const id = await seedToken({ clientId: 'proto-21', userId: user.id, accessHash: 'acc-21', refreshHash: 'ref-21' });
await tokens.revokeByAccessOrRefreshHashAndClient('acc-21', 'proto-21');
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(id) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((await revokedAtOf(id))).not.toBeNull();
});
});
@@ -352,19 +369,19 @@ describe('OauthTokensRepository', () => {
it('OAUTHTOKREPO-022: lists only this user\'s non-revoked, non-expired-refresh sessions, newest first, with the client name joined in', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
seedClient(user.id, 'proto-22a', 'Client A');
seedClient(user.id, 'proto-22b', 'Client B');
seedToken({ clientId: 'proto-22a', userId: user.id, scopes: '["trips:read"]' });
await seedClient(user.id, 'proto-22a', 'Client A');
await seedClient(user.id, 'proto-22b', 'Client B');
await seedToken({ clientId: 'proto-22a', userId: user.id, scopes: '["trips:read"]' });
testDb.exec("UPDATE oauth_tokens SET created_at = '2020-01-01 00:00:00' WHERE client_id = 'proto-22a'");
seedToken({ clientId: 'proto-22b', userId: user.id });
await seedToken({ clientId: 'proto-22b', userId: user.id });
testDb.exec("UPDATE oauth_tokens SET created_at = '2021-01-01 00:00:00' WHERE client_id = 'proto-22b'");
// revoked — excluded
seedToken({ clientId: 'proto-22a', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
await seedToken({ clientId: 'proto-22a', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
// expired refresh — excluded
seedToken({ clientId: 'proto-22a', userId: user.id, refreshExpiresAt: '2000-01-01 00:00:00' });
await seedToken({ clientId: 'proto-22a', userId: user.id, refreshExpiresAt: '2000-01-01 00:00:00' });
// another user's session — excluded
seedClient(other.id, 'proto-22c');
seedToken({ clientId: 'proto-22c', userId: other.id });
await seedClient(other.id, 'proto-22c');
await seedToken({ clientId: 'proto-22c', userId: other.id });
const rows = await tokens.listActiveByUser(user.id);
expect(rows.map((r) => r.client_name)).toEqual(['Client B', 'Client A']);
@@ -373,9 +390,9 @@ describe('OauthTokensRepository', () => {
it('OAUTHTOKREPO-022c: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-22null');
const id = seedToken({ clientId: 'proto-22null', userId: user.id });
testDb.prepare('UPDATE oauth_tokens SET created_at = NULL WHERE id = ?').run(id);
await seedClient(user.id, 'proto-22null');
const id = await seedToken({ clientId: 'proto-22null', userId: user.id });
await updateRows(t, OauthTokens, { id }, { created_at: null });
const rows = await tokens.listActiveByUser(user.id);
expect(rows[0].created_at).toBeNull();
});
@@ -384,16 +401,16 @@ describe('OauthTokensRepository', () => {
describe('findOwnedById (OA27)', () => {
it('OAUTHTOKREPO-023: matches id+user', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-23');
const id = seedToken({ clientId: 'proto-23', userId: user.id });
await seedClient(user.id, 'proto-23');
const id = await seedToken({ clientId: 'proto-23', userId: user.id });
expect(await tokens.findOwnedById(id, user.id)).toEqual({ id, client_id: 'proto-23' });
});
it('OAUTHTOKREPO-024: 404-shape null for another user\'s session', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
seedClient(other.id, 'proto-24');
const id = seedToken({ clientId: 'proto-24', userId: other.id });
await seedClient(other.id, 'proto-24');
const id = await seedToken({ clientId: 'proto-24', userId: other.id });
expect(await tokens.findOwnedById(id, user.id)).toBeNull();
});
});
@@ -402,10 +419,10 @@ describe('OauthTokensRepository', () => {
it('OAUTHTOKREPO-025: lists every active session across all users, with client name and username joined in', async () => {
const { user: a } = createUser(testDb, { username: 'admin-panel-a' });
const { user: b } = createUser(testDb, { username: 'admin-panel-b' });
seedClient(a.id, 'proto-25a', 'Client A');
seedClient(b.id, 'proto-25b', 'Client B');
seedToken({ clientId: 'proto-25a', userId: a.id });
seedToken({ clientId: 'proto-25b', userId: b.id, revokedAt: '2020-01-01 00:00:00' }); // excluded
await seedClient(a.id, 'proto-25a', 'Client A');
await seedClient(b.id, 'proto-25b', 'Client B');
await seedToken({ clientId: 'proto-25a', userId: a.id });
await seedToken({ clientId: 'proto-25b', userId: b.id, revokedAt: '2020-01-01 00:00:00' }); // excluded
const rows = await tokens.listAllActiveWithClientAndUser();
expect(rows).toHaveLength(1);
@@ -414,9 +431,9 @@ describe('OauthTokensRepository', () => {
it('OAUTHTOKREPO-025b: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => {
const { user } = createUser(testDb, { username: 'admin-panel-null' });
seedClient(user.id, 'proto-25null', 'Client Null');
const id = seedToken({ clientId: 'proto-25null', userId: user.id });
testDb.prepare('UPDATE oauth_tokens SET created_at = NULL WHERE id = ?').run(id);
await seedClient(user.id, 'proto-25null', 'Client Null');
const id = await seedToken({ clientId: 'proto-25null', userId: user.id });
await updateRows(t, OauthTokens, { id }, { created_at: null });
const rows = await tokens.listAllActiveWithClientAndUser();
expect(rows.find((r) => r.id === id)?.created_at).toBeNull();
});
@@ -425,8 +442,8 @@ describe('OauthTokensRepository', () => {
describe('findById (OA32)', () => {
it('OAUTHTOKREPO-026: unscoped by owner — for the admin 404 check', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-26');
const id = seedToken({ clientId: 'proto-26', userId: user.id });
await seedClient(user.id, 'proto-26');
const id = await seedToken({ clientId: 'proto-26', userId: user.id });
expect(await tokens.findById(id)).toEqual({ id, user_id: user.id, client_id: 'proto-26' });
});
@@ -439,20 +456,20 @@ describe('OauthTokensRepository', () => {
it('OAUTHTOKREPO-028: revokes every live session for the user, across every client, leaves other users untouched', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
seedClient(user.id, 'proto-28a');
seedClient(user.id, 'proto-28b');
seedClient(other.id, 'proto-28c');
const idA = seedToken({ clientId: 'proto-28a', userId: user.id });
const idB = seedToken({ clientId: 'proto-28b', userId: user.id });
const idAlready = seedToken({ clientId: 'proto-28a', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idOther = seedToken({ clientId: 'proto-28c', userId: other.id });
await seedClient(user.id, 'proto-28a');
await seedClient(user.id, 'proto-28b');
await seedClient(other.id, 'proto-28c');
const idA = await seedToken({ clientId: 'proto-28a', userId: user.id });
const idB = await seedToken({ clientId: 'proto-28b', userId: user.id });
const idAlready = await seedToken({ clientId: 'proto-28a', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idOther = await seedToken({ clientId: 'proto-28c', userId: other.id });
await tokens.revokeAllForUser(user.id);
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idA) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idB) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idAlready) as { revoked_at: string }).revoked_at).toBe('2020-01-01 00:00:00');
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idOther) as { revoked_at: string | null }).revoked_at).toBeNull();
expect((await revokedAtOf(idA))).not.toBeNull();
expect((await revokedAtOf(idB))).not.toBeNull();
expect((await revokedAtOf(idAlready))).toBe('2020-01-01 00:00:00');
expect((await revokedAtOf(idOther))).toBeNull();
});
});
@@ -463,10 +480,10 @@ describe('OauthTokensRepository', () => {
// `client_id` — this test additionally asserts the two literally
// differ, so a future fixture change can't silently make the
// distinction disappear and hide a regression.
seedClient(user.id, 'proto-30', 'Client Thirty');
const clientRow = testDb.prepare('SELECT id, client_id FROM oauth_clients WHERE client_id = ?').get('proto-30') as { id: string; client_id: string };
expect(clientRow.id).not.toBe(clientRow.client_id);
const id = seedToken({ clientId: 'proto-30', userId: user.id });
await seedClient(user.id, 'proto-30', 'Client Thirty');
const clientRow = await findRow(t, OauthClients, { client_id: 'proto-30' });
expect(clientRow?.id).not.toBe(clientRow?.client_id);
const id = await seedToken({ clientId: 'proto-30', userId: user.id });
const em = t.orm.em.fork();
const qb = em.getRepository(OauthTokens).qb('ot').innerJoin('ot.client', 'oc').select(['ot.id', 'oc.name as client_name']).where({ 'ot.id': id });
@@ -477,21 +494,26 @@ describe('OauthTokensRepository', () => {
it('OAUTHTOKREPO-031: a DECOY client whose row id equals the real client_id — regression-proofs the join against silently falling back to oauth_clients.id (task-4-review.md, "For Task 5 / interlude A")', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-31-real', 'Real Client');
const realClientRow = testDb
.prepare('SELECT id, client_id FROM oauth_clients WHERE client_id = ?')
.get('proto-31-real') as { id: string; client_id: string };
await seedClient(user.id, 'proto-31-real', 'Real Client');
const realClientRow = await findRow(t, OauthClients, { client_id: 'proto-31-real' });
if (!realClientRow) throw new Error('the real client should be seeded');
// The decoy's own PRIMARY KEY (`id`) is deliberately set to the REAL
// client's `client_id` — a wrong join (`oc.id = ot.client_id`, the pre-
// Rule-11 defect) would match THIS row instead of the real one, and
// return the decoy's name, not silently return nothing. A regression
// to the wrong column is caught by a WRONG answer, not just an absent
// one — the strongest form of this proof.
testDb.prepare(
`INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, '[]', '[]')`,
).run(realClientRow.client_id, user.id, 'Decoy Client', 'proto-31-decoy', 'hash');
await insertRow(t, OauthClients, {
id: realClientRow.client_id,
user: user.id,
name: 'Decoy Client',
client_id: 'proto-31-decoy',
client_secret_hash: 'hash',
redirect_uris: '[]',
allowed_scopes: '[]',
});
const id = seedToken({ clientId: 'proto-31-real', userId: user.id });
const id = await seedToken({ clientId: 'proto-31-real', userId: user.id });
const em = t.orm.em.fork();
const qb = em.getRepository(OauthTokens).qb('ot').innerJoin('ot.client', 'oc').select(['ot.id', 'oc.name as client_name']).where({ 'ot.id': id });
@@ -510,8 +532,8 @@ describe('OauthTokensRepository', () => {
describe('identity-map write-back regression (program RULING, D-shape)', () => {
it('OAUTHTOKREPO-029: findByRefreshTokenHash (projection A) then findParent (projection B) then revokeById inside uow.transactional — the write survives', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-29');
const id = seedToken({ clientId: 'proto-29', userId: user.id, refreshHash: 'refresh-29', scopes: '["kept-scope"]' });
await seedClient(user.id, 'proto-29');
const id = await seedToken({ clientId: 'proto-29', userId: user.id, refreshHash: 'refresh-29', scopes: '["kept-scope"]' });
await withRequestContext(t.orm, async () => {
// Projection A — a wide fields set (id, client, user, scopes, audience, refresh_token_expires_at, revoked_at, parentToken).
@@ -524,7 +546,7 @@ describe('OauthTokensRepository', () => {
});
});
const row = testDb.prepare('SELECT revoked_at, scopes FROM oauth_tokens WHERE id = ?').get(id) as { revoked_at: string | null; scopes: string };
const row = await tokenRow(id);
expect(row.revoked_at).not.toBeNull(); // the nativeUpdate must stick
expect(row.scopes).toBe('["kept-scope"]'); // untouched by either read
});
@@ -533,29 +555,29 @@ describe('OauthTokensRepository', () => {
describe('deleteExpiredBefore (retention)', () => {
const longAgo = '2026-01-01T00:00:00.000Z';
const cutoff = '2026-06-01T00:00:00.000Z';
const ids = () => (testDb.prepare('SELECT id FROM oauth_tokens ORDER BY id').all() as { id: number }[]).map((r) => r.id);
const ids = async () => (await findRows(t, OauthTokens, {}, { id: 'asc' })).map((r) => r.id);
it('OAUTHTOKREPO-040: deletes a whole expired chain, parents after their children', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'ret-1');
const root = seedToken({ clientId: 'ret-1', userId: user.id, refreshExpiresAt: longAgo, revokedAt: longAgo });
const mid = seedToken({ clientId: 'ret-1', userId: user.id, refreshExpiresAt: longAgo, revokedAt: longAgo, parentId: root });
seedToken({ clientId: 'ret-1', userId: user.id, refreshExpiresAt: longAgo, parentId: mid });
await seedClient(user.id, 'ret-1');
const root = await seedToken({ clientId: 'ret-1', userId: user.id, refreshExpiresAt: longAgo, revokedAt: longAgo });
const mid = await seedToken({ clientId: 'ret-1', userId: user.id, refreshExpiresAt: longAgo, revokedAt: longAgo, parentId: root });
await seedToken({ clientId: 'ret-1', userId: user.id, refreshExpiresAt: longAgo, parentId: mid });
expect(await withRequestContext(t.orm, () => tokens.deleteExpiredBefore(cutoff))).toBe(3);
expect(ids()).toEqual([]);
expect(await ids()).toEqual([]);
});
it('OAUTHTOKREPO-041: keeps an expired parent while a live token still names it', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'ret-2');
const root = seedToken({ clientId: 'ret-2', userId: user.id, refreshExpiresAt: longAgo, revokedAt: longAgo });
const live = seedToken({ clientId: 'ret-2', userId: user.id, parentId: root });
const unrelated = seedToken({ clientId: 'ret-2', userId: user.id, refreshExpiresAt: longAgo });
await seedClient(user.id, 'ret-2');
const root = await seedToken({ clientId: 'ret-2', userId: user.id, refreshExpiresAt: longAgo, revokedAt: longAgo });
const live = await seedToken({ clientId: 'ret-2', userId: user.id, parentId: root });
const unrelated = await seedToken({ clientId: 'ret-2', userId: user.id, refreshExpiresAt: longAgo });
expect(await withRequestContext(t.orm, () => tokens.deleteExpiredBefore(cutoff))).toBe(1);
expect(ids()).toEqual([root, live].sort((a, b) => a - b));
expect(ids()).not.toContain(unrelated);
expect(await ids()).toEqual([root, live].sort((a, b) => a - b));
expect(await ids()).not.toContain(unrelated);
});
});
});
+138 -114
View File
@@ -26,6 +26,16 @@ import { createUser, createTrip, createBudgetItem, createPlace, createReservatio
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { addBudgetItemMember, addBudgetItemPayer } from '../../helpers/factories/budget';
import { setAppSetting } from '../../helpers/factories/settings';
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';
beforeEach(() => {
resetTestDb(testDb);
@@ -37,8 +47,15 @@ beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
});
afterAll(() => {
let orm: TestOrm;
beforeAll(async () => {
orm = await createTestOrm(testDb);
});
afterAll(async () => {
vi.unstubAllGlobals();
await orm.close();
testDb.close();
});
@@ -60,32 +77,35 @@ function tripWithTwo() {
return { user, other, trip };
}
function memberRows(itemId: number) {
return testDb.prepare('SELECT user_id, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id')
.all(itemId) as { user_id: number; amount: number | null }[];
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,
}));
}
function itemRow(itemId: number) {
return testDb.prepare('SELECT total_price, currency, exchange_rate, persons, expense_date, place_id FROM budget_items WHERE id = ?')
.get(itemId) as {
total_price: number; currency: string | null; exchange_rate: number | null;
persons: number | null; expense_date: string | null; place_id: number | 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): number {
return (testDb.prepare('SELECT COUNT(*) AS count FROM budget_items WHERE trip_id = ?').get(tripId) as { count: number }).count;
function itemCount(tripId: number): Promise<number> {
return countRows(orm, BudgetItems, { trip: tripId });
}
function settlementRow(id: number) {
return testDb.prepare('SELECT amount, currency, exchange_rate FROM budget_settlements WHERE id = ?')
.get(id) as { amount: number; currency: string | null; exchange_rate: number | null };
/** 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): number {
return (testDb.prepare('SELECT COUNT(*) AS count FROM budget_settlements WHERE trip_id = ?').get(tripId) as { count: number }).count;
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. */
@@ -310,7 +330,7 @@ describe('Tool: delete_budget_item', () => {
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(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(item.id)).toBeUndefined();
expect(await findRow(orm, BudgetItems, { id: item.id })).toBeNull();
});
});
@@ -394,11 +414,11 @@ describe('Tool: create_budget_item (uneven split)', () => {
},
});
const data = parseToolResult(result) as any;
expect(memberRows(data.item.id)).toEqual([
expect(await memberRows(data.item.id)).toEqual([
{ user_id: user.id, amount: 70 },
{ user_id: other.id, amount: 30 },
]);
expect(itemRow(data.item.id).persons).toBe(2);
expect((await itemRow(data.item.id)).persons).toBe(2);
});
});
@@ -414,7 +434,7 @@ describe('Tool: create_budget_item (uneven split)', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('does not add up');
expect(itemCount(trip.id)).toBe(0);
expect(await itemCount(trip.id)).toBe(0);
});
});
@@ -430,8 +450,8 @@ describe('Tool: create_budget_item (uneven split)', () => {
},
});
const data = parseToolResult(result) as any;
expect(itemRow(data.item.id).total_price).toBe(90);
expect(memberRows(data.item.id).map(m => m.amount)).toEqual([60, 30]);
expect((await itemRow(data.item.id)).total_price).toBe(90);
expect((await memberRows(data.item.id)).map(m => m.amount)).toEqual([60, 30]);
});
});
@@ -447,7 +467,7 @@ describe('Tool: create_budget_item (uneven split)', () => {
},
});
expect(result.isError).toBe(true);
expect(itemCount(trip.id)).toBe(0);
expect(await itemCount(trip.id)).toBe(0);
});
});
@@ -464,7 +484,7 @@ describe('Tool: create_budget_item (uneven split)', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not on this trip');
expect(itemCount(trip.id)).toBe(0);
expect(await itemCount(trip.id)).toBe(0);
});
});
@@ -481,23 +501,22 @@ describe('Tool: create_budget_item (uneven split)', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not both');
expect(itemCount(trip.id)).toBe(0);
expect(await itemCount(trip.id)).toBe(0);
});
});
});
describe('Tool: update_budget_item (uneven split)', () => {
function itemWithEqualSplit(tripId: number, userIds: number[]) {
async function itemWithEqualSplit(tripId: number, userIds: number[]) {
const item = createBudgetItem(testDb, tripId, { name: 'Dinner', total_price: 100 });
const insert = testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
for (const id of userIds) insert.run(item.id, id);
testDb.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(userIds.length, item.id);
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 = itemWithEqualSplit(trip.id, [user.id, other.id]);
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',
@@ -507,17 +526,17 @@ describe('Tool: update_budget_item (uneven split)', () => {
},
});
expect(result.isError).toBeFalsy();
expect(memberRows(item.id)).toEqual([
expect(await memberRows(item.id)).toEqual([
{ user_id: user.id, amount: 40 },
{ user_id: other.id, amount: 60 },
]);
expect(itemRow(item.id).persons).toBe(2);
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 = itemWithEqualSplit(trip.id, [user.id, other.id]);
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',
@@ -528,7 +547,7 @@ describe('Tool: update_budget_item (uneven split)', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('100.00');
expect(memberRows(item.id)).toEqual([
expect(await memberRows(item.id)).toEqual([
{ user_id: user.id, amount: null },
{ user_id: other.id, amount: null },
]);
@@ -537,7 +556,7 @@ describe('Tool: update_budget_item (uneven split)', () => {
it('refuses the same member twice', async () => {
const { user, other, trip } = tripWithTwo();
const item = itemWithEqualSplit(trip.id, [user.id, other.id]);
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',
@@ -548,13 +567,13 @@ describe('Tool: update_budget_item (uneven split)', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('twice');
expect(memberRows(item.id).map(m => m.amount)).toEqual([null, null]);
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 = itemWithEqualSplit(trip.id, [user.id, other.id]);
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',
@@ -566,7 +585,7 @@ describe('Tool: update_budget_item (uneven split)', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not both');
expect(memberRows(item.id)).toHaveLength(2);
expect(await memberRows(item.id)).toHaveLength(2);
});
});
@@ -599,7 +618,7 @@ describe('Tool: update_budget_item (currency)', () => {
arguments: { tripId: trip.id, itemId: item.id, currency: 'USD' },
});
expect(result.isError).toBeFalsy();
const row = itemRow(item.id);
const row = await itemRow(item.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
});
@@ -609,14 +628,14 @@ describe('Tool: update_budget_item (currency)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
testDb.prepare('UPDATE budget_items SET currency = ?, exchange_rate = ? WHERE id = ?').run('USD', 1.1, item.id);
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(itemRow(item.id).currency).toBeNull();
expect((await itemRow(item.id)).currency).toBeNull();
});
});
@@ -646,7 +665,7 @@ describe('Tool: update_budget_item (currency)', () => {
arguments: { tripId: trip.id, itemId: item.id, currency: 'United States Dollars' },
});
expect(result.isError).toBe(true);
expect(itemRow(item.id).currency).toBeNull();
expect((await itemRow(item.id)).currency).toBeNull();
});
});
});
@@ -662,7 +681,7 @@ describe('Tool: update_budget_item (expense date)', () => {
arguments: { tripId: trip.id, itemId: item.id, expense_date: '2026-09-14' },
});
expect(result.isError).toBeFalsy();
expect(itemRow(item.id).expense_date).toBe('2026-09-14');
expect((await itemRow(item.id)).expense_date).toBe('2026-09-14');
});
});
@@ -670,14 +689,14 @@ describe('Tool: update_budget_item (expense date)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
testDb.prepare('UPDATE budget_items SET expense_date = ? WHERE id = ?').run('2026-09-14', item.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(itemRow(item.id).expense_date).toBeNull();
expect((await itemRow(item.id)).expense_date).toBeNull();
});
});
@@ -685,14 +704,14 @@ describe('Tool: update_budget_item (expense date)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
testDb.prepare('UPDATE budget_items SET expense_date = ? WHERE id = ?').run('2026-09-14', item.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(itemRow(item.id).expense_date).toBe('2026-09-14');
expect((await itemRow(item.id)).expense_date).toBe('2026-09-14');
});
});
});
@@ -712,7 +731,7 @@ describe('Budget tools: place link', () => {
arguments: { tripId: trip.id, name: 'Louvre tickets', total_price: 34, place_id: place.id },
});
const data = parseToolResult(result) as any;
expect(itemRow(data.item.id).place_id).toBe(place.id);
expect((await itemRow(data.item.id)).place_id).toBe(place.id);
});
});
@@ -728,7 +747,7 @@ describe('Budget tools: place link', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('place_id does not belong to this trip.');
expect(itemCount(trip.id)).toBe(0);
expect(await itemCount(trip.id)).toBe(0);
});
});
@@ -741,7 +760,7 @@ describe('Budget tools: place link', () => {
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(itemRow(data.item.id).place_id).toBe(place.id);
expect((await itemRow(data.item.id)).place_id).toBe(place.id);
});
});
@@ -756,7 +775,7 @@ describe('Budget tools: place link', () => {
});
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('place_id does not belong to this trip.');
expect(itemCount(trip.id)).toBe(0);
expect(await itemCount(trip.id)).toBe(0);
});
});
});
@@ -767,13 +786,14 @@ describe('Budget tools: place link', () => {
// ---------------------------------------------------------------------------
describe('Tool: update_budget_item (links)', () => {
function links(itemId: number) {
return testDb.prepare('SELECT reservation_id, place_id FROM budget_items WHERE id = ?')
.get(itemId) as { reservation_id: number | null; place_id: number | null };
async function links(itemId: number) {
const row = await itemRow(itemId);
return { reservation_id: row.reservation_id ?? null, place_id: row.place_id ?? null };
}
function priceOf(reservationId: number): Record<string, unknown> {
const row = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string | 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) : {};
}
@@ -790,11 +810,11 @@ describe('Tool: update_budget_item (links)', () => {
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(priceOf(booking.id)).toEqual({ price: '180' });
expect(await priceOf(booking.id)).toEqual({ price: '180' });
await update(h, { tripId: trip.id, itemId: luggage.id, reservation_id: booking.id });
expect(links(luggage.id).reservation_id).toBe(booking.id);
expect(priceOf(booking.id)).toEqual({ price: '215.5' });
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 }) }));
@@ -812,9 +832,9 @@ describe('Tool: update_budget_item (links)', () => {
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('reservation_id does not belong to this trip.');
});
expect(links(item.id)).toEqual({ reservation_id: null, place_id: null });
expect(testDb.prepare('SELECT name FROM budget_items WHERE id = ?').get(item.id)).toEqual({ name: 'Fare' });
expect(priceOf(foreign.id)).toEqual({});
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();
});
@@ -829,7 +849,7 @@ describe('Tool: update_budget_item (links)', () => {
expect(result.isError).toBe(true);
expect(errorText(result)).toBe('place_id does not belong to this trip.');
});
expect(links(item.id).place_id).toBeNull();
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 () => {
@@ -839,14 +859,14 @@ describe('Tool: update_budget_item (links)', () => {
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(links(item.id).place_id).toBe(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(links(item.id).place_id).toBe(place.id);
expect((await links(item.id)).place_id).toBe(place.id);
await update(h, { tripId: trip.id, itemId: item.id, place_id: null });
});
expect(links(item.id).place_id).toBeNull();
expect(itemRow(item.id).total_price).toBe(34);
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 () => {
@@ -858,14 +878,14 @@ describe('Tool: update_budget_item (links)', () => {
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(priceOf(booking.id)).toEqual({ price: '120' });
expect(await priceOf(booking.id)).toEqual({ price: '120' });
await update(h, { tripId: trip.id, itemId: extra.id, reservation_id: null });
expect(priceOf(booking.id)).toEqual({ price: '100' });
expect(await priceOf(booking.id)).toEqual({ price: '100' });
await update(h, { tripId: trip.id, itemId: fare.id, reservation_id: null });
expect(priceOf(booking.id)).toEqual({});
expect(await priceOf(booking.id)).toEqual({});
});
expect(itemCount(trip.id)).toBe(2);
expect(await itemCount(trip.id)).toBe(2);
});
it('moving an expense to another booking resyncs both', async () => {
@@ -878,8 +898,8 @@ describe('Tool: update_budget_item (links)', () => {
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(priceOf(outbound.id)).toEqual({});
expect(priceOf(inbound.id)).toEqual({ price: '75' });
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 () => {
@@ -887,11 +907,11 @@ describe('Tool: update_budget_item (links)', () => {
const trip = createTrip(testDb, user.id);
const booking = createReservation(testDb, trip.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 50 });
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(booking.id, item.id);
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(priceOf(booking.id)).toEqual({ 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 () => {
@@ -899,13 +919,13 @@ describe('Tool: update_budget_item (links)', () => {
const trip = createTrip(testDb, user.id);
const booking = createReservation(testDb, trip.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 50 });
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(booking.id, item.id);
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(priceOf(booking.id)).toEqual({ price: '50', priceCurrency: 'USD' });
expect(await priceOf(booking.id)).toEqual({ price: '50', priceCurrency: 'USD' });
await update(h, { tripId: trip.id, itemId: item.id, currency: null });
expect(priceOf(booking.id)).toEqual({ price: '50' });
expect(await priceOf(booking.id)).toEqual({ price: '50' });
});
});
});
@@ -924,7 +944,7 @@ describe('Tool: create_settlement (currency)', () => {
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 = settlementRow(data.settlement.id);
const row = await settlementRow(data.settlement.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
expect(row.amount).toBe(20);
@@ -939,7 +959,7 @@ describe('Tool: create_settlement (currency)', () => {
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 20 },
});
const data = parseToolResult(result) as any;
const row = settlementRow(data.settlement.id);
const row = await settlementRow(data.settlement.id);
expect(row.currency).toBeNull();
expect(row.exchange_rate).toBe(1);
});
@@ -947,14 +967,14 @@ describe('Tool: create_settlement (currency)', () => {
it('refuses a currency that is not a code', async () => {
const { user, other, trip } = tripWithTwo();
const before = settlementCount(trip.id);
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(settlementCount(trip.id)).toBe(before);
expect(await settlementCount(trip.id)).toBe(before);
});
});
});
@@ -973,7 +993,7 @@ describe('Tool: update_settlement (currency)', () => {
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 = settlementRow(created.settlement.id);
const row = await settlementRow(created.settlement.id);
expect(row.currency).toBe('USD');
expect(row.exchange_rate).toBe(1.1);
});
@@ -992,7 +1012,7 @@ describe('Tool: update_settlement (currency)', () => {
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(settlementRow(created.settlement.id).currency).toBeNull();
expect((await settlementRow(created.settlement.id)).currency).toBeNull();
});
});
});
@@ -1023,7 +1043,7 @@ describe('Budget tools: a custom split cannot be certified against a total the r
});
expect(result.isError).toBe(true);
expect(errorText(result)).toContain('not on this trip');
expect(testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 });
expect(await itemCount(trip.id)).toBe(0);
});
});
@@ -1050,7 +1070,7 @@ describe('Budget tools: a custom split cannot be certified against a total the r
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 = testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(item.id) as any;
const row = await itemRow(item.id);
expect(row.total_price).toBe(100);
});
});
@@ -1070,7 +1090,7 @@ describe('Budget tools: a custom split cannot be certified against a total the r
arguments: { tripId: trip.id, itemId: item.id, payers: [], members: [] },
});
expect(result.isError).toBeFalsy();
const row = testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(item.id) as any;
const row = await itemRow(item.id);
expect(row.total_price).toBe(0);
});
});
@@ -1092,9 +1112,9 @@ describe('Budget tools: a custom split cannot be certified against a total the r
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
// total_price came from the payers, not from the stated 999.
const row = testDb.prepare('SELECT total_price FROM budget_items WHERE id = ?').get(data.item.id) as any;
const row = await itemRow(data.item.id);
expect(row.total_price).toBe(100);
const shares = testDb.prepare('SELECT user_id, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id').all(data.item.id) as any[];
const shares = await memberRows(data.item.id);
expect(shares.map(s => s.amount)).toEqual([60, 40]);
});
});
@@ -1109,25 +1129,29 @@ describe('Budget tools: a custom split cannot be certified against a total the r
// ---------------------------------------------------------------------------
/** A trip in `currency` with one unfrozen VND bill and one unfrozen VND transfer. */
function tripWithVndRows(currency: string) {
async function tripWithVndRows(currency: string) {
const { user, other, trip } = tripWithTwo();
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run(currency, trip.id);
testDb.prepare('DELETE FROM budget_settlements WHERE trip_id = ?').run(trip.id);
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 });
testDb.prepare("UPDATE budget_items SET currency = 'VND', exchange_rate = 1 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, 8920000);
const settlementId = Number(testDb.prepare(
"INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate) VALUES (?, ?, ?, ?, 'VND', 1)",
).run(trip.id, other.id, user.id, 100000).lastInsertRowid);
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 } = tripWithVndRows('AUD');
const { other, trip, item, settlementId } = await tripWithVndRows('AUD');
const { user: stranger } = createUser(testDb);
const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates');
try {
@@ -1137,13 +1161,13 @@ describe('Tool: freeze_budget_rates', () => {
});
// budget_edit lowered to the owner, the way the admin permission panel does it.
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_budget_edit', 'trip_owner');
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);
});
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run();
await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' });
await invalidatePermissionsCache();
process.env.DEMO_MODE = 'true';
@@ -1155,17 +1179,17 @@ describe('Tool: freeze_budget_rates', () => {
});
expect(spy).not.toHaveBeenCalled();
expect(itemRow(item.id).exchange_rate).toBe(1);
expect(settlementRow(settlementId).exchange_rate).toBe(1);
expect((await itemRow(item.id)).exchange_rate).toBe(1);
expect((await settlementRow(settlementId)).exchange_rate).toBe(1);
} finally {
spy.mockRestore();
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run();
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 } = tripWithVndRows('AUD');
const { user, trip, item, settlementId } = await tripWithVndRows('AUD');
stubRates({ VND: 18241.3 });
const spy = vi.spyOn(BudgetService.prototype, 'freezeMissingRates');
try {
@@ -1178,8 +1202,8 @@ describe('Tool: freeze_budget_rates', () => {
expect(data.items.map(i => i.id)).toEqual([item.id]);
expect(data.settlements.map(s => s.id)).toEqual([settlementId]);
expect(data.unresolved).toEqual([]);
expect(itemRow(item.id).exchange_rate).toBe(18241.3);
expect(settlementRow(settlementId).exchange_rate).toBe(18241.3);
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 }) }));
@@ -1193,7 +1217,7 @@ describe('Tool: freeze_budget_rates', () => {
});
it('answers with an error and broadcasts nothing when the trip currency changed during the fetch', async () => {
const { user, trip, item, settlementId } = tripWithVndRows('AUD');
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 {
@@ -1204,8 +1228,8 @@ describe('Tool: freeze_budget_rates', () => {
});
expect(spy).toHaveBeenCalledTimes(1);
expect(broadcastMock).not.toHaveBeenCalled();
expect(itemRow(item.id).exchange_rate).toBe(1);
expect(settlementRow(settlementId).exchange_rate).toBe(1);
expect((await itemRow(item.id)).exchange_rate).toBe(1);
expect((await settlementRow(settlementId)).exchange_rate).toBe(1);
} finally {
spy.mockRestore();
}
@@ -1214,7 +1238,7 @@ describe('Tool: freeze_budget_rates', () => {
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 } = tripWithVndRows('NZD');
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 {