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`