From 894a5ee781c13feab33490b46ae073f384069265 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 15:20:07 +0200 Subject: [PATCH] test(server): seed the small leftover specs through the repositories Converts the raw prepare() reads and writes in the share, airtrail, files, todo, dev-link, vacay, tours, bucket list, unit-of-work and plugin guard specs to the ORM factories and readers, so they go through the entities the app uses instead of hand-written SQLite. The vacay e2e moves from hand-rolled tables to the migrated snapshot, with its user pinned to the id its session cookie is signed for. Statements that are raw by nature keep the allow marker with the reason: the legacy oracle of the bucket list parity spec, the PRAGMA probe of the trek_photos migration, the unmapped probe table of the route import rollback, the behind-the-ORM reads of the entity insert probes, and seedUser, which also serves the hand-rolled users table of createTempDb. Comments that quoted a prepare() call are reworded so the ratchet no longer counts them. --- server/tests/e2e/harness.ts | 1 + server/tests/e2e/vacay.e2e.test.ts | 85 +++++-------------- server/tests/integration/files.test.ts | 9 +- .../integration/plugins/dev-link.test.ts | 12 ++- server/tests/integration/todo.test.ts | 8 +- .../tests/unit/db/entity-class-fields.test.ts | 2 + .../db/repositories/Tours.repository.test.ts | 10 ++- .../repositories/_shared/owned-lookup.test.ts | 3 +- .../bucket-list-duplicate.parity.test.ts | 39 +++++---- server/tests/unit/mcp/tools-airtrail.test.ts | 71 +++++++++------- server/tests/unit/mcp/tools-share.test.ts | 18 +++- .../tests/unit/nest/airtrail.service.test.ts | 11 +-- .../unit/nest/database/unit-of-work.test.ts | 10 +-- server/tests/unit/nest/google-route.test.ts | 2 + .../nest/place-enrichment.service.test.ts | 2 +- .../tests/unit/plugins/plugin-guards.test.ts | 5 +- .../plugins/plugin-routes.controller.test.ts | 2 +- .../tests/unit/plugins/rpc-fallbacks.test.ts | 2 +- .../unit/services/trekPhotoMedia.test.ts | 6 +- server/tests/unit/trips/trips.rpc.test.ts | 2 +- 20 files changed, 152 insertions(+), 148 deletions(-) diff --git a/server/tests/e2e/harness.ts b/server/tests/e2e/harness.ts index d50f59f3f..96dd2111c 100644 --- a/server/tests/e2e/harness.ts +++ b/server/tests/e2e/harness.ts @@ -48,6 +48,7 @@ export function seedUser(db: Database.Database, overrides: Partial = role: overrides.role ?? 'user', password_version: overrides.password_version ?? 0, }; + // test-sql-allow: seedUser also serves createTempDb's hand-rolled users table, which no ORM is bound to. db.prepare( 'INSERT INTO users (id, username, email, role, password_version) VALUES (?, ?, ?, ?, ?)', ).run(user.id, user.username, user.email, user.role, user.password_version); diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index 8cb433498..9ab474e40 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -1,6 +1,6 @@ /** * Vacay module e2e — exercises the migrated /api/addons/vacay endpoints through - * the real JwtAuthGuard against a temp SQLite db. DI-native: VacayService runs + * the real JwtAuthGuard against a migrated temp SQLite db. DI-native: VacayService runs * its real SQL over the temp db (no legacy service mock); only the websocket * and notification side channels stay mocked. Focuses on auth, status codes * (POSTs stay 200), the Zod-pipe 400 envelope and a 403 body. @@ -10,78 +10,30 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; 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'); - 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);`); - // The vacay tables VacayService's real SQL touches (trimmed from src/db/schema.ts, - // with the fraction/kind columns the migrations add to vacay_entries). - tmp.exec(`CREATE TABLE vacay_plans (id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - block_weekends INTEGER DEFAULT 1, holidays_enabled INTEGER DEFAULT 0, holidays_region TEXT DEFAULT '', - school_holidays_enabled INTEGER DEFAULT 0, company_holidays_enabled INTEGER DEFAULT 1, - carry_over_enabled INTEGER DEFAULT 1, weekend_days TEXT, week_start INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner_id));`); - tmp.exec(`CREATE TABLE vacay_plan_members (id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(plan_id, user_id));`); - tmp.exec(`CREATE TABLE vacay_user_colors (id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - color TEXT DEFAULT '#6366f1', UNIQUE(user_id, plan_id));`); - tmp.exec(`CREATE TABLE vacay_years (id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - year INTEGER NOT NULL, UNIQUE(plan_id, year));`); - tmp.exec(`CREATE TABLE vacay_user_years (id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - year INTEGER NOT NULL, vacation_days INTEGER DEFAULT 30, carried_over INTEGER DEFAULT 0, - UNIQUE(user_id, plan_id, year));`); - tmp.exec(`CREATE TABLE vacay_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - date TEXT NOT NULL, note TEXT DEFAULT '', - fraction REAL NOT NULL DEFAULT 1, kind TEXT NOT NULL DEFAULT 'vacation', - UNIQUE(user_id, plan_id, date));`); - tmp.exec(`CREATE TABLE vacay_company_holidays (id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - date TEXT NOT NULL, note TEXT DEFAULT '', fraction REAL NOT NULL DEFAULT 1, UNIQUE(plan_id, date));`); - tmp.exec(`CREATE TABLE vacay_holiday_calendars (id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - type TEXT NOT NULL DEFAULT 'public_holiday', region TEXT NOT NULL, label TEXT, - color TEXT NOT NULL DEFAULT '#fecaca', sort_order INTEGER NOT NULL DEFAULT 0);`); - tmp.exec(`CREATE TABLE vacay_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - hidden INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner_id, user_id));`); - tmp.exec(`CREATE TABLE vacay_user_settings ( - user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - year_type TEXT NOT NULL DEFAULT 'calendar', year_start_month INTEGER NOT NULL DEFAULT 1, - year_start_day INTEGER NOT NULL DEFAULT 1, hire_date TEXT);`); - // 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()); }); - -vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() })); +import { db } from '../../src/db/database'; +import { VacayEntries } from '../../src/db/entities/VacayEntries.entity'; +import { VacayPlans } from '../../src/db/entities/VacayPlans.entity'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { VacayModule } from '../../src/nest/vacay/vacay.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { broadcastToUser } from '../../src/websocket'; 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 { findRow } from '../helpers/factories/rows'; -describe('Vacay e2e (real auth guard + temp SQLite)', () => { +let orm: TestOrm; + +describe('Vacay e2e (real auth guard + migrated temp SQLite)', () => { let server: Server; let app: Awaited>; @@ -98,13 +50,16 @@ describe('Vacay 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, email: 'e2e@example.test' }); app = await build(); server = app.getHttpServer(); }); afterAll(async () => { await app.close(); + await orm.close(); }); it('401 without a session cookie', async () => { @@ -117,7 +72,7 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => { expect(res.status).toBe(200); expect(res.body.plan.owner_id).toBe(1); expect(res.body.isOwner).toBe(true); - expect(db.prepare('SELECT id FROM vacay_plans WHERE owner_id = 1').get()).toBeDefined(); + expect(await findRow(orm, VacayPlans, { owner: 1 })).not.toBeNull(); }); it('200 (not 201) on POST entries/toggle, forwarding the socket id', async () => { @@ -125,7 +80,7 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => { .set('Cookie', sessionCookie(1)).set('X-Socket-Id', 'sock-7').send({ date: '2026-07-01' }); expect(res.status).toBe(200); expect(res.body).toEqual({ action: 'added', fraction: 1, kind: 'vacation' }); - expect(db.prepare("SELECT id FROM vacay_entries WHERE user_id = 1 AND date = '2026-07-01'").get()).toBeDefined(); + expect(await findRow(orm, VacayEntries, { user: 1, date: '2026-07-01' })).not.toBeNull(); expect(vi.mocked(broadcastToUser)).toHaveBeenCalledWith(1, { type: 'vacay:update' }, 'sock-7'); }); diff --git a/server/tests/integration/files.test.ts b/server/tests/integration/files.test.ts index e001b2e2e..143193f6e 100644 --- a/server/tests/integration/files.test.ts +++ b/server/tests/integration/files.test.ts @@ -30,13 +30,17 @@ vi.mock('../../src/config', () => ({ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, createReservation, createPlace, addTripMember } from '../helpers/factories'; import { authCookie, generateToken } from '../helpers/auth'; +import { setAppSetting } from '../helpers/factories/settings'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf'); const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg'); @@ -46,16 +50,17 @@ const uploadsDir = path.join(__dirname, '../../uploads/files'); beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); // Seed allowed_file_types to include common types (wildcard) - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); + await setAppSetting(orm, 'allowed_file_types', '*'); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); // Re-seed allowed_file_types after reset - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); + await setAppSetting(orm, 'allowed_file_types', '*'); }); afterAll(async () => { diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index 1f6786702..de7e89df2 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -22,6 +22,10 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import { createPluginRuntime } from '../../helpers/plugin-host'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { findRow } from '../../helpers/factories/rows'; +import { makePlugin } from '../../helpers/factories/plugins'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; const testDb = createSnapshotTestDb(); const dbConn = testDb; @@ -77,9 +81,7 @@ describe('PluginRuntimeService dev-link', () => { expect(fs.existsSync(path.join(dest, 'server', 'index.js'))).toBe(true); expect(fs.realpathSync(dest)).toBe(fs.realpathSync(dir)); - const row = testDb.prepare("SELECT source_repo, status, enabled FROM plugins WHERE id = 'linkplug'").get() as { - source_repo: string; status: string; enabled: number; - }; + const row = await findRow(await sharedTestOrm(dbConn), Plugins, { id: 'linkplug' }); expect(row).toMatchObject({ source_repo: 'local:link', status: 'inactive', enabled: 0 }); }); @@ -132,7 +134,9 @@ describe('PluginRuntimeService dev-link', () => { }); it('refuses to clobber a real (non-linked) installed plugin of the same id', async () => { - testDb.prepare("INSERT INTO plugins (id, name, type, version, status, source_repo) VALUES ('installed','X','integration','1.0.0','inactive','local:upload')").run(); + await makePlugin(await sharedTestOrm(dbConn), 'installed', { + name: 'X', type: 'integration', version: '1.0.0', status: 'inactive', source_repo: 'local:upload', + }); await expect(runtime.link(writeSource('installed'))).rejects.toThrow(/already installed/); }); diff --git a/server/tests/integration/todo.test.ts b/server/tests/integration/todo.test.ts index dfeb5147a..5279f03c9 100644 --- a/server/tests/integration/todo.test.ts +++ b/server/tests/integration/todo.test.ts @@ -23,18 +23,24 @@ vi.mock('../../src/config', () => ({ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { findRows } from '../helpers/factories/rows'; +import { TodoItems } from '../../src/db/entities/TodoItems.entity'; import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -199,7 +205,7 @@ describe('Todo items', () => { expect(res.body.success).toBe(true); // Verify the new order in the DB - const items = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[]; + const items = await findRows(orm, TodoItems, { trip: trip.id }, { sort_order: 'asc' }); expect(items[0].id).toBe(id3); expect(items[1].id).toBe(id2); expect(items[2].id).toBe(id1); diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts index 166cbd7dc..b92c03102 100644 --- a/server/tests/unit/db/entity-class-fields.test.ts +++ b/server/tests/unit/db/entity-class-fields.test.ts @@ -163,6 +163,7 @@ describe('insert probes (task-4-review-shape.md, the reviewer\'s exact repro)', }); await t.em.persist(created).flush(); + // test-sql-allow: reads the stored columns behind the ORM to prove what its own insert wrote. const row = testDb.prepare('SELECT persons, days, place_id, reservation_id FROM budget_items WHERE id = ?').get(created.id); expect(row).toEqual({ persons: null, days: null, place_id: null, reservation_id: null }); }); @@ -181,6 +182,7 @@ describe('insert probes (task-4-review-shape.md, the reviewer\'s exact repro)', await t.em.persist(created).flush(); const row = testDb + // test-sql-allow: reads the stored column behind the ORM to prove the database default filled it. .prepare('SELECT created_at FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?') .get('idem-key-probe-1', user.id, 'POST', '/api/trips') as { created_at: number }; // The DB's own defaultRaw (strftime('%s','now')) filled it in — never NaN, never null. diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index 02e8c318f..e68d432eb 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -10,6 +10,8 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories'; import { createTour } from '../../../helpers/tours-repos'; import { Tours } from '../../../../src/db/entities/Tours.entity'; +import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity'; +import { insertRow, updateRows } from '../../../helpers/factories/rows'; import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository'; const testDb = createSnapshotTestDb(); @@ -30,8 +32,8 @@ function fixture() { return { trip, other }; } -function addWaypoint(placeId: number, sequence: number): void { - testDb.prepare("INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 47, 11, 'via', ?)").run(placeId, sequence); +async function addWaypoint(placeId: number, sequence: number): Promise { + await insertRow(t, TourWaypoints, { place: placeId, lat: 47, lng: 11, role: 'via', sequence }); } const METRICS = { @@ -55,7 +57,7 @@ describe('ToursRepository reads', () => { createTour(testDb, newer.id, { created_at: '2026-01-02 10:00:00', max_hiking_difficulty: 5 }); createTour(testDb, foreign.id); createDayAssignment(testDb, createDay(testDb, trip.id).id, older.id); - addWaypoint(newer.id, 0); + await addWaypoint(newer.id, 0); expect(await repo.listForTrip(trip.id)).toEqual([ { @@ -149,7 +151,7 @@ describe('ToursRepository writes', () => { const { trip, other } = fixture(); const source = createPlace(testDb, trip.id); createTour(testDb, source.id, { created_at: '2026-03-04 05:06:07', distance: 3.5, max_hiking_difficulty: 4 }); - testDb.prepare("UPDATE tours SET difficulty = 'T3', wanderer_ref = 'w-1' WHERE place_id = ?").run(source.id); + await updateRows(t, Tours, { place: source.id }, { difficulty: 'T3', wanderer_ref: 'w-1' }); const rows = await repo.listRowsForTrip(trip.id); expect(rows).toEqual([{ diff --git a/server/tests/unit/db/repositories/_shared/owned-lookup.test.ts b/server/tests/unit/db/repositories/_shared/owned-lookup.test.ts index a3c77c221..af577053f 100644 --- a/server/tests/unit/db/repositories/_shared/owned-lookup.test.ts +++ b/server/tests/unit/db/repositories/_shared/owned-lookup.test.ts @@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../../helpers/db-mock'; import { resetTestDb } from '../../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../../helpers/test-orm'; import { createTag, createUser } from '../../../../helpers/factories'; +import { updateRows } from '../../../../helpers/factories/rows'; import { Tags } from '../../../../../src/db/entities/Tags.entity'; import type { TagsRepository } from '../../../../../src/db/repositories/Tags.repository'; import { findOwnedByUser, listForOwner } from '../../../../../src/db/repositories/_shared/owned-lookup'; @@ -69,7 +70,7 @@ describe('findOwnedByUser', () => { const { user: owner } = createUser(testDb); const tag = createTag(testDb, owner.id, { name: 'Old' }); expect((await findOwnedByUser(tags, tag.id, 'user', owner.id))?.name).toBe('Old'); - testDb.prepare('UPDATE tags SET name = ? WHERE id = ?').run('New', tag.id); + await updateRows(t, Tags, { id: tag.id }, { name: 'New' }); const connection = t.orm.em.getConnection(); const spy = vi.spyOn(connection, 'execute'); const found = await findOwnedByUser(tags, tag.id, 'user', owner.id); diff --git a/server/tests/unit/db/repositories/bucket-list-duplicate.parity.test.ts b/server/tests/unit/db/repositories/bucket-list-duplicate.parity.test.ts index aad840794..d0aa35449 100644 --- a/server/tests/unit/db/repositories/bucket-list-duplicate.parity.test.ts +++ b/server/tests/unit/db/repositories/bucket-list-duplicate.parity.test.ts @@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { makeBucketListItem } from '../../../helpers/factories/atlas'; import { BucketList } from '../../../../src/db/entities/BucketList.entity'; import type { BucketListIdentity } from '../../../../src/db/repositories/BucketList.repository'; @@ -34,15 +35,21 @@ beforeAll(async () => { t = await createTestOrm(testDb); }); beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function insertBucketItem(userId: number, row: Partial & { name: string; notes?: string | null }): number { - const result = testDb - .prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run(userId, row.name, row.lat ?? null, row.lng ?? null, row.country_code ?? null, row.notes ?? null, row.target_date ?? null); - return Number(result.lastInsertRowid); +async function insertBucketItem(userId: number, row: Partial & { name: string; notes?: string | null }): Promise { + const item = await makeBucketListItem(t, userId, { + name: row.name, + lat: row.lat ?? null, + lng: row.lng ?? null, + country_code: row.country_code ?? null, + notes: row.notes ?? null, + target_date: row.target_date ?? null, + }); + return item.id; } function legacyFindDuplicate(userId: number, key: BucketListIdentity, excludeId: number | null): number | null { const row = testDb + // test-sql-allow: the legacy statement is this parity test's oracle and has to run as written. .prepare(LEGACY_FIND_DUPLICATE) .get(userId, key.name, key.country_code, key.target_date, key.lat, key.lng, excludeId) as { id: number } | undefined; return row?.id ?? null; @@ -55,7 +62,7 @@ async function repoFindDuplicate(userId: number, key: BucketListIdentity, exclud describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => { it('BLDUP-001: an all-NULL fixture row matches an all-NULL key (NULL-safe IS, not NULL = NULL)', async () => { const { user } = createUser(testDb); - const id = insertBucketItem(user.id, { name: 'Someday Idea', lat: null, lng: null, country_code: null, target_date: null }); + const id = await insertBucketItem(user.id, { name: 'Someday Idea', lat: null, lng: null, country_code: null, target_date: null }); const key: BucketListIdentity = { name: 'Someday Idea', lat: null, lng: null, country_code: null, target_date: null }; const legacy = legacyFindDuplicate(user.id, key, null); @@ -67,7 +74,7 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => { it('BLDUP-002: an all-SET fixture row matches an identical all-SET key', async () => { const { user } = createUser(testDb); - const id = insertBucketItem(user.id, { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, country_code: 'FR', target_date: '2026-06-01' }); + const id = await insertBucketItem(user.id, { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, country_code: 'FR', target_date: '2026-06-01' }); const key: BucketListIdentity = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, country_code: 'FR', target_date: '2026-06-01' }; const legacy = legacyFindDuplicate(user.id, key, null); @@ -79,7 +86,7 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => { it('BLDUP-003: name matches case- and whitespace-insensitively (lower(trim()) on both sides)', async () => { const { user } = createUser(testDb); - const id = insertBucketItem(user.id, { name: 'Eiffel Tower', lat: null, lng: null, country_code: null, target_date: null }); + const id = await insertBucketItem(user.id, { name: 'Eiffel Tower', lat: null, lng: null, country_code: null, target_date: null }); const key: BucketListIdentity = { name: ' eiffel TOWER ', lat: null, lng: null, country_code: null, target_date: null }; const legacy = legacyFindDuplicate(user.id, key, null); @@ -91,7 +98,7 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => { it('BLDUP-004: an all-NULL row does NOT match an all-SET key with the same name (mixed NULL/non-NULL, every column checked)', async () => { const { user } = createUser(testDb); - insertBucketItem(user.id, { name: 'Tower', lat: null, lng: null, country_code: null, target_date: null }); + await insertBucketItem(user.id, { name: 'Tower', lat: null, lng: null, country_code: null, target_date: null }); const key: BucketListIdentity = { name: 'Tower', lat: 48.8584, lng: 2.2945, country_code: 'FR', target_date: '2026-06-01' }; const legacy = legacyFindDuplicate(user.id, key, null); @@ -103,7 +110,7 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => { it('BLDUP-005: excludeId excludes the row itself (the update-against-self no-op path) — a bound NULL excludeId (create) never excludes anything', async () => { const { user } = createUser(testDb); - const id = insertBucketItem(user.id, { name: 'Colosseum', lat: 41.8902, lng: 12.4922, country_code: 'IT', target_date: null }); + const id = await insertBucketItem(user.id, { name: 'Colosseum', lat: 41.8902, lng: 12.4922, country_code: 'IT', target_date: null }); const key: BucketListIdentity = { name: 'Colosseum', lat: 41.8902, lng: 12.4922, country_code: 'IT', target_date: null }; // update-against-self: excluding the row's own id finds nothing. @@ -126,8 +133,8 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => { // Restored immediately after observing the failure; `findDuplicate` itself is // unchanged in this commit — see this task's report for the per-column results. const { user } = createUser(testDb); - const genericIdea = insertBucketItem(user.id, { name: 'Tower', lat: null, lng: null, country_code: null, target_date: null }); - const pinnedPlace = insertBucketItem(user.id, { name: 'Tower', lat: 48.8584, lng: 2.2945, country_code: null, target_date: null }); + const genericIdea = await insertBucketItem(user.id, { name: 'Tower', lat: null, lng: null, country_code: null, target_date: null }); + const pinnedPlace = await insertBucketItem(user.id, { name: 'Tower', lat: 48.8584, lng: 2.2945, country_code: null, target_date: null }); const key: BucketListIdentity = { name: 'Tower', lat: 48.8584, lng: 2.2945, country_code: null, target_date: null }; const legacy = legacyFindDuplicate(user.id, key, null); @@ -143,8 +150,8 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => { // object and re-running this suite made this test go red (`ideaAnywhere` was returned // in place of `franceIdea`). Restored immediately; `findDuplicate` unchanged. const { user } = createUser(testDb); - const ideaAnywhere = insertBucketItem(user.id, { name: 'Castle', lat: null, lng: null, country_code: null, target_date: null }); - const franceIdea = insertBucketItem(user.id, { name: 'Castle', lat: null, lng: null, country_code: 'FR', target_date: null }); + const ideaAnywhere = await insertBucketItem(user.id, { name: 'Castle', lat: null, lng: null, country_code: null, target_date: null }); + const franceIdea = await insertBucketItem(user.id, { name: 'Castle', lat: null, lng: null, country_code: 'FR', target_date: null }); const key: BucketListIdentity = { name: 'Castle', lat: null, lng: null, country_code: 'FR', target_date: null }; const legacy = legacyFindDuplicate(user.id, key, null); @@ -160,8 +167,8 @@ describe('BucketListRepository.findDuplicate parity (AT31, #1898)', () => { // object and re-running this suite made this test go red (`undated` was returned in // place of `dated`). Restored immediately; `findDuplicate` unchanged. const { user } = createUser(testDb); - const undated = insertBucketItem(user.id, { name: 'Museum', lat: null, lng: null, country_code: null, target_date: null }); - const dated = insertBucketItem(user.id, { name: 'Museum', lat: null, lng: null, country_code: null, target_date: '2026-09-01' }); + const undated = await insertBucketItem(user.id, { name: 'Museum', lat: null, lng: null, country_code: null, target_date: null }); + const dated = await insertBucketItem(user.id, { name: 'Museum', lat: null, lng: null, country_code: null, target_date: '2026-09-01' }); const key: BucketListIdentity = { name: 'Museum', lat: null, lng: null, country_code: null, target_date: '2026-09-01' }; const legacy = legacyFindDuplicate(user.id, key, null); diff --git a/server/tests/unit/mcp/tools-airtrail.test.ts b/server/tests/unit/mcp/tools-airtrail.test.ts index f00dc4c4c..27f9a397f 100644 --- a/server/tests/unit/mcp/tools-airtrail.test.ts +++ b/server/tests/unit/mcp/tools-airtrail.test.ts @@ -33,6 +33,10 @@ import { AirtrailClient, AirtrailRequestError, type AirtrailFlightRaw } from '.. import { AirtrailImportService } from '../../../src/nest/integrations/airtrail-import.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { findRows, updateRows } from '../../helpers/factories/rows'; +import { Users } from '../../../src/db/entities/Users.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; // The permissions cache is module-scoped, so a write through any instance is // what the tool's own checkPermission call reads back. @@ -60,7 +64,13 @@ beforeEach(async () => { await savePermissions({ reservation_edit: 'trip_member' }); }); -afterAll(() => { +let orm: TestOrm; +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -70,8 +80,8 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise, } /** The stored connection, plaintext key: decrypt_api_key passes legacy plaintext straight through. */ -function connectAirtrail(userId: number, url = 'https://airtrail.example.com') { - testDb.prepare('UPDATE users SET airtrail_url = ?, airtrail_api_key = ? WHERE id = ?').run(url, 'plain-test-key', userId); +async function connectAirtrail(userId: number, url = 'https://airtrail.example.com'): Promise { + await updateRows(orm, Users, { id: userId }, { airtrail_url: url, airtrail_api_key: 'plain-test-key' }); } const ZRH = { id: 1, icao: 'LSZH', iata: 'ZRH', name: 'Zurich', lat: 47.458, lon: 8.548, tz: 'Europe/Zurich', country: 'CH' }; @@ -99,12 +109,7 @@ function flight(overrides: Partial & { id: number }): Airtrai } function reservationRows(tripId: number) { - return testDb.prepare( - 'SELECT id, title, type, external_source, external_id, external_owner_user_id, sync_enabled, metadata FROM reservations WHERE trip_id = ? ORDER BY id', - ).all(tripId) as { - id: number; title: string; type: string; external_source: string | null; external_id: string | null; - external_owner_user_id: number | null; sync_enabled: number | null; metadata: string | null; - }[]; + return findRows(orm, Reservations, { trip: tripId }, { id: 'asc' }); } // --------------------------------------------------------------------------- @@ -114,7 +119,7 @@ function reservationRows(tripId: number) { describe('Tool: list_airtrail_flights', () => { it('returns the caller flights, normalized and oldest departure first', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); listFlightsMock.mockResolvedValue([ flight({ id: 22, date: '2026-09-20', departure: '2026-09-20T06:00:00Z', flightNumber: 'LH400', from: FRA, to: JFK }), flight({ id: 11 }), @@ -141,7 +146,7 @@ describe('Tool: list_airtrail_flights', () => { it('narrows to a date window and keeps undated flights', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); listFlightsMock.mockResolvedValue([ flight({ id: 1, date: '2026-08-01', departure: '2026-08-01T06:00:00Z' }), flight({ id: 2, date: '2026-09-10', departure: null }), @@ -167,7 +172,7 @@ describe('Tool: list_airtrail_flights', () => { it('falls back to its own wording when AirTrail fails without a message', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); listFlightsMock.mockRejectedValue(new Error('')); await withHarness(user.id, async (h) => { @@ -179,7 +184,7 @@ describe('Tool: list_airtrail_flights', () => { it('caps the result and says it was truncated', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); listFlightsMock.mockResolvedValue( Array.from({ length: 5 }, (_, i) => flight({ id: i + 1, departure: `2026-09-1${i}T06:00:00Z` })), ); @@ -206,7 +211,7 @@ describe('Tool: list_airtrail_flights', () => { it('passes an upstream failure through as the tool error', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); listFlightsMock.mockRejectedValue(new AirtrailRequestError('AirTrail list failed (HTTP 500)', 500)); await withHarness(user.id, async (h) => { @@ -218,7 +223,7 @@ describe('Tool: list_airtrail_flights', () => { it('is hidden while the airtrail addon is off', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); setAddonEnabled(testDb, ADDON_IDS.AIRTRAIL, false); await withHarness(user.id, async (h) => { @@ -232,7 +237,7 @@ describe('Tool: list_airtrail_flights', () => { it('rides reservations:read and is hidden from a token without it', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); await withHarness(user.id, async (h) => { expect((await h.client.listTools()).tools.map(t => t.name)).toContain('list_airtrail_flights'); @@ -251,7 +256,7 @@ describe('Tool: list_airtrail_flights', () => { describe('Tool: import_airtrail_flights', () => { it('imports the listed flights as linked flight bookings', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); listFlightsMock.mockResolvedValue([flight({ id: 11 }), flight({ id: 12, flightNumber: 'LH1202' })]); @@ -268,7 +273,7 @@ describe('Tool: import_airtrail_flights', () => { expect(data.skipped).toEqual([]); }); - const rows = reservationRows(trip.id); + const rows = await reservationRows(trip.id); expect(rows).toHaveLength(2); expect(rows[0]).toMatchObject({ title: 'LH1201', type: 'flight', external_source: 'airtrail', external_id: '11', @@ -280,7 +285,7 @@ describe('Tool: import_airtrail_flights', () => { it('skips a flight that is already linked to the trip', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); listFlightsMock.mockResolvedValue([flight({ id: 11 })]); @@ -291,12 +296,12 @@ describe('Tool: import_airtrail_flights', () => { expect(data.imported).toEqual([]); expect(data.skipped).toEqual([{ flightId: '11', reason: 'already-imported' }]); }); - expect(reservationRows(trip.id)).toHaveLength(1); + expect(await reservationRows(trip.id)).toHaveLength(1); }); it('joins a connection into one multi-leg booking detached from sync', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); listFlightsMock.mockResolvedValue([ flight({ id: 11 }), @@ -315,7 +320,7 @@ describe('Tool: import_airtrail_flights', () => { expect(data.imported).toEqual(['11', '12']); }); - const rows = reservationRows(trip.id); + const rows = await reservationRows(trip.id); expect(rows).toHaveLength(1); expect(rows[0].sync_enabled).toBe(0); expect(JSON.parse(rows[0].metadata ?? '{}').airtrail_ids).toEqual(['11', '12']); @@ -323,7 +328,7 @@ describe('Tool: import_airtrail_flights', () => { it('refuses a connection naming a flight that is not being imported', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); listFlightsMock.mockResolvedValue([flight({ id: 11 })]); @@ -335,13 +340,13 @@ describe('Tool: import_airtrail_flights', () => { expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('flight 99'); }); - expect(reservationRows(trip.id)).toHaveLength(0); + expect(await reservationRows(trip.id)).toHaveLength(0); expect(listFlightsMock).not.toHaveBeenCalled(); }); it('refuses more than fifty flights in one call', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); await withHarness(user.id, async (h) => { @@ -352,7 +357,7 @@ describe('Tool: import_airtrail_flights', () => { expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('at most 50'); }); - expect(reservationRows(trip.id)).toHaveLength(0); + expect(await reservationRows(trip.id)).toHaveLength(0); expect(listFlightsMock).not.toHaveBeenCalled(); }); @@ -368,12 +373,12 @@ describe('Tool: import_airtrail_flights', () => { expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('AirTrail is not connected'); }); - expect(reservationRows(trip.id)).toHaveLength(0); + expect(await reservationRows(trip.id)).toHaveLength(0); }); it('falls back to its own wording when the import fails without a message', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); const importMock = vi.spyOn(AirtrailImportService.prototype, 'importAirtrailFlights') .mockRejectedValue(new Error('')); @@ -402,7 +407,7 @@ describe('Tool: import_airtrail_flights', () => { const trip = createTrip(testDb, owner.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); addTripMember(testDb, trip.id, demo.id); addTripMember(testDb, trip.id, member.id); - for (const u of [owner, demo, stranger, member]) connectAirtrail(u.id); + for (const u of [owner, demo, stranger, member]) await connectAirtrail(u.id); listFlightsMock.mockResolvedValue([flight({ id: 11 })]); process.env.DEMO_MODE = 'true'; @@ -426,14 +431,14 @@ describe('Tool: import_airtrail_flights', () => { expect((result.content as any)[0].text).toContain('permission'); }); - expect(reservationRows(trip.id)).toHaveLength(0); + expect(await reservationRows(trip.id)).toHaveLength(0); expect(broadcastMock).not.toHaveBeenCalled(); expect(listFlightsMock).not.toHaveBeenCalled(); }); it('is hidden while the airtrail addon is off', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-09-10', end_date: '2026-09-12' }); setAddonEnabled(testDb, ADDON_IDS.AIRTRAIL, false); @@ -447,12 +452,12 @@ describe('Tool: import_airtrail_flights', () => { expect(result.isError).toBe(true); expect((result.content as any)[0].text).toContain('not found'); }); - expect(reservationRows(trip.id)).toHaveLength(0); + expect(await reservationRows(trip.id)).toHaveLength(0); }); it('rides reservations:write and is hidden from a read-only token', async () => { const { user } = createUser(testDb); - connectAirtrail(user.id); + await connectAirtrail(user.id); await withHarness(user.id, async (h) => { expect((await h.client.listTools()).tools.map(t => t.name)).toContain('import_airtrail_flights'); diff --git a/server/tests/unit/mcp/tools-share.test.ts b/server/tests/unit/mcp/tools-share.test.ts index e03ca8993..fb9507238 100644 --- a/server/tests/unit/mcp/tools-share.test.ts +++ b/server/tests/unit/mcp/tools-share.test.ts @@ -22,13 +22,23 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { findRow } from '../../helpers/factories/rows'; +import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity'; beforeEach(() => { resetTestDb(testDb); delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -78,8 +88,8 @@ describe('Tool: create_share_link', () => { await withHarness(user.id, async (h) => { const first = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } })) as any; expect(first.created).toBe(true); - const row = testDb.prepare('SELECT share_map, share_bookings, share_packing, share_budget, share_collab FROM share_tokens WHERE trip_id = ?').get(trip.id) as any; - expect(row).toEqual({ share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0 }); + const row = await findRow(orm, ShareTokens, { trip: trip.id }); + expect(row).toMatchObject({ share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0 }); const second = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id, share_budget: true } })) as any; expect(second.created).toBe(false); expect(second.token).toBe(first.token); @@ -110,7 +120,7 @@ describe('Tool: delete_share_link', () => { await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } }); const result = parseToolResult(await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } })) as any; expect(result.success).toBe(true); - expect(testDb.prepare('SELECT id FROM share_tokens WHERE trip_id = ?').get(trip.id)).toBeUndefined(); + expect(await findRow(orm, ShareTokens, { trip: trip.id })).toBeNull(); }); const { user: stranger } = createUser(testDb); await withHarness(stranger.id, async (h) => { diff --git a/server/tests/unit/nest/airtrail.service.test.ts b/server/tests/unit/nest/airtrail.service.test.ts index 865f53355..844480cb2 100644 --- a/server/tests/unit/nest/airtrail.service.test.ts +++ b/server/tests/unit/nest/airtrail.service.test.ts @@ -24,6 +24,7 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createUser } from '../../helpers/factories'; +import { readUser } from '../../helpers/factories/users'; import { Users } from '../../../src/db/entities/Users.entity'; import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; @@ -50,8 +51,8 @@ beforeEach(() => { }); afterAll(async () => { await t.close(); testDb.close(); }); -function rawAirtrailKey(userId: number): string | null { - return (testDb.prepare('SELECT airtrail_api_key FROM users WHERE id = ?').get(userId) as { airtrail_api_key: string | null }).airtrail_api_key; +async function rawAirtrailKey(userId: number): Promise { + return (await readUser(t, userId)).airtrail_api_key ?? null; } describe('AirtrailService — at-rest encryption (3h L3)', () => { @@ -60,7 +61,7 @@ describe('AirtrailService — at-rest encryption (3h L3)', () => { await svc.saveSettings(user.id, 'https://airtrail.example.test', 'super-secret-key', false, false, null); - const stored = rawAirtrailKey(user.id); + const stored = await rawAirtrailKey(user.id); expect(stored).toMatch(/^enc:v1:/); expect(stored).not.toContain('super-secret-key'); @@ -71,10 +72,10 @@ describe('AirtrailService — at-rest encryption (3h L3)', () => { it('AIRTRAIL-SVC-ENC-002: a second save with a new key re-encrypts to a different ciphertext that still round-trips', async () => { const { user } = createUser(testDb); await svc.saveSettings(user.id, 'https://airtrail.example.test', 'first-key', false, false, null); - const first = rawAirtrailKey(user.id); + const first = await rawAirtrailKey(user.id); await svc.saveSettings(user.id, 'https://airtrail.example.test', 'second-key', false, false, null); - const second = rawAirtrailKey(user.id); + const second = await rawAirtrailKey(user.id); expect(second).toMatch(/^enc:v1:/); expect(second).not.toBe(first); diff --git a/server/tests/unit/nest/database/unit-of-work.test.ts b/server/tests/unit/nest/database/unit-of-work.test.ts index 328e12df6..3fa8220db 100644 --- a/server/tests/unit/nest/database/unit-of-work.test.ts +++ b/server/tests/unit/nest/database/unit-of-work.test.ts @@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createTrip, createUser } from '../../../helpers/factories'; +import { countRows } from '../../../helpers/factories/rows'; import { Days } from '../../../../src/db/entities/Days.entity'; import type { DaysRepository } from '../../../../src/db/repositories/Days.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; @@ -29,8 +30,7 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -const countDays = (tripId: number) => - (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(tripId) as { n: number }).n; +const countDays = (tripId: number): Promise => countRows(t, Days, { trip: tripId }); describe('UnitOfWork.transactional', () => { it('UOW-001: a throw rolls every statement back', async () => { @@ -42,7 +42,7 @@ describe('UnitOfWork.transactional', () => { throw new Error('boom'); }), ).rejects.toThrow('boom'); - expect(countDays(trip.id)).toBe(0); + expect(await countDays(trip.id)).toBe(0); }); it('UOW-002: an inner transaction is a savepoint — its failure leaves the outer writes intact', async () => { @@ -57,7 +57,7 @@ describe('UnitOfWork.transactional', () => { }) .catch(() => undefined); }); - expect(countDays(trip.id)).toBe(1); + expect(await countDays(trip.id)).toBe(1); }); it('UOW-003: concurrent transactions serialise on the one connection instead of throwing', async () => { @@ -72,7 +72,7 @@ describe('UnitOfWork.transactional', () => { order.push(`${label}:out`); }); await Promise.all([tx('a', 1), tx('b', 2)]); - expect(countDays(trip.id)).toBe(2); + expect(await countDays(trip.id)).toBe(2); // The second never enters before the first leaves. expect(order.indexOf('b:in')).toBeGreaterThan(order.indexOf('a:out')); }); diff --git a/server/tests/unit/nest/google-route.test.ts b/server/tests/unit/nest/google-route.test.ts index 4fd893e3c..2d71aebd0 100644 --- a/server/tests/unit/nest/google-route.test.ts +++ b/server/tests/unit/nest/google-route.test.ts @@ -104,12 +104,14 @@ describe('Google route import', () => { const { service, places, assignments } = await setup(); routeTestDb.exec('CREATE TABLE stops (name TEXT)'); places.create.mockImplementation((_trip, stop) => { + // test-sql-allow: the stops probe table exists only for this test; no entity maps it. routeTestDb.prepare('INSERT INTO stops VALUES (?)').run(stop.name); return { id: stop.name }; }); assignments.createAssignment.mockImplementationOnce(() => ({ dayId: 3, placeId: 'Munich' })).mockImplementationOnce(() => { throw new Error('write failed'); }); try { await expect(service.import(1, 7, input)).rejects.toThrow('write failed'); + // test-sql-allow: reads back the unmapped stops probe table on the same handle. expect(routeTestDb.prepare('SELECT * FROM stops').all()).toEqual([]); expect(places.broadcast).not.toHaveBeenCalled(); expect(assignments.broadcast).not.toHaveBeenCalled(); diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index 0956721d5..15ff70871 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -6,7 +6,7 @@ * the test. The SSRF guard is mocked the same way maps.service.test.ts mocks it. * * Rebuilt on PlaceDetailsCacheRepository/AppSettingsRepository (Plan 3c Task - * 1, R8's rewrite list): the legacy version stubbed `db.prepare(sql).{get,run}` + * 1, R8's rewrite list): the legacy version stubbed the raw `db.prepare` handle (`get`, `run`) * and branched on whether the SQL text contained `'place_details_cache'` to * tell the settings read (PE1) apart from the cache read (PE2)/write (PE3). * That coupling cannot survive the service calling two named repository diff --git a/server/tests/unit/plugins/plugin-guards.test.ts b/server/tests/unit/plugins/plugin-guards.test.ts index 94963d736..93c6640bf 100644 --- a/server/tests/unit/plugins/plugin-guards.test.ts +++ b/server/tests/unit/plugins/plugin-guards.test.ts @@ -16,6 +16,7 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createUser } from '../../helpers/factories'; +import { updateRows } from '../../helpers/factories/rows'; import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; import { Users } from '../../../src/db/entities/Users.entity'; @@ -38,7 +39,7 @@ const ctx = (actingUserId: number | undefined): PluginRpcContext => ({ * Trip 1 belongs to user 42 and only user 42 may reach it, mirroring rpc-host.test.ts. * * `overrides.role` drives the stubbed `UsersRepository.getRole` (PG3/PG4, Plan 3j - * Task 1) the same way it used to drive `db.prepare(...).get()`: omitted means a + * Task 1) the same way it used to drive the raw `db.prepare` stub: omitted means a * normal row with role 'user'; explicitly `undefined` (or `null`) means "no row", * which `getRole`'s own `string | null` contract folds into a single `null` — the * `users` column is `NOT NULL DEFAULT 'user'`, so a present row can never actually @@ -222,7 +223,7 @@ describe('PluginGuards — canEditAs/canCreateAs against a real UsersRepository }); it('PGUARD-025 mutation proof: promoting that SAME member to admin in the real users table flips PGUARD-024 to pass', async () => { - testDb.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', memberId); + await updateRows(orm, Users, { id: memberId }, { role: 'admin' }); expect(await guards.canEditAs('trip_delete', 1, memberId)).toBe(true); }); diff --git a/server/tests/unit/plugins/plugin-routes.controller.test.ts b/server/tests/unit/plugins/plugin-routes.controller.test.ts index f0adb1bd4..b84d1c260 100644 --- a/server/tests/unit/plugins/plugin-routes.controller.test.ts +++ b/server/tests/unit/plugins/plugin-routes.controller.test.ts @@ -32,7 +32,7 @@ function controller(invoke: () => unknown, providers = ['ev-plug']) { } as unknown as PluginHooks; // Plan 3j Task 3: `declaredProfiles` now takes `PluginsRepository`, not the raw // connection — `findCapabilities` reads the same `capabilitiesRow` fixture the - // old `connection.prepare(...).get()` stub above read. + // old raw `connection.prepare` stub above read. const plugins = { findCapabilities: vi.fn(async () => (capabilitiesRow.value === undefined ? null : capabilitiesRow.value)), } as unknown as PluginsRepository; diff --git a/server/tests/unit/plugins/rpc-fallbacks.test.ts b/server/tests/unit/plugins/rpc-fallbacks.test.ts index 2da99a27b..727d9a6aa 100644 --- a/server/tests/unit/plugins/rpc-fallbacks.test.ts +++ b/server/tests/unit/plugins/rpc-fallbacks.test.ts @@ -46,7 +46,7 @@ function guardsFor() { prepare: vi.fn(() => ({ get: () => ({ role: 'user' }), all: () => [] })), } as unknown as TripsRepository; // Plan 3c Task 7: TripsRpc's RP1-RP6 resolve `EntityManager.getRepository(...)` - // now, not `db.prepare(...)` — a generic fake repository (same shape regardless + // now, not a raw `db.prepare` stub — a generic fake repository (same shape regardless // of which entity class was requested) covers every RP method these fallback // cases exercise, none of which assert on the repository's specific return value. const em = { diff --git a/server/tests/unit/services/trekPhotoMedia.test.ts b/server/tests/unit/services/trekPhotoMedia.test.ts index df4b1f1e4..665c3ebee 100644 --- a/server/tests/unit/services/trekPhotoMedia.test.ts +++ b/server/tests/unit/services/trekPhotoMedia.test.ts @@ -24,6 +24,7 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity'; import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { deleteRows } from '../../helpers/factories/rows'; // Was photos.bridge, deleted with the other three that had no consumer outside // the container. These call the repository directly now. @@ -38,8 +39,8 @@ beforeAll(async () => { trekPhotos = new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), t.repo(JourneyPhotos)); }); -beforeEach(() => { - testDb.prepare('DELETE FROM trek_photos').run(); +beforeEach(async () => { + await deleteRows(t, TrekPhotos); }); afterAll(async () => { @@ -49,6 +50,7 @@ afterAll(async () => { describe('trek_photos media_type', () => { it('migration added media_type (default image) and duration_ms', () => { + // test-sql-allow: the column list comes from PRAGMA table_info, which no entity or repository maps. const cols = (testDb.prepare("PRAGMA table_info('trek_photos')").all() as { name: string }[]).map(c => c.name); expect(cols).toContain('media_type'); expect(cols).toContain('duration_ms'); diff --git a/server/tests/unit/trips/trips.rpc.test.ts b/server/tests/unit/trips/trips.rpc.test.ts index 4f2e5cf18..988a1cc4f 100644 --- a/server/tests/unit/trips/trips.rpc.test.ts +++ b/server/tests/unit/trips/trips.rpc.test.ts @@ -77,7 +77,7 @@ export function build(opts: { allow?: (action: string) => boolean; updateThrows? // Plan 3c Task 7: RP1-RP6's SQL-text-keyed `db.prepare` stub is gone — // every raw statement moved to a repository method, so this is a // repository-level stub, keyed on the entity CLASS `em.getRepository(...)` - // was asked for, matching the shape the legacy `db.prepare(sql)` branch + // was asked for, matching the shape the legacy raw `db.prepare` branch // covered: `Trips.findRaw` (RP1/getById), `Places.listForTripOrdered` // (RP2/getPlaces), `TripMembers.listRawUsernameAndDisplayName` // (RP3/members), `Users.getRole` (RP4/update) and `.findIdAndEmail`