diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 4beb77a50..ed22160af 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -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) => request(server).put(`/api/trips/${tripId}/budget/${id}`).set('Cookie', sessionCookie(1)).send(body); const create = (body: Record) => 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 { + 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 () => { diff --git a/server/tests/e2e/collab.e2e.test.ts b/server/tests/e2e/collab.e2e.test.ts index b2364d0cf..8d9d7d0ae 100644 --- a/server/tests/e2e/collab.e2e.test.ts +++ b/server/tests/e2e/collab.e2e.test.ts @@ -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); }); diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index 109c772ff..49de77945 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -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); }); diff --git a/server/tests/unit/db/repositories/OauthTokens.repository.test.ts b/server/tests/unit/db/repositories/OauthTokens.repository.test.ts index 90c9f1a9b..3363d2253 100644 --- a/server/tests/unit/db/repositories/OauthTokens.repository.test.ts +++ b/server/tests/unit/db/repositories/OauthTokens.repository.test.ts @@ -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 { + 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 { + 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; + 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); }); }); }); diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts index 5c7a6fd5f..5830e790b 100644 --- a/server/tests/unit/mcp/tools-budget.test.ts +++ b/server/tests/unit/mcp/tools-budget.test.ts @@ -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 { + 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 { + 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 { - const row = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string | null }; + async function priceOf(reservationId: number): Promise> { + 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 {