diff --git a/server/tests/integration/immich.test.ts b/server/tests/integration/immich.test.ts index ddca46ede..60802d4a3 100644 --- a/server/tests/integration/immich.test.ts +++ b/server/tests/integration/immich.test.ts @@ -50,17 +50,28 @@ vi.mock('../../src/utils/ssrfGuard', async () => { }); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { findRow, findRows, updateRows } from '../helpers/factories/rows'; +import { makeTrip } from '../helpers/factories/trips'; +import { addAlbumLink, addTripPhoto } from '../helpers/factories/photos'; +import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity'; +import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity'; +import { TripAlbumLinks } from '../../src/db/entities/TripAlbumLinks.entity'; +import { TripPhotos } from '../../src/db/entities/TripPhotos.entity'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -72,7 +83,7 @@ beforeEach(async () => { // configure it before it's usable in production); the legacy test helper always // seeded it enabled, which is what these tests assume. Same convention // memories-synology.test.ts already uses for its own provider. - testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'immich'").run(); + await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 1 }); }); afterAll(async () => { @@ -141,7 +152,7 @@ describe('Immich authentication', () => { describe('Immich album links', () => { it('IMMICH-020 — POST album-links creates a link', async () => { const { user } = createUser(testDb); - const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any; + const trip = await makeTrip(orm, user.id, { title: 'Test Trip' }); const res = await request(app) .post(`/api/integrations/memories/unified/trips/${trip.id}/album-links`) @@ -151,16 +162,16 @@ describe('Immich album links', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const link = testDb.prepare('SELECT * FROM trip_album_links WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as any; - expect(link).toBeDefined(); - expect(link.album_id).toBe('album-uuid-123'); - expect(link.album_name).toBe('Vacation 2024'); + const link = await findRow(orm, TripAlbumLinks, { trip: trip.id, user: user.id }); + expect(link).not.toBeNull(); + expect(link?.album_id).toBe('album-uuid-123'); + expect(link?.album_name).toBe('Vacation 2024'); }); it('IMMICH-021 — GET album-links returns linked albums', async () => { const { user } = createUser(testDb); - const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any; - testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?)').run(trip.id, user.id, 'album-abc', 'My Album', 'immich'); + const trip = await makeTrip(orm, user.id, { title: 'Test Trip' }); + await addAlbumLink(orm, trip.id, user.id, 'immich', 'album-abc', 'My Album'); const res = await request(app) .get(`/api/integrations/memories/unified/trips/${trip.id}/album-links`) @@ -174,23 +185,18 @@ describe('Immich album links', () => { it('IMMICH-022 — DELETE album-links removes associated photos but not individually-added ones', async () => { const { user } = createUser(testDb); - const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any; + const trip = await makeTrip(orm, user.id, { title: 'Test Trip' }); // Create album link - const linkResult = testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *') - .get(trip.id, user.id, 'album-xyz', 'Album XYZ', 'immich') as any; + const linkResult = await addAlbumLink(orm, trip.id, user.id, 'immich', 'album-xyz', 'Album XYZ'); // Insert photos synced from the album for (const assetId of ['asset-001', 'asset-002']) { - testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', assetId, user.id); - const tkp = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', assetId, user.id) as any; - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, user.id, tkp.id, linkResult.id); + await addTripPhoto(orm, trip.id, user.id, assetId, 'immich', { shared: true, albumLinkId: linkResult.id }); } // Insert an individually-added photo (no album_link_id) - testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-manual', user.id); - const tkpManual = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-manual', user.id) as any; - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, tkpManual.id); + await addTripPhoto(orm, trip.id, user.id, 'asset-manual', 'immich', { shared: true }); const res = await request(app) .delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`) @@ -200,29 +206,26 @@ describe('Immich album links', () => { expect(res.body.success).toBe(true); // Album-linked photos should be gone - const remainingPhotos = testDb.prepare(` - SELECT tp.*, tkp.asset_id FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.trip_id = ? - `).all(trip.id) as any[]; + const remainingPhotos = []; + for (const tp of await findRows(orm, TripPhotos, { trip: trip.id })) { + const tkp = await findRow(orm, TrekPhotos, { id: tp.photo_id }); + if (tkp) remainingPhotos.push({ ...tp, asset_id: tkp.asset_id }); + } expect(remainingPhotos.length).toBe(1); expect(remainingPhotos[0].asset_id).toBe('asset-manual'); // Album link itself should be gone - const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id); - expect(link).toBeUndefined(); + const link = await findRow(orm, TripAlbumLinks, { id: linkResult.id }); + expect(link).toBeNull(); }); it('IMMICH-023 — DELETE album-link by non-member returns 404', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); - const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(owner.id, 'Test Trip') as any; + const trip = await makeTrip(orm, owner.id, { title: 'Test Trip' }); - const linkResult = testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *') - .get(trip.id, owner.id, 'album-secret', 'Secret Album', 'immich') as any; - testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-owned', owner.id); - const tkpOwned = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-owned', owner.id) as any; - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, owner.id, tkpOwned.id, linkResult.id); + const linkResult = await addAlbumLink(orm, trip.id, owner.id, 'immich', 'album-secret', 'Secret Album'); + await addTripPhoto(orm, trip.id, owner.id, 'asset-owned', 'immich', { shared: true, albumLinkId: linkResult.id }); // Non-member tries to delete owner's album link — should be denied const res = await request(app) @@ -232,14 +235,11 @@ describe('Immich album links', () => { expect(res.status).toBe(404); // Link and photos should still exist - const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id); - expect(link).toBeDefined(); - const photo = testDb.prepare(` - SELECT tp.* FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tkp.asset_id = ? - `).get('asset-owned'); - expect(photo).toBeDefined(); + const link = await findRow(orm, TripAlbumLinks, { id: linkResult.id }); + expect(link).not.toBeNull(); + const owned = await findRow(orm, TrekPhotos, { asset_id: 'asset-owned' }); + const photo = owned ? await findRow(orm, TripPhotos, { photo: owned.id }) : null; + expect(photo).not.toBeNull(); }); it('IMMICH-024 — DELETE album-link without auth returns 401', async () => { diff --git a/server/tests/unit/db/repositories/DemoRepository.test.ts b/server/tests/unit/db/repositories/DemoRepository.test.ts index acd3e3181..6b7a4b455 100644 --- a/server/tests/unit/db/repositories/DemoRepository.test.ts +++ b/server/tests/unit/db/repositories/DemoRepository.test.ts @@ -4,6 +4,27 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, createAdmin } from '../../../helpers/factories'; import { DemoRepository, type NewDemoPlaceRow } from '../../../../src/db/repositories/DemoRepository'; +import { findRow, findRows, insertRows, updateRows } from '../../../helpers/factories/rows'; +import { readAppSetting } from '../../../helpers/factories/settings'; +import { readUser } from '../../../helpers/factories/users'; +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; +import { BudgetItems } from '../../../../src/db/entities/BudgetItems.entity'; +import { DayAssignments } from '../../../../src/db/entities/DayAssignments.entity'; +import { DayNotes } from '../../../../src/db/entities/DayNotes.entity'; +import { Days } from '../../../../src/db/entities/Days.entity'; +import { PackingItems } from '../../../../src/db/entities/PackingItems.entity'; +import { Places } from '../../../../src/db/entities/Places.entity'; +import { Reservations } from '../../../../src/db/entities/Reservations.entity'; +import { TripMembers } from '../../../../src/db/entities/TripMembers.entity'; +import { Users } from '../../../../src/db/entities/Users.entity'; + +/** The given columns of a row, the shape a narrow SELECT returned. */ +function pick(row: T | null, keys: K[]): Pick | null { + if (!row) return null; + const out = {} as Pick; + for (const key of keys) out[key] = row[key]; + return out; +} const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -25,20 +46,16 @@ describe('DemoRepository', () => { it('DEMOREPO-002: createUser — DMS2, leaves first_seen_version/login_count/oidc/avatar to the entity default', async () => { const id = await demo.createUser({ username: 'demo', email: 'demo@trek.app', password_hash: 'x', role: 'user' }); - const row = testDb.prepare('SELECT username, email, role, first_seen_version, login_count FROM users WHERE id = ?').get(id) as { - username: string; email: string; role: string; first_seen_version: string; login_count: number; - }; + const row = pick(await readUser(t, id), ['username', 'email', 'role', 'first_seen_version', 'login_count']); expect(row).toEqual({ username: 'demo', email: 'demo@trek.app', role: 'user', first_seen_version: '0.0.0', login_count: 0 }); }); it('DEMOREPO-003: setAllowRegistrationFalse — DMS3', async () => { await demo.setAllowRegistrationFalse(); - const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string }; - expect(row.value).toBe('false'); + expect(await readAppSetting(t, 'allow_registration')).toBe('false'); // Idempotent — a second call still lands 'false' (INSERT OR REPLACE dialect). await demo.setAllowRegistrationFalse(); - const row2 = testDb.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string }; - expect(row2.value).toBe('false'); + expect(await readAppSetting(t, 'allow_registration')).toBe('false'); }); it('DEMOREPO-004: countTripsByUser / listTripIdsByUser — DMS4/DMS5', async () => { @@ -56,7 +73,7 @@ describe('DemoRepository', () => { const tripId = await demo.insertTrip(owner.id, 'A', 'desc', '2026-01-01', '2026-01-02', 'EUR'); await demo.addTripMember(tripId, member.id, owner.id); await demo.addTripMember(tripId, member.id, owner.id); // no UNIQUE-constraint throw - const rows = testDb.prepare('SELECT trip_id, user_id, invited_by FROM trip_members WHERE trip_id = ?').all(tripId); + const rows = (await findRows(t, TripMembers, { trip: tripId })).map((r) => pick(r, ['trip_id', 'user_id', 'invited_by'])); expect(rows).toEqual([{ trip_id: tripId, user_id: member.id, invited_by: owner.id }]); }); @@ -64,30 +81,32 @@ describe('DemoRepository', () => { const { user } = createAdmin(testDb); const tripId = await demo.insertTrip(user.id, 'Test Trip', 'd', '2026-04-15', '2026-04-16', 'JPY'); const dayId = await demo.insertDay(tripId, 1, '2026-04-15'); - expect(testDb.prepare('SELECT trip_id, day_number, date FROM days WHERE id = ?').get(dayId)).toEqual({ trip_id: tripId, day_number: 1, date: '2026-04-15' }); + expect(pick(await findRow(t, Days, { id: dayId }), ['trip_id', 'day_number', 'date'])).toEqual({ trip_id: tripId, day_number: 1, date: '2026-04-15' }); const place: NewDemoPlaceRow = [tripId, 'Hotel X', 35.1, 139.1, 'Addr', 1, '15:00', 60, 'notes', null, 'gpid', null, null]; const placeId = await demo.insertPlace(place); - const placeRow = testDb.prepare('SELECT trip_id, name, category_id FROM places WHERE id = ?').get(placeId) as { trip_id: number; name: string; category_id: number }; + const placeRow = pick(await findRow(t, Places, { id: placeId }), ['trip_id', 'name', 'category_id']); expect(placeRow).toEqual({ trip_id: tripId, name: 'Hotel X', category_id: 1 }); await demo.insertDayAssignment(dayId, placeId, 0); - expect(testDb.prepare('SELECT day_id, place_id, order_index FROM day_assignments WHERE day_id = ?').get(dayId)).toEqual({ day_id: dayId, place_id: placeId, order_index: 0 }); + expect(pick(await findRow(t, DayAssignments, { day: dayId }), ['day_id', 'place_id', 'order_index'])).toEqual({ day_id: dayId, place_id: placeId, order_index: 0 }); await demo.insertPackingItem(tripId, 'Passport', 1, 'Documents', 0); - const packing = testDb.prepare('SELECT trip_id, name, checked, category, sort_order FROM packing_items WHERE trip_id = ?').get(tripId); + const packing = pick(await findRow(t, PackingItems, { trip: tripId }), ['trip_id', 'name', 'checked', 'category', 'sort_order']); expect(packing).toEqual({ trip_id: tripId, name: 'Passport', checked: 1, category: 'Documents', sort_order: 0 }); await demo.insertBudgetItem(tripId, 'Food', 'Lunch', 1000, 2, null); - const budget = testDb.prepare('SELECT trip_id, category, name, total_price, persons, note FROM budget_items WHERE trip_id = ?').get(tripId); + const budget = pick(await findRow(t, BudgetItems, { trip: tripId }), ['trip_id', 'category', 'name', 'total_price', 'persons', 'note']); expect(budget).toEqual({ trip_id: tripId, category: 'Food', name: 'Lunch', total_price: 1000, persons: 2, note: null }); await demo.insertReservation(tripId, dayId, 'Check-in', '2026-04-15T15:00', 'CONF-1', 'confirmed', 'hotel', 'Tokyo'); - const reservation = testDb.prepare('SELECT trip_id, day_id, title, reservation_time, confirmation_number, status, type, location FROM reservations WHERE trip_id = ?').get(tripId); + const reservation = pick(await findRow(t, Reservations, { trip: tripId }), [ + 'trip_id', 'day_id', 'title', 'reservation_time', 'confirmation_number', 'status', 'type', 'location', + ]); expect(reservation).toEqual({ trip_id: tripId, day_id: dayId, title: 'Check-in', reservation_time: '2026-04-15T15:00', confirmation_number: 'CONF-1', status: 'confirmed', type: 'hotel', location: 'Tokyo' }); await demo.insertDayNote(dayId, tripId, 'Note text', '13:00', 'Info', 0.5); - const note = testDb.prepare('SELECT day_id, trip_id, text, time, icon, sort_order FROM day_notes WHERE day_id = ?').get(dayId); + const note = pick(await findRow(t, DayNotes, { day: dayId }), ['day_id', 'trip_id', 'text', 'time', 'icon', 'sort_order']); expect(note).toEqual({ day_id: dayId, trip_id: tripId, text: 'Note text', time: '13:00', icon: 'Info', sort_order: 0.5 }); }); @@ -109,8 +128,9 @@ describe('DemoRepository', () => { it('DEMOREPO-CRED-001 (DMR1/DMR5 round trip): getAdminCredentials reads exactly what restoreAdminCredentials wrote', async () => { const { user } = createAdmin(testDb, { email: 'admin@nomad.app' }); - testDb.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE id = ?') - .run('maps-key', 'ow-key', 'un-key', 'avatar.png', user.id); + await updateRows(t, Users, { id: user.id }, { + maps_api_key: 'maps-key', openweather_api_key: 'ow-key', unsplash_api_key: 'un-key', avatar: 'avatar.png', + }); const before = await demo.getAdminCredentials('admin@nomad.app'); expect(before).toEqual({ @@ -122,28 +142,31 @@ describe('DemoRepository', () => { }); // Simulate a baseline restore clobbering the row, then restore the preserved values. - testDb.prepare('UPDATE users SET password_hash = ?, maps_api_key = NULL, openweather_api_key = NULL, unsplash_api_key = NULL, avatar = NULL WHERE id = ?') - .run('stale-hash-from-baseline', user.id); + await updateRows(t, Users, { id: user.id }, { + password_hash: 'stale-hash-from-baseline', maps_api_key: null, openweather_api_key: null, unsplash_api_key: null, avatar: null, + }); await demo.restoreAdminCredentials('admin@nomad.app', before!); - const after = testDb.prepare('SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE id = ?').get(user.id); + const after = pick(await readUser(t, user.id), ['password_hash', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'avatar']); expect(after).toEqual(before); }); it('DEMOREPO-CRED-002 (DMR5): writes exactly the 5 named columns, no updated_at bump', async () => { const { user } = createAdmin(testDb, { email: 'admin@nomad.app' }); - const before = testDb.prepare('SELECT updated_at FROM users WHERE id = ?').get(user.id) as { updated_at: string | null }; + const before = await readUser(t, user.id); await demo.restoreAdminCredentials('admin@nomad.app', { password_hash: 'h', maps_api_key: 'm', openweather_api_key: 'o', unsplash_api_key: 'u', avatar: 'a', }); - const after = testDb.prepare('SELECT updated_at FROM users WHERE id = ?').get(user.id) as { updated_at: string | null }; + const after = await readUser(t, user.id); expect(after.updated_at).toBe(before.updated_at); }); it('DEMOREPO-KEYS-001 (DMR2/DMR6 round trip): getInstanceApiKeys preserves a NULL value, restoreInstanceApiKeys writes it back as NULL', async () => { - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'a-maps-key')").run(); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('unsplash_api_key', NULL)").run(); + await insertRows(t, AppSettings, [ + { key: 'maps_api_key', value: 'a-maps-key' }, + { key: 'unsplash_api_key', value: null }, + ]); const rows = await demo.getInstanceApiKeys(); expect(rows.sort((a, b) => a.key.localeCompare(b.key))).toEqual([ @@ -152,10 +175,11 @@ describe('DemoRepository', () => { ]); // Clobber, then restore from what was read. - testDb.prepare("UPDATE app_settings SET value = 'clobbered' WHERE key = 'maps_api_key'").run(); + await updateRows(t, AppSettings, { key: 'maps_api_key' }, { value: 'clobbered' }); await demo.restoreInstanceApiKeys(rows); - const after = testDb.prepare("SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key') ORDER BY key").all(); + const after = (await findRows(t, AppSettings, { key: { $in: ['maps_api_key', 'unsplash_api_key'] } }, { key: 'asc' })) + .map((r) => pick(r, ['key', 'value'])); expect(after).toEqual([ { key: 'maps_api_key', value: 'a-maps-key' }, { key: 'unsplash_api_key', value: null }, @@ -164,7 +188,6 @@ describe('DemoRepository', () => { it('DEMOREPO-KEYS-002: restoreInstanceApiKeys upserts a row that did not previously exist', async () => { await demo.restoreInstanceApiKeys([{ key: 'maps_api_key', value: 'brand-new' }]); - const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'maps_api_key'").get() as { value: string }; - expect(row.value).toBe('brand-new'); + expect(await readAppSetting(t, 'maps_api_key')).toBe('brand-new'); }); }); diff --git a/server/tests/unit/mcp/tools-transports.test.ts b/server/tests/unit/mcp/tools-transports.test.ts index 6f770d1a7..47ef8f495 100644 --- a/server/tests/unit/mcp/tools-transports.test.ts +++ b/server/tests/unit/mcp/tools-transports.test.ts @@ -24,6 +24,18 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { makeDay } from '../../helpers/factories/trips'; +import { makeBudgetItem } from '../../helpers/factories/budget'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; + +/** Overwrites the booking's stored metadata text, the way an import or a legacy row leaves it. */ +async function setMetadata(metadata: string, reservationId: number): Promise { + await updateRows(orm, Reservations, { id: reservationId }, { metadata }); +} beforeEach(() => { resetTestDb(testDb); @@ -31,7 +43,14 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -53,9 +72,15 @@ const stopoverEndpoints = [ ]; const errorText = (result: unknown) => (result as { content: { text: string }[] }).content[0].text; -const storedMetadata = (reservationId: number) => JSON.parse( - (testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string }).metadata -); +const storedMetadata = async (reservationId: number) => + JSON.parse(String((await findRow(orm, Reservations, { id: reservationId }))?.metadata)); + +/** The ids of the booking's stored endpoints, in sequence order. */ +const endpointIds = async (reservationId: number) => + (await findRows(orm, ReservationEndpoints, { reservation: reservationId }, { sequence: 'asc' })).map((r) => r.id); + +/** A second trip's day, which the tools must refuse as a foreign day. */ +const foreignDayOn = async (tripId: number, date: string) => (await makeDay(orm, tripId, { date, day_number: 1 })).id; describe('Tool: create_transport', () => { it('backfills lat/lng/timezone for code-only flight endpoints', async () => { @@ -74,7 +99,7 @@ describe('Tool: create_transport', () => { expect(typeof from.lng).toBe('number'); expect(from.timezone).toBe('Europe/Zurich'); // persisted NOT NULL columns are populated - const rows = testDb.prepare('SELECT lat, lng FROM reservation_endpoints WHERE reservation_id = ?').all(data.reservation.id) as any[]; + const rows = await findRows(orm, ReservationEndpoints, { reservation: data.reservation.id }); expect(rows.every(r => r.lat != null && r.lng != null)).toBe(true); }); }); @@ -250,7 +275,7 @@ describe('Transport tools: access and validation', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const otherTrip = createTrip(testDb, user.id); - const foreignDay = Number(testDb.prepare("INSERT INTO days (trip_id, date, day_number) VALUES (?, '2026-07-01', 1)").run(otherTrip.id).lastInsertRowid); + const foreignDay = await foreignDayOn(otherTrip.id, '2026-07-01'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_transport', @@ -265,7 +290,7 @@ describe('Transport tools: access and validation', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const otherTrip = createTrip(testDb, user.id); - const foreignDay = Number(testDb.prepare("INSERT INTO days (trip_id, date, day_number) VALUES (?, '2026-07-02', 1)").run(otherTrip.id).lastInsertRowid); + const foreignDay = await foreignDayOn(otherTrip.id, '2026-07-02'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_transport', @@ -288,7 +313,7 @@ describe('Transport tools: the price link', () => { }); const data = parseToolResult(result) as { reservation: { id: number } }; - const item = testDb.prepare('SELECT name, category, total_price FROM budget_items WHERE reservation_id = ?').get(data.reservation.id) as { name: string; category: string; total_price: number }; + const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id }); expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'Flights', total_price: 240 }); // The price also rides along in the reservation metadata, as the REST path does. expect(broadcastMock.mock.calls.some(c => c[1] === 'budget:created')).toBe(true); @@ -304,8 +329,8 @@ describe('Transport tools: the price link', () => { arguments: { tripId: trip.id, type: 'train', title: 'ICE 599', price: 89 }, }); const data = parseToolResult(result) as { reservation: { id: number } }; - const item = testDb.prepare('SELECT category FROM budget_items WHERE reservation_id = ?').get(data.reservation.id) as { category: string }; - expect(item.category).toBe('train'); + const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id }); + expect(item?.category).toBe('train'); }); }); @@ -318,7 +343,7 @@ describe('Transport tools: the price link', () => { arguments: { tripId: trip.id, type: 'car', title: 'Rental', price: 0 }, }); const data = parseToolResult(result) as { reservation: { id: number } }; - expect(testDb.prepare('SELECT id FROM budget_items WHERE reservation_id = ?').get(data.reservation.id)).toBeUndefined(); + expect(await findRow(orm, BudgetItems, { reservation: data.reservation.id })).toBeNull(); }); }); }); @@ -360,7 +385,7 @@ describe('Tool: update_transport (guards)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const otherTrip = createTrip(testDb, user.id); - const foreignDay = Number(testDb.prepare("INSERT INTO days (trip_id, date, day_number) VALUES (?, '2026-08-01', 1)").run(otherTrip.id).lastInsertRowid); + const foreignDay = await foreignDayOn(otherTrip.id, '2026-08-01'); await withHarness(user.id, async (h) => { const created = await h.client.callTool({ name: 'create_transport', @@ -418,7 +443,7 @@ describe('Tool: delete_transport', () => { arguments: { tripId: trip.id, reservationId: reservation.id }, }); expect(parseToolResult(result)).toEqual({ success: true }); - expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id)).toBeUndefined(); + expect(await findRow(orm, Reservations, { id: reservation.id })).toBeNull(); expect(broadcastMock.mock.calls.some(c => c[1] === 'reservation:deleted')).toBe(true); expect(broadcastMock.mock.calls.some(c => c[1] === 'budget:deleted')).toBe(false); }); @@ -433,11 +458,10 @@ describe('Tool: delete_transport', () => { arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints }, }); const { reservation } = parseToolResult(created) as { reservation: { id: number } }; - const expense = (name: string) => Number(testDb.prepare( - 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)', - ).run(trip.id, name, 'flights', 50, reservation.id).lastInsertRowid); - const fare = expense('Fare'); - const seat = expense('Seat'); + const expense = async (name: string) => + (await makeBudgetItem(orm, trip.id, { name, category: 'flights', total_price: 50, reservation: reservation.id })).id; + const fare = await expense('Fare'); + const seat = await expense('Seat'); broadcastMock.mockClear(); const result = await h.client.callTool({ @@ -445,7 +469,7 @@ describe('Tool: delete_transport', () => { arguments: { tripId: trip.id, reservationId: reservation.id }, }); expect(parseToolResult(result)).toEqual({ success: true }); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 }); + expect(await countRows(orm, BudgetItems, { trip: trip.id })).toBe(0); // One event per expense, and all of them before the transport itself goes. expect(broadcastMock.mock.calls.map(c => c[1])).toEqual(['budget:deleted', 'budget:deleted', 'reservation:deleted']); expect(broadcastMock.mock.calls.slice(0, 2).map(c => (c[2] as { itemId: number }).itemId)).toEqual([fare, seat]); @@ -507,7 +531,7 @@ describe('Tool: create_transport (multi-leg)', () => { }, }); const data = parseToolResult(result) as any; - expect(storedMetadata(data.reservation.id).legs).toEqual([ + expect((await storedMetadata(data.reservation.id)).legs).toEqual([ { from: 'AMS', to: 'CDG', dep_day_id: day.id, dep_time: '09:00', arr_day_id: null, arr_time: '10:00' }, { from: 'CDG', to: 'FCO', dep_day_id: null, dep_time: '11:00', arr_day_id: null, arr_time: '12:00' }, ]); @@ -537,7 +561,7 @@ describe('Tool: create_transport (multi-leg)', () => { ], }, })) as any; - const meta = storedMetadata(data.reservation.id); + const meta = await storedMetadata(data.reservation.id); expect(meta.departure_airport).toBe('AMS'); expect(meta.arrival_airport).toBe('FCO'); expect(meta.airline).toBe('KLM'); @@ -561,7 +585,7 @@ describe('Tool: create_transport (multi-leg)', () => { ], }, })) as any; - const meta = storedMetadata(data.reservation.id); + const meta = await storedMetadata(data.reservation.id); expect(meta.legs[0].confirmation_number).toBe('ABC123'); // A leg without one gets no key at all, rather than an undefined that // serialises away and back as null. @@ -589,7 +613,7 @@ describe('Tool: create_transport (multi-leg)', () => { ], }, })) as any; - const meta = storedMetadata(data.reservation.id); + const meta = await storedMetadata(data.reservation.id); expect(meta.airline).toBe('KLM Cityhopper'); expect(meta.legs[0].airline).toBe('KLM'); }); @@ -607,7 +631,7 @@ describe('Tool: create_transport (multi-leg)', () => { legs: [{ dep_time: '09:00', arr_time: '10:00' }, { dep_time: '11:00', arr_time: '12:00' }], }, })) as any; - const meta = storedMetadata(data.reservation.id); + const meta = await storedMetadata(data.reservation.id); expect(meta.legs.map((l: any) => [l.from, l.to])).toEqual([['AMS', 'CDG'], ['CDG', 'FCO']]); }); }); @@ -631,7 +655,7 @@ describe('Tool: create_transport (multi-leg)', () => { ], }, })) as any; - const meta = storedMetadata(data.reservation.id); + const meta = await storedMetadata(data.reservation.id); // Station endpoints carry no code, so the labels come from their names. expect(meta.legs.map((l: any) => [l.from, l.to])).toEqual([ ['Basel SBB', 'Lugano'], ['Lugano', 'Milano Centrale'], @@ -846,7 +870,7 @@ describe('Tool: update_transport (multi-leg)', () => { await withHarness(user.id, async (h) => { const created = await seedStopover(h, trip.id); // What an AirTrail import leaves behind: sync ids plus per-leg planner positions. - testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ + await setMetadata(JSON.stringify({ departure_airport: 'AMS', arrival_airport: 'FCO', airtrail_ids: ['17', '18'], legs: [ { from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00', day_positions: { 5: 2 } }, @@ -864,7 +888,7 @@ describe('Tool: update_transport (multi-leg)', () => { ], }, }); - const meta = storedMetadata((parseToolResult(result) as any).reservation.id); + const meta = await storedMetadata((parseToolResult(result) as any).reservation.id); expect(meta.airtrail_ids).toEqual(['17', '18']); expect(meta.departure_airport).toBe('AMS'); expect(meta.legs[0].arr_time).toBe('10:15'); @@ -888,7 +912,7 @@ describe('Tool: update_transport (multi-leg)', () => { ], }, }); - const meta = storedMetadata((parseToolResult(result) as any).reservation.id); + const meta = await storedMetadata((parseToolResult(result) as any).reservation.id); // The copy-through list in applyLegs is what carries the field: a field // missing from it is accepted by the schema and then silently dropped. expect(meta.legs.map((l: any) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']); @@ -912,7 +936,7 @@ describe('Tool: update_transport (multi-leg)', () => { ], }, }); - const meta = storedMetadata((parseToolResult(result) as any).reservation.id); + const meta = await storedMetadata((parseToolResult(result) as any).reservation.id); expect(meta.confirmation_source).toBe('email'); expect(meta.legs).toHaveLength(2); // departure_airport is mirrored back in because the supplied metadata dropped it. @@ -925,12 +949,12 @@ describe('Tool: update_transport (multi-leg)', () => { const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { const created = await seedStopover(h, trip.id); - const before = storedMetadata(created.reservation.id); + const before = await storedMetadata(created.reservation.id); await h.client.callTool({ name: 'update_transport', arguments: { tripId: trip.id, reservationId: created.reservation.id, status: 'confirmed' }, }); - expect(storedMetadata(created.reservation.id)).toEqual(before); + expect(await storedMetadata(created.reservation.id)).toEqual(before); }); }); @@ -959,8 +983,7 @@ describe('Tool: update_transport (multi-leg)', () => { const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { const created = await seedStopover(h, trip.id); - const idsBefore = (testDb.prepare('SELECT id FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence') - .all(created.reservation.id) as any[]).map(r => r.id); + const idsBefore = await endpointIds(created.reservation.id); await h.client.callTool({ name: 'update_transport', arguments: { @@ -971,8 +994,7 @@ describe('Tool: update_transport (multi-leg)', () => { ], }, }); - const idsAfter = (testDb.prepare('SELECT id FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence') - .all(created.reservation.id) as any[]).map(r => r.id); + const idsAfter = await endpointIds(created.reservation.id); expect(idsAfter).toEqual(idsBefore); }); }); @@ -1005,7 +1027,7 @@ describe('Tool: update_transport (multi-leg)', () => { const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { const created = await seedStopover(h, trip.id); - testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run('not json at all', created.reservation.id); + await setMetadata('not json at all', created.reservation.id); const result = await h.client.callTool({ name: 'update_transport', arguments: { @@ -1017,7 +1039,7 @@ describe('Tool: update_transport (multi-leg)', () => { }, }); expect((result as { isError?: boolean }).isError).toBeFalsy(); - expect(storedMetadata((parseToolResult(result) as any).reservation.id).legs).toHaveLength(2); + expect((await storedMetadata((parseToolResult(result) as any).reservation.id)).legs).toHaveLength(2); }); }); @@ -1026,7 +1048,7 @@ describe('Tool: update_transport (multi-leg)', () => { const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { const created = await seedStopover(h, trip.id); - testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(JSON.stringify({ + await setMetadata(JSON.stringify(JSON.stringify({ legs: [{ from: 'AMS', to: 'CDG', day_positions: { 9: 1 } }, { from: 'CDG', to: 'FCO' }], })), created.reservation.id); const result = await h.client.callTool({ @@ -1039,7 +1061,7 @@ describe('Tool: update_transport (multi-leg)', () => { ], }, }); - expect(storedMetadata((parseToolResult(result) as any).reservation.id).legs[0].day_positions).toEqual({ 9: 1 }); + expect((await storedMetadata((parseToolResult(result) as any).reservation.id)).legs[0].day_positions).toEqual({ 9: 1 }); }); }); @@ -1110,8 +1132,7 @@ describe('Transport tools: the full type list', () => { expect((result as { isError?: boolean }).isError).toBeFalsy(); const data = parseToolResult(result) as any; expect(data.reservation.type).toBe(type); - const row = testDb.prepare('SELECT type FROM reservations WHERE id = ?').get(data.reservation.id) as any; - expect(row.type).toBe(type); + expect((await findRow(orm, Reservations, { id: data.reservation.id }))?.type).toBe(type); }); }); @@ -1138,9 +1159,9 @@ describe('Transport tools: the full type list', () => { expect(data.reservation.type).toBe('bus'); expect(data.reservation.confirmation_number).toBe('FLIX-8891'); expect(data.reservation.endpoints).toHaveLength(2); - const row = testDb.prepare('SELECT day_id, end_day_id FROM reservations WHERE id = ?').get(data.reservation.id) as any; - expect(row.day_id).toBe(depDay.id); - expect(row.end_day_id).toBe(arrDay.id); + const row = (await findRow(orm, Reservations, { id: data.reservation.id })); + expect(row?.day_id).toBe(depDay.id); + expect(row?.end_day_id).toBe(arrDay.id); }); }); @@ -1175,8 +1196,7 @@ describe('Transport tools: the full type list', () => { arguments: { tripId: trip.id, reservationId: reservation.id, type }, }); expect((result as { isError?: boolean }).isError).toBeFalsy(); - const row = testDb.prepare('SELECT type FROM reservations WHERE id = ?').get(reservation.id) as any; - expect(row.type).toBe(type); + expect((await findRow(orm, Reservations, { id: reservation.id }))?.type).toBe(type); }); }); @@ -1195,8 +1215,7 @@ describe('Transport tools: the full type list', () => { arguments: { tripId: trip.id, reservationId: reservation.id, status: 'confirmed' }, }); expect((result as { isError?: boolean }).isError).toBeFalsy(); - const row = testDb.prepare('SELECT status FROM reservations WHERE id = ?').get(reservation.id) as any; - expect(row.status).toBe('confirmed'); + expect((await findRow(orm, Reservations, { id: reservation.id }))?.status).toBe('confirmed'); }); }); @@ -1228,16 +1247,14 @@ describe('Transport tools: the full type list', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // As create_transit_journey leaves it: a transport row carrying an itinerary. - const reservationId = Number(testDb.prepare( - "INSERT INTO reservations (trip_id, title, type, status) VALUES (?, 'Tram 4', 'transit', 'pending')" - ).run(trip.id).lastInsertRowid); + const reservationId = await insertRow(orm, Reservations, { trip: trip.id, title: 'Tram 4', type: 'transit', status: 'pending' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_transport', arguments: { tripId: trip.id, reservationId, status: 'confirmed' }, }); expect((result as { isError?: boolean }).isError).toBeFalsy(); - const row = testDb.prepare('SELECT status, type FROM reservations WHERE id = ?').get(reservationId) as any; + const row = (await findRow(orm, Reservations, { id: reservationId })); expect(row).toMatchObject({ status: 'confirmed', type: 'transit' }); }); });