From b2f2aef0165beac3f38b9a47d0fef398c04c4089 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 15:25:15 +0200 Subject: [PATCH] test(server): seed the small specs through the repositories The suites with one to three raw statements left now seed and read their rows through the ORM factories instead of hand-written SQLite on the better-sqlite3 handle. A legacy statement that serves as a parity oracle, the schema introspection and the bare backup file keep their raw SQL and say why with a test-sql-allow marker. Two database mocks still answered a getPlaceWithTags the module no longer exports; that dead branch goes too, and the budget seam suite stubs the reservations repository directly instead of a fake prepare(). --- server/tests/e2e/booking-import.e2e.test.ts | 30 ++- server/tests/e2e/mailer.e2e.test.ts | 12 +- server/tests/e2e/place-enrichment.e2e.test.ts | 15 +- server/tests/e2e/plugin-pois.e2e.test.ts | 22 +- .../async-closure-truthiness-guards.test.ts | 4 +- .../atlas-geocode-request-context.test.ts | 15 +- .../doc-sync-webhook-request-context.test.ts | 58 ++-- .../integration/feeds-request-context.test.ts | 8 +- .../orm-request-context-seams.test.ts | 21 +- server/tests/integration/vacay.test.ts | 16 +- server/tests/unit/db/connection.test.ts | 19 +- .../CollabMessages.repository.test.ts | 40 +-- .../PhotoProviderFields.repository.test.ts | 80 +++--- .../PluginOauthTokens.repository.test.ts | 19 +- .../VacayPlans.repository.test.ts | 16 +- .../reservation-travelers-owns.parity.test.ts | 14 +- .../addons-config-encryption.test.ts | 17 +- .../tests/unit/mcp/tools-plugin-pois.test.ts | 28 +- .../unit/nest/backup-archive-version.test.ts | 1 + server/tests/unit/nest/budget.service.test.ts | 73 ++--- .../tests/unit/nest/dawarich.service.test.ts | 250 ++++++++---------- server/tests/unit/nest/day-boundaries.test.ts | 3 +- .../tests/unit/nest/places.directions.test.ts | 14 +- .../tests/unit/nest/roadtrip.service.test.ts | 11 +- .../storage/storage-admin.service.test.ts | 54 ++-- .../tests/unit/nest/thumbnail.service.test.ts | 14 +- .../plugin-runtime.boot-no-orm.test.ts | 23 +- .../unit/plugins/rpc-host-inv-strip.test.ts | 2 +- .../unit/services/conflictUpdate.test.ts | 13 +- 29 files changed, 489 insertions(+), 403 deletions(-) diff --git a/server/tests/e2e/booking-import.e2e.test.ts b/server/tests/e2e/booking-import.e2e.test.ts index 1ffd1b6f7..b7ccdb02a 100644 --- a/server/tests/e2e/booking-import.e2e.test.ts +++ b/server/tests/e2e/booking-import.e2e.test.ts @@ -47,7 +47,11 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm } from '../helpers/test-orm'; +import { makeUser } from '../helpers/factories/users'; +import { makeTrip } from '../helpers/factories/trips'; +import { upsertRow } from '../helpers/factories/rows'; +import { Addons } from '../../src/db/entities/Addons.entity'; /** A one-page PDF whose text layer holds `lines`, in the standard Helvetica. */ function pdfWithText(lines: string[]): Buffer { @@ -134,15 +138,23 @@ describe('Booking import e2e (#2477): a schema-bound provider on the upload rout }; beforeAll(async () => { - db.prepare( - "INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)", - ).run(); - tripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'Albania')").run().lastInsertRowid); + const orm = await createTestOrm(db); + await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 }); + tripId = (await makeTrip(orm, 1, { title: 'Albania' })).id; // The instance config the reporter ran: Gemini behind the "openai" provider. - db.prepare( - `INSERT INTO addons (id, name, type, enabled, config) VALUES ('llm_parsing', 'AI Parsing', 'integration', 1, ?) - ON CONFLICT(id) DO UPDATE SET enabled = 1, config = excluded.config`, - ).run(JSON.stringify({ provider: 'openai', model: 'gemini-3.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' })); + await upsertRow( + orm, + Addons, + { + id: 'llm_parsing', + name: 'AI Parsing', + type: 'integration', + enabled: true, + config: { provider: 'openai', model: 'gemini-3.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' }, + }, + ['enabled', 'config'], + ); + await orm.close(); app = await build(); vi.spyOn(app.get(PermissionsService), 'checkPermission').mockResolvedValue(true); server = app.getHttpServer(); diff --git a/server/tests/e2e/mailer.e2e.test.ts b/server/tests/e2e/mailer.e2e.test.ts index f7526079e..856b37e39 100644 --- a/server/tests/e2e/mailer.e2e.test.ts +++ b/server/tests/e2e/mailer.e2e.test.ts @@ -58,7 +58,8 @@ import { db } from '../../src/db/database'; import { createUser } from '../helpers/factories'; import { AuthModule } from '../../src/nest/auth/auth.module'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm } from '../helpers/test-orm'; +import { setAppSetting } from '../helpers/factories/settings'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -105,10 +106,11 @@ describe('Mailer e2e: the header logo in a mail on the wire (#2507)', () => { beforeAll(async () => { email = createUser(db as never, { username: 'mail-e2e', email: 'mail-e2e@example.test' }).user.email; - const setting = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)'); - setting.run('smtp_host', 'mail.internal.example'); - setting.run('smtp_port', '587'); - setting.run('smtp_from', 'trek@example.test'); + const orm = await createTestOrm(db); + await setAppSetting(orm, 'smtp_host', 'mail.internal.example'); + await setAppSetting(orm, 'smtp_port', '587'); + await setAppSetting(orm, 'smtp_from', 'trek@example.test'); + await orm.close(); app = await build(); server = app.getHttpServer(); diff --git a/server/tests/e2e/place-enrichment.e2e.test.ts b/server/tests/e2e/place-enrichment.e2e.test.ts index 545dfd133..7ba00366f 100644 --- a/server/tests/e2e/place-enrichment.e2e.test.ts +++ b/server/tests/e2e/place-enrichment.e2e.test.ts @@ -37,7 +37,11 @@ import { RateLimitService } from '../../src/nest/common/rate-limit.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { deleteRows } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; +import { PlaceDetailsCache } from '../../src/db/entities/PlaceDetailsCache.entity'; const BODY = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'way:12345' }; @@ -45,6 +49,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () => let server: Server; let app: Awaited>; let maps: MapsService; + let orm: TestOrm; async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), PlaceEnrichmentModule] }).compile(); @@ -60,6 +65,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () => beforeAll(async () => { seedUser(db as never, { id: 1 }); + orm = await createTestOrm(db); app = await build(); server = app.getHttpServer(); @@ -75,11 +81,12 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () => afterAll(async () => { await app.close(); + await orm.close(); }); beforeEach(async () => { - db.prepare('DELETE FROM app_settings').run(); - db.prepare('DELETE FROM place_details_cache').run(); + await deleteRows(orm, AppSettings); + await deleteRows(orm, PlaceDetailsCache); await app.get(RateLimitService).reset('place_enrichment'); // spyOn hands back the same spy on a second call, so the call counts carry // over between tests unless they are cleared explicitly. @@ -159,7 +166,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () => }); it('200 with the disabled envelope once an admin switches enrichment off', async () => { - db.prepare("INSERT INTO app_settings (key, value) VALUES ('places_enrich_enabled', 'false')").run(); + await setAppSetting(orm, 'places_enrich_enabled', 'false'); const res = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY); diff --git a/server/tests/e2e/plugin-pois.e2e.test.ts b/server/tests/e2e/plugin-pois.e2e.test.ts index 000355406..e620afd25 100644 --- a/server/tests/e2e/plugin-pois.e2e.test.ts +++ b/server/tests/e2e/plugin-pois.e2e.test.ts @@ -42,7 +42,9 @@ vi.mock('../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => plu import { db } from '../../src/db/database'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm } from '../helpers/test-orm'; +import { makeUser } from '../helpers/factories/users'; +import { makePlugin } from '../helpers/factories/plugins'; const trailheads = { id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' }; const query = { pluginId: 'trail-finder', category: 'trailheads', south: '47', west: '11', north: '47.5', east: '11.5', lang: 'de' }; @@ -66,14 +68,16 @@ describe('Plugin POIs e2e (real guard chain + temp SQLite)', () => { } beforeAll(async () => { - // harness.ts's seedUser() omits password_hash, which the migrated schema - // requires NOT NULL (addons.e2e.test.ts does the same) — the same SeededUser - // shape sessionCookie(1) needs, with the hash filled in. - db.prepare( - "INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)", - ).run(); - db.prepare("INSERT INTO plugins (id, name, status, capabilities, granted_permissions) VALUES (?, ?, 'active', ?, ?)") - .run('trail-finder', 'Trail Finder', JSON.stringify({ poiCategories: [trailheads] }), JSON.stringify(['hook:poi-category-provider'])); + // The user sessionCookie(1) is signed for, pinned to id 1. + const orm = await createTestOrm(db); + await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 }); + await makePlugin(orm, 'trail-finder', { + name: 'Trail Finder', + status: 'active', + capabilities: JSON.stringify({ poiCategories: [trailheads] }), + granted_permissions: JSON.stringify(['hook:poi-category-provider']), + }); + await orm.close(); app = await build(); const hooks = app.get(PluginHooks, { strict: false }); providersOf = vi.spyOn(hooks, 'providersOf'); diff --git a/server/tests/integration/async-closure-truthiness-guards.test.ts b/server/tests/integration/async-closure-truthiness-guards.test.ts index 8ab7bfc0a..a05285ca9 100644 --- a/server/tests/integration/async-closure-truthiness-guards.test.ts +++ b/server/tests/integration/async-closure-truthiness-guards.test.ts @@ -81,6 +81,7 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import type { INestApplication } from '@nestjs/common'; +import { MikroORM } from '@mikro-orm/core'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); @@ -103,6 +104,7 @@ import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../helpers/factories'; import { generateToken } from '../helpers/auth'; +import { setAddonEnabled } from '../helpers/factories/settings'; describe('R1 async-closure truthiness guards (RB2/RS21/RS23) — driven through the real routes', () => { let app: INestApplication; @@ -111,7 +113,7 @@ describe('R1 async-closure truthiness guards (RB2/RS21/RS23) — driven through beforeAll(async () => { app = await buildApp(); httpApp = app.getHttpAdapter().getInstance() as Application; - testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'roadtrip'").run(); + await setAddonEnabled(app.get(MikroORM), 'roadtrip', true); }); afterAll(async () => { diff --git a/server/tests/integration/atlas-geocode-request-context.test.ts b/server/tests/integration/atlas-geocode-request-context.test.ts index 29062e514..549c31dfe 100644 --- a/server/tests/integration/atlas-geocode-request-context.test.ts +++ b/server/tests/integration/atlas-geocode-request-context.test.ts @@ -53,9 +53,15 @@ import { resetTestDb } from '../helpers/test-db'; import { createUser, createTrip, createPlace } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { PlaceRegionsRepository } from '../../src/db/repositories/PlaceRegions.repository'; +import { PlaceRegions } from '../../src/db/entities/PlaceRegions.entity'; +import { findRow, findRows } from '../helpers/factories/rows'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { MikroORM } from '@mikro-orm/core'; let nestApp: INestApplication; let app: Application; +/** The app's own ORM, for reading back what the background geocode wrote. */ +const orm = (): FactoryOrm => nestApp.get(MikroORM); const errors: unknown[] = []; beforeAll(async () => { const orig = PlaceRegionsRepository.prototype.upsertRegionWhileUnmoved; @@ -83,10 +89,10 @@ describe('AT4/AT29 background geocode request context', () => { const p = createPlace(testDb, trip.id, { lat: 48.85, lng: 2.35 }); const res = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); - expect(testDb.prepare('SELECT * FROM place_regions WHERE place_id=?').get(p.id)).toBeUndefined(); + expect(await findRow(orm(), PlaceRegions, { place: p.id })).toBeNull(); await new Promise((r) => setTimeout(r, 2500)); expect(errors).toEqual([]); - expect(testDb.prepare('SELECT * FROM place_regions WHERE place_id=?').get(p.id)).toMatchObject({ country_code: 'FR', region_code: 'FR-IDF' }); + expect(await findRow(orm(), PlaceRegions, { place: p.id })).toMatchObject({ country_code: 'FR', region_code: 'FR-IDF' }); }, 15_000); it('IIFE-CTX-AT29 — /regions IIFE writes two places sequentially (2nd after ~2.4s)', async () => { @@ -99,7 +105,10 @@ describe('AT4/AT29 background geocode request context', () => { expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 3500)); expect(errors).toEqual([]); - const rows = testDb.prepare('SELECT place_id, region_code FROM place_regions ORDER BY place_id').all(); + const rows = (await findRows(orm(), PlaceRegions, {}, { place: 'asc' })).map((r) => ({ + place_id: r.place_id, + region_code: r.region_code, + })); expect(rows).toEqual([ { place_id: p1.id, region_code: 'FR-IDF' }, { place_id: p2.id, region_code: 'FR-PAC' }, diff --git a/server/tests/integration/doc-sync-webhook-request-context.test.ts b/server/tests/integration/doc-sync-webhook-request-context.test.ts index d40db24e0..333196dda 100644 --- a/server/tests/integration/doc-sync-webhook-request-context.test.ts +++ b/server/tests/integration/doc-sync-webhook-request-context.test.ts @@ -31,12 +31,14 @@ * callback it schedules (`() => config.getLink(linkId)`) is what needs the * fork, and it only runs once the timer fires, inside `schedule()`'s own * `withRequestContext` — exactly the wrap under test. The link id comes from - * a raw `testDb` read (no ORM, no context needed) rather than through - * `nudge()`/`getLinkByToken`, so nothing before the `schedule()` call ever - * touches the EntityManager. + * the fixture `beforeAll` seeds, whose factory calls each open and close a + * request context of their own, rather than through + * `nudge()`/`getLinkByToken`, so nothing in the test body before the + * `schedule()` call ever touches the EntityManager. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import type { INestApplication } from '@nestjs/common'; +import { MikroORM } from '@mikro-orm/core'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); @@ -56,6 +58,11 @@ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi. import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; import { createUser, createTrip } from '../helpers/factories'; +import { insertRow, updateRows } from '../helpers/factories/rows'; +import { setAddonEnabled } from '../helpers/factories/settings'; +import { DocumentConnections } from '../../src/db/entities/DocumentConnections.entity'; +import { DocumentProviders } from '../../src/db/entities/DocumentProviders.entity'; +import { TripDocumentLinks } from '../../src/db/entities/TripDocumentLinks.entity'; import { WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from '../../src/nest/doc-sync/doc-sync.constants'; import { DocSyncWebhookController } from '../../src/nest/doc-sync/doc-sync-webhook.controller'; import { DocSyncConfigService } from '../../src/nest/doc-sync/doc-sync-config.service'; @@ -74,28 +81,35 @@ describe('DocSyncWebhookController#schedule() runs its detached timer body insid // The addon gate and the provider both have to be genuinely on: schedule()'s // own reload callback (`syncIsOn`) skips the run otherwise, before ever // reaching the assertion below. - testDb.exec("UPDATE addons SET enabled = 1 WHERE id = 'documents'"); - testDb.exec("UPDATE document_providers SET enabled = 1 WHERE id = 'paperless'"); + const orm = app.get(MikroORM); + await setAddonEnabled(orm, 'documents', true); + await updateRows(orm, DocumentProviders, { id: 'paperless' }, { enabled: 1 }); const owner = createUser(testDb, { username: 'docsync-webhook-ctx', email: 'docsync-webhook-ctx@test.local' }).user; const trip = createTrip(testDb, owner.id, { title: 'Japan' }); - const connInfo = testDb - .prepare( - `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings) - VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`, - ) - .run(trip.id, owner.id); - const linkInfo = testDb - .prepare( - `INSERT INTO trip_document_links - (trip_id, connection_id, provider_id, remote_scope_key, remote_label, direction, delete_policy, - conflict_policy, sync_enabled, webhook_token, created_by) - VALUES (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, 'webhook-ctx-ratchet-token', ?)`, - ) - .run(trip.id, connInfo.lastInsertRowid, owner.id); - // A plain better-sqlite3 read — no ORM, no request context — so nothing - // before the `schedule()` call below ever touches the EntityManager. - linkId = Number(linkInfo.lastInsertRowid); + const connectionId = await insertRow(orm, DocumentConnections, { + trip: trip.id, + provider: 'paperless', + ownerUser: owner.id, + base_url: 'https://paperless.example.com', + secrets: null, + settings: '{}', + }); + // Each factory call above closed its own request context before returning, + // so the test body below starts with no EntityManager fork around it. + linkId = await insertRow(orm, TripDocumentLinks, { + trip: trip.id, + connection: connectionId, + provider_id: 'paperless', + remote_scope_key: 'tag:1', + remote_label: 'Japan', + direction: 'both', + delete_policy: 'unlink', + conflict_policy: 'manual', + sync_enabled: 1, + webhook_token: 'webhook-ctx-ratchet-token', + createdByRef: owner.id, + }); }); afterAll(async () => { diff --git a/server/tests/integration/feeds-request-context.test.ts b/server/tests/integration/feeds-request-context.test.ts index 37d884b42..5530e5a7b 100644 --- a/server/tests/integration/feeds-request-context.test.ts +++ b/server/tests/integration/feeds-request-context.test.ts @@ -67,7 +67,9 @@ import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; import { CalendarService } from '../../src/nest/calendar/calendar.service'; import { Trips } from '../../src/db/entities/Trips.entity'; +import { Users } from '../../src/db/entities/Users.entity'; import { createTrip, createUser } from '../helpers/factories'; +import { updateRows } from '../helpers/factories/rows'; describe('Anonymous ICS feed routes run inside a request context', () => { let app: INestApplication; @@ -86,7 +88,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Context-Proof Trip' }); const token = randomUUID(); - testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id); + await updateRows(app.get(MikroORM), Trips, { id: trip.id }, { feed_token: token }); let repoRead: unknown; let caught: unknown; @@ -136,7 +138,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Mutation-Proof Trip' }); const token = randomUUID(); - testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id); + await updateRows(app.get(MikroORM), Trips, { id: trip.id }, { feed_token: token }); const calendar = app.get(CalendarService); const spy = vi.spyOn(calendar, 'buildTripCalendar').mockImplementation(async () => { @@ -184,7 +186,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => { const { user } = createUser(testDb); createTrip(testDb, user.id, { title: 'User-Feed Context-Proof Trip' }); const token = randomUUID(); - testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, user.id); + await updateRows(app.get(MikroORM), Users, { id: user.id }, { feed_token: token }); let repoRead: unknown; let caught: unknown; diff --git a/server/tests/integration/orm-request-context-seams.test.ts b/server/tests/integration/orm-request-context-seams.test.ts index 8a2e306d6..2c0bc8d00 100644 --- a/server/tests/integration/orm-request-context-seams.test.ts +++ b/server/tests/integration/orm-request-context-seams.test.ts @@ -58,6 +58,8 @@ import { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.serv import { PlaceShadowRetentionJob } from '../../src/nest/place-shadow/place-shadow.job'; import { StorageService } from '../../src/nest/storage/storage.service'; import { Users } from '../../src/db/entities/Users.entity'; +import { PlaceShadowPicks } from '../../src/db/entities/PlaceShadowPicks.entity'; +import { countRows, insertRow } from '../helpers/factories/rows'; import { createUser } from '../helpers/factories'; import { generateToken } from '../helpers/auth'; @@ -161,10 +163,18 @@ describe('ORM request-context seams populated in production', () => { const isEnabledSpy = vi.spyOn(registrar, 'isEnabled').mockReturnValue(true); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { - testDb.prepare(` - INSERT INTO place_shadow_picks (created_at, query, source, live_rank, live_count, picked_name, picked_lat, picked_lng) - VALUES (datetime('now', '-999 days'), 'seam-003', 'nominatim', 1, 1, 'expired pick', 0, 0) - `).run(); + // 999 days old, in the `datetime('now')` text form the column defaults to. + const expiredAt = new Date(Date.now() - 999 * 86_400_000).toISOString().slice(0, 19).replace('T', ' '); + await insertRow(orm, PlaceShadowPicks, { + created_at: expiredAt, + query: 'seam-003', + source: 'nominatim', + live_rank: 1, + live_count: 1, + picked_name: 'expired pick', + picked_lat: 0, + picked_lng: 0, + }); // Real bug found converting `purgeExpired` to a genuinely async // repository call (0b security review F-B4): the `cron` package's @@ -199,8 +209,7 @@ describe('ORM request-context seams populated in production', () => { // `withRequestContext` wrap in `cron-registrar.service.ts`'s `register()` // makes the repository call throw inside `PlaceShadowRetentionJob.tick`'s // own try/catch, and the row survives (verified by hand, reverted). - const remaining = testDb.prepare("SELECT COUNT(*) as n FROM place_shadow_picks WHERE query = 'seam-003'").get() as { n: number }; - expect(remaining.n).toBe(0); + expect(await countRows(orm, PlaceShadowPicks, { query: 'seam-003' })).toBe(0); } finally { registrar.unregister('place-shadow-retention'); isEnabledSpy.mockRestore(); diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts index ee2f1414a..6067b313f 100644 --- a/server/tests/integration/vacay.test.ts +++ b/server/tests/integration/vacay.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; +import { MikroORM } from '@mikro-orm/core'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); @@ -44,15 +45,26 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { findRows } from '../helpers/factories/rows'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { VacayHolidayCalendars } from '../../src/db/entities/VacayHolidayCalendars.entity'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); +/** The id of the holiday calendar created last. */ +async function latestCalendarId(): Promise { + const [latest] = await findRows(orm, VacayHolidayCalendars, {}, { id: 'desc' }); + return latest?.id; +} + beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); @@ -299,7 +311,7 @@ describe('Vacay holiday calendar CRUD', () => { .send({ region: 'US', label: 'US Holidays' }); expect(createRes.status).toBe(200); const calId = createRes.body.plan?.holiday_calendars?.at(-1)?.id - ?? (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id; + ?? (await latestCalendarId()); const res = await request(app) .put(`/api/addons/vacay/plan/holiday-calendars/${calId}`) @@ -317,7 +329,7 @@ describe('Vacay holiday calendar CRUD', () => { .set('Cookie', authCookie(user.id)) .send({ region: 'FR', label: 'French Holidays' }); expect(createRes.status).toBe(200); - const calId = (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id; + const calId = await latestCalendarId(); const res = await request(app) .delete(`/api/addons/vacay/plan/holiday-calendars/${calId}`) diff --git a/server/tests/unit/db/connection.test.ts b/server/tests/unit/db/connection.test.ts index 9aa8684d0..65d80a492 100644 --- a/server/tests/unit/db/connection.test.ts +++ b/server/tests/unit/db/connection.test.ts @@ -17,6 +17,10 @@ import { createTrip, createUser, } from '../../helpers/factories'; +import { readTripDays } from '../../helpers/factories/trips'; +import { countRows } from '../../helpers/factories/rows'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; // #2518: a container with a read-only root filesystem and no tmpfs on /tmp // gives SQLite nowhere to put a temp file, so deleting a trip failed with @@ -124,7 +128,8 @@ async function deleteTripAndWatchTempFiles(db: Database.Database): Promise<{ start_date: '2026-06-01', end_date: '2026-06-14', }); - const days = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; + const t = await createTestOrm(db); + const days = await readTripDays(t, trip.id); for (let i = 0; i < 25; i++) { const place = createPlace(db, trip.id, { name: `Ort ${i}`, description: 'Grachten und Tulpen. '.repeat(10) }); createDayAssignment(db, days[i % days.length].id, place.id); @@ -141,19 +146,17 @@ async function deleteTripAndWatchTempFiles(db: Database.Database): Promise<{ db.exec('CREATE TRIGGER trek_watch_trip_delete AFTER DELETE ON trips BEGIN SELECT trek_watch_temp_files(); END'); const none = undefined as never; - const t = await createTestOrm(db); try { const trips = new TripsService(none, none, none, none, none, none, none, none, new UnitOfWork(t.em), t.em, none); await trips.remove(trip.id, user.id, 'user'); + return { + tempFiles: [...seen], + tripLeft: await countRows(t, Trips, { id: trip.id }), + placesLeft: await countRows(t, Places, { trip: trip.id }), + }; } finally { await t.close(); } - - return { - tempFiles: [...seen], - tripLeft: (db.prepare('SELECT COUNT(*) AS n FROM trips WHERE id = ?').get(trip.id) as { n: number }).n, - placesLeft: (db.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id) as { n: number }).n, - }; } describe.runIf(canSeeTempFiles)('deleting a trip with content (#2518)', () => { diff --git a/server/tests/unit/db/repositories/CollabMessages.repository.test.ts b/server/tests/unit/db/repositories/CollabMessages.repository.test.ts index ccc0698d5..3ac953bfc 100644 --- a/server/tests/unit/db/repositories/CollabMessages.repository.test.ts +++ b/server/tests/unit/db/repositories/CollabMessages.repository.test.ts @@ -14,32 +14,42 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTrip, createUser } from '../../../helpers/factories'; import { createTestCollabMessagesRepo } from '../../../helpers/collab-repos'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { makeCollabMessage } from '../../../helpers/factories/collab'; +import { updateRows } from '../../../helpers/factories/rows'; +import { Users } from '../../../../src/db/entities/Users.entity'; import type { CollabMessagesRepository } from '../../../../src/db/repositories/CollabMessages.repository'; const testDb = createSnapshotTestDb(); let collabMessagesRepo: CollabMessagesRepository; +let t: TestOrm; beforeAll(async () => { collabMessagesRepo = await createTestCollabMessagesRepo(testDb); + t = await createTestOrm(testDb); }); beforeEach(() => resetTestDb(testDb)); -afterAll(() => testDb.close()); +afterAll(async () => { + await t.close(); + testDb.close(); +}); -function insertMessage( +async function insertMessage( tripId: number, userId: number, overrides: Partial<{ text: string; reply_to: number | null; deleted: number; created_at: string }> = {}, -): number { - const result = testDb.prepare( - 'INSERT INTO collab_messages (trip_id, user_id, text, reply_to, deleted, created_at) VALUES (?, ?, ?, ?, ?, ?)', - ).run( - tripId, userId, overrides.text ?? 'hi', overrides.reply_to ?? null, overrides.deleted ?? 0, - overrides.created_at ?? '2026-09-01T00:00:00.000Z', - ); - return Number(result.lastInsertRowid); +): Promise { + const message = await makeCollabMessage(t, tripId, userId, { + text: overrides.text ?? 'hi', + replyToRef: overrides.reply_to ?? null, + deleted: overrides.deleted ?? 0, + created_at: overrides.created_at ?? '2026-09-01T00:00:00.000Z', + }); + return message.id; } function legacyPublicForShare(tripId: number): unknown { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. return testDb.prepare( 'SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at ASC', ).all(tripId); @@ -48,15 +58,15 @@ function legacyPublicForShare(tripId: number): unknown { describe('CollabMessagesRepository — share.service.ts SH16 read', () => { it('listPublicForShare — matches the legacy statement, deleted excluded, every nullable column both null and set', async () => { const { user: author } = createUser(testDb, { username: 'alice' }); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('alice.png', author.id); + await updateRows(t, Users, { id: author.id }, { avatar: 'alice.png' }); const { user: replier } = createUser(testDb, { username: 'bob' }); const trip = createTrip(testDb, author.id); const other = createTrip(testDb, author.id); - const rootId = insertMessage(trip.id, author.id, { text: 'root', reply_to: null, created_at: '2026-09-01T10:00:00.000Z' }); - const replyId = insertMessage(trip.id, replier.id, { text: 'reply', reply_to: rootId, created_at: '2026-09-01T11:00:00.000Z' }); - insertMessage(trip.id, author.id, { text: 'gone', deleted: 1, created_at: '2026-09-01T12:00:00.000Z' }); - insertMessage(other.id, author.id, { text: 'foreign' }); + const rootId = await insertMessage(trip.id, author.id, { text: 'root', reply_to: null, created_at: '2026-09-01T10:00:00.000Z' }); + const replyId = await insertMessage(trip.id, replier.id, { text: 'reply', reply_to: rootId, created_at: '2026-09-01T11:00:00.000Z' }); + await insertMessage(trip.id, author.id, { text: 'gone', deleted: 1, created_at: '2026-09-01T12:00:00.000Z' }); + await insertMessage(other.id, author.id, { text: 'foreign' }); const legacy = legacyPublicForShare(trip.id); const typed = await collabMessagesRepo.listPublicForShare(trip.id); diff --git a/server/tests/unit/db/repositories/PhotoProviderFields.repository.test.ts b/server/tests/unit/db/repositories/PhotoProviderFields.repository.test.ts index 631c7ad1c..20881ad71 100644 --- a/server/tests/unit/db/repositories/PhotoProviderFields.repository.test.ts +++ b/server/tests/unit/db/repositories/PhotoProviderFields.repository.test.ts @@ -3,6 +3,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { PhotoProviderFields } from '../../../../src/db/entities/PhotoProviderFields.entity'; +import { PhotoProviders } from '../../../../src/db/entities/PhotoProviders.entity'; +import { deleteRows, insertRow } from '../../../helpers/factories/rows'; import type { PhotoProviderFieldsRepository } from '../../../../src/db/repositories/PhotoProviderFields.repository'; const testDb = createSnapshotTestDb(); @@ -13,10 +15,10 @@ beforeAll(async () => { t = await createTestOrm(testDb); fields = t.repo(PhotoProviderFields); }); -beforeEach(() => { +beforeEach(async () => { resetTestDb(testDb); - testDb.exec('DELETE FROM photo_provider_fields'); - testDb.exec('DELETE FROM photo_providers'); + await deleteRows(t, PhotoProviderFields); + await deleteRows(t, PhotoProviders); t.clear(); }); afterAll(async () => { @@ -24,11 +26,11 @@ afterAll(async () => { testDb.close(); }); -function insertProvider(id: string): void { - testDb.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES (?, ?, 'Image', 1, 0)").run(id, id); +async function insertProvider(id: string): Promise { + await insertRow(t, PhotoProviders, { id, name: id, icon: 'Image', enabled: 1, sort_order: 0 }); } -function insertField(row: { +async function insertField(row: { provider_id: string; field_key: string; label: string; @@ -40,35 +42,29 @@ function insertField(row: { settings_key?: string | null; payload_key?: string | null; sort_order?: number; -}): void { - testDb - .prepare( - `INSERT INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - row.provider_id, - row.field_key, - row.label, - row.input_type ?? 'text', - row.placeholder ?? null, - row.hint ?? null, - row.required ?? 0, - row.secret ?? 0, - row.settings_key ?? null, - row.payload_key ?? null, - row.sort_order ?? 0, - ); +}): Promise { + await insertRow(t, PhotoProviderFields, { + provider: row.provider_id, + field_key: row.field_key, + label: row.label, + input_type: row.input_type ?? 'text', + placeholder: row.placeholder ?? null, + hint: row.hint ?? null, + required: row.required ?? 0, + secret: row.secret ?? 0, + settings_key: row.settings_key ?? null, + payload_key: row.payload_key ?? null, + sort_order: row.sort_order ?? 0, + }); } describe('PhotoProviderFieldsRepository.listAllOrdered', () => { it('ADDONSPPFREPO-001: returns every field, ordered by sort_order then id, across all providers', async () => { - insertProvider('immich'); - insertProvider('synology'); - insertField({ provider_id: 'synology', field_key: 'url', label: 'URL', sort_order: 1 }); - insertField({ provider_id: 'immich', field_key: 'url', label: 'URL', sort_order: 0 }); - insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', sort_order: 0 }); + await insertProvider('immich'); + await insertProvider('synology'); + await insertField({ provider_id: 'synology', field_key: 'url', label: 'URL', sort_order: 1 }); + await insertField({ provider_id: 'immich', field_key: 'url', label: 'URL', sort_order: 0 }); + await insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', sort_order: 0 }); const rows = await fields.listAllOrdered(); // Two rows share sort_order 0 — insertion order (ascending id) breaks the tie. @@ -80,15 +76,15 @@ describe('PhotoProviderFieldsRepository.listAllOrdered', () => { }); it('ADDONSPPFREPO-002: the provider_id twin reads back from find(), same as trip_id on Days', async () => { - insertProvider('immich'); - insertField({ provider_id: 'immich', field_key: 'url', label: 'URL' }); + await insertProvider('immich'); + await insertField({ provider_id: 'immich', field_key: 'url', label: 'URL' }); const [row] = await fields.listAllOrdered(); expect(row.provider_id).toBe('immich'); }); it('ADDONSPPFREPO-003: required/secret come back as the raw stored integer, matching the entity type (not a coerced boolean)', async () => { - insertProvider('immich'); - insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', required: 1, secret: 1 }); + await insertProvider('immich'); + await insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', required: 1, secret: 1 }); const [row] = await fields.listAllOrdered(); expect(row.required).toBe(1); expect(row.secret).toBe(1); @@ -96,8 +92,8 @@ describe('PhotoProviderFieldsRepository.listAllOrdered', () => { }); it('ADDONSPPFREPO-004: nullable columns (placeholder, hint, settings_key, payload_key) come back null when unset', async () => { - insertProvider('immich'); - insertField({ provider_id: 'immich', field_key: 'k', label: 'L' }); + await insertProvider('immich'); + await insertField({ provider_id: 'immich', field_key: 'k', label: 'L' }); const [row] = await fields.listAllOrdered(); expect(row.placeholder).toBeNull(); expect(row.hint).toBeNull(); @@ -124,11 +120,11 @@ describe('PhotoProviderFieldsRepository.listAllOrderedForAdminShelf', () => { // just `provider_id`, so a future narrowed-projection regression on any // other column fails here too. it('AD28: returns the full legacy row shape, ordered by sort_order then id, across all providers', async () => { - insertProvider('immich'); - insertProvider('synology'); - insertField({ provider_id: 'synology', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://photos.example.com', hint: 'ignored: not in the AD28 shape', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'url', sort_order: 1 }); - insertField({ provider_id: 'immich', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'url', sort_order: 0 }); - insertField({ provider_id: 'immich', field_key: 'api_key', label: 'API Key', input_type: 'password', required: 1, secret: 1, settings_key: 'immich_api_key', payload_key: 'apiKey', sort_order: 1 }); + await insertProvider('immich'); + await insertProvider('synology'); + await insertField({ provider_id: 'synology', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://photos.example.com', hint: 'ignored: not in the AD28 shape', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'url', sort_order: 1 }); + await insertField({ provider_id: 'immich', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'url', sort_order: 0 }); + await insertField({ provider_id: 'immich', field_key: 'api_key', label: 'API Key', input_type: 'password', required: 1, secret: 1, settings_key: 'immich_api_key', payload_key: 'apiKey', sort_order: 1 }); const rows = await fields.listAllOrderedForAdminShelf(); // sort_order 0 first; the sort_order-1 tie breaks by id (insertion order: diff --git a/server/tests/unit/db/repositories/PluginOauthTokens.repository.test.ts b/server/tests/unit/db/repositories/PluginOauthTokens.repository.test.ts index 0e0faac01..10a9f15c3 100644 --- a/server/tests/unit/db/repositories/PluginOauthTokens.repository.test.ts +++ b/server/tests/unit/db/repositories/PluginOauthTokens.repository.test.ts @@ -17,6 +17,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { PluginOauthTokens } from '../../../../src/db/entities/PluginOauthTokens.entity'; import type { PluginOauthTokensRepository } from '../../../../src/db/repositories/PluginOauthTokens.repository'; import { currentTimestampKysely } from '../../../../src/db/dialect/kysely-functions'; +import { findRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -30,9 +31,7 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); function storedRow(pluginId: string, userId: number) { - return testDb - .prepare('SELECT plugin_id, user_id, access_token, refresh_token, expires_at, scope, updated_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?') - .get(pluginId, userId) as { plugin_id: string; user_id: number; access_token: string | null; refresh_token: string | null; expires_at: number | null; scope: string | null; updated_at: string } | undefined; + return findRow(t, PluginOauthTokens, { plugin_id: pluginId, user_id: userId }); } describe('PluginOauthTokensRepository', () => { @@ -75,12 +74,12 @@ describe('PluginOauthTokensRepository', () => { // Seed an existing token row with a non-null refresh_token — the state a // previously-connected user is in when a later refresh comes back without one. await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-1', refresh_token: 'RT-original', expires_at: 1000, scope: 'read' }); - expect(storedRow('p', 42)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-original' }); + expect(await storedRow('p', 42)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-original' }); // A refresh response that omits refresh_token — the repository call the // service makes when it has NOT pre-filled the field itself. await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-2', refresh_token: null, expires_at: 2000, scope: 'read write' }); - const row = storedRow('p', 42)!; + const row = (await storedRow('p', 42))!; // access_token/expires_at/scope always take the new value (plain excluded.* reassignment)... expect(row.access_token).toBe('AT-2'); expect(row.expires_at).toBe(2000); @@ -93,14 +92,14 @@ describe('PluginOauthTokensRepository', () => { it('PO11REPO-003 (R-oauth-upsert, the other COALESCE direction): a refresh response that DOES supply a new refresh_token overwrites the stored one', async () => { await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-1', refresh_token: 'RT-original', expires_at: 1000, scope: 'read' }); await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-2', refresh_token: 'RT-rotated', expires_at: 2000, scope: 'read' }); - const row = storedRow('p', 42)!; + const row = (await storedRow('p', 42))!; expect(row.refresh_token).toBe('RT-rotated'); }); it('PO11REPO-004: a brand-new row (no prior state) inserts plainly — no COALESCE fallback target to speak of yet', async () => { - expect(storedRow('fresh', 7)).toBeUndefined(); + expect(await storedRow('fresh', 7)).toBeNull(); await repo.storeToken({ plugin_id: 'fresh', user_id: 7, access_token: 'AT', refresh_token: null, expires_at: null, scope: null }); - const row = storedRow('fresh', 7)!; + const row = (await storedRow('fresh', 7))!; expect(row.access_token).toBe('AT'); expect(row.refresh_token).toBeNull(); }); @@ -108,8 +107,8 @@ describe('PluginOauthTokensRepository', () => { it('PO11REPO-005: the composite key isolates rows — a second user of the same plugin gets its own row, the first is untouched', async () => { await repo.storeToken({ plugin_id: 'p', user_id: 1, access_token: 'AT-1', refresh_token: 'RT-1', expires_at: null, scope: null }); await repo.storeToken({ plugin_id: 'p', user_id: 2, access_token: 'AT-2', refresh_token: 'RT-2', expires_at: null, scope: null }); - expect(storedRow('p', 1)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-1' }); - expect(storedRow('p', 2)).toMatchObject({ access_token: 'AT-2', refresh_token: 'RT-2' }); + expect(await storedRow('p', 1)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-1' }); + expect(await storedRow('p', 2)).toMatchObject({ access_token: 'AT-2', refresh_token: 'RT-2' }); }); }); }); diff --git a/server/tests/unit/db/repositories/VacayPlans.repository.test.ts b/server/tests/unit/db/repositories/VacayPlans.repository.test.ts index 6d74b62cb..3e13794e5 100644 --- a/server/tests/unit/db/repositories/VacayPlans.repository.test.ts +++ b/server/tests/unit/db/repositories/VacayPlans.repository.test.ts @@ -10,6 +10,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { VacayPlans } from '../../../../src/db/entities/VacayPlans.entity'; +import { makeVacayPlan } from '../../../helpers/factories/vacay'; import type { VacayPlansRepository } from '../../../../src/db/repositories/VacayPlans.repository'; const testDb = createSnapshotTestDb(); @@ -23,20 +24,16 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function insertPlan(ownerId: number, overrides: Partial<{ block_weekends: number; holidays_enabled: number; holidays_region: string; school_holidays_enabled: number; company_holidays_enabled: number; carry_over_enabled: number; weekend_days: string; week_start: number }> = {}): number { - const cols = Object.keys(overrides); - const placeholders = cols.map(() => '?').join(', '); - const extra = cols.length > 0 ? `, ${cols.join(', ')}` : ''; - const result = testDb.prepare(`INSERT INTO vacay_plans (owner_id${extra}) VALUES (?${cols.length > 0 ? ', ' + placeholders : ''})`) - .run(ownerId, ...cols.map((c) => (overrides as Record)[c])); - return Number(result.lastInsertRowid); +async function insertPlan(ownerId: number, overrides: Partial<{ block_weekends: number; holidays_enabled: number; holidays_region: string; school_holidays_enabled: number; company_holidays_enabled: number; carry_over_enabled: number; weekend_days: string; week_start: number }> = {}): Promise { + return (await makeVacayPlan(t, ownerId, overrides)).id; } describe('VacayPlansRepository.findByOwner (VC7/9/78)', () => { it('VACAYPLANREPO-001: matches SELECT * FROM vacay_plans WHERE owner_id = ? run raw, every nullable column set', async () => { const { user } = createUser(testDb); - const id = insertPlan(user.id, { holidays_region: 'DE', weekend_days: '["sat","sun"]', week_start: 1 }); + const id = await insertPlan(user.id, { holidays_region: 'DE', weekend_days: '["sat","sun"]', week_start: 1 }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(user.id); expect(await repo.findByOwner(user.id)).toEqual(legacy); expect((await repo.findByOwner(user.id))!.id).toBe(id); @@ -51,8 +48,9 @@ describe('VacayPlansRepository.findByOwner (VC7/9/78)', () => { describe('VacayPlansRepository.findById (VC14, the hottest statement in this file)', () => { it('VACAYPLANREPO-003: matches SELECT * FROM vacay_plans WHERE id = ? run raw, defaulted columns included', async () => { const { user } = createUser(testDb); - const id = insertPlan(user.id); + const id = await insertPlan(user.id); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(id); expect(await repo.findById(id)).toEqual(legacy); }); diff --git a/server/tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts b/server/tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts index a5ebf5a23..9bf492ce1 100644 --- a/server/tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts +++ b/server/tests/unit/db/repositories/_shared/reservation-travelers-owns.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 { createReservation, createTrip, createUser } from '../../../../helpers/factories'; +import { addReservationTraveler } from '../../../../helpers/factories/reservations'; import { Reservations } from '../../../../../src/db/entities/Reservations.entity'; import { travelerOwnsCondition, @@ -32,12 +33,13 @@ beforeAll(async () => { t = await createTestOrm(testDb); }); beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function addTraveler(reservationId: number, userId: number): void { - testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(reservationId, userId); +async function addTraveler(reservationId: number, userId: number): Promise { + await addReservationTraveler(t, reservationId, userId); } async function legacyOwnedIds(tripId: number, userId: number): Promise { const rows = testDb + // test-sql-allow: the legacy TRAVELER_OWNS fragment is the oracle, so it has to run as the raw SQL it was. .prepare(`SELECT id FROM reservations r WHERE r.trip_id = ? AND ${TRAVELER_OWNS} ORDER BY id ASC`) .all(tripId, userId) as { id: number }[]; return rows.map((row) => row.id); @@ -91,7 +93,7 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, traveler.id); const assigned = createReservation(testDb, trip.id, { title: 'Flight with named travelers' }); - addTraveler(assigned.id, traveler.id); + await addTraveler(assigned.id, traveler.id); const legacyForTraveler = await legacyOwnedIds(trip.id, traveler.id); const typedQbForTraveler = await typedQbOwnedIds(trip.id, traveler.id); @@ -114,8 +116,8 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, travelerA.id); const shared = createReservation(testDb, trip.id, { title: 'Family flight' }); - addTraveler(shared.id, travelerA.id); - addTraveler(shared.id, travelerB.id); + await addTraveler(shared.id, travelerA.id); + await addTraveler(shared.id, travelerB.id); for (const userId of [travelerA.id, travelerB.id]) { const legacy = await legacyOwnedIds(trip.id, userId); @@ -141,7 +143,7 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m const trip = createTrip(testDb, owner.id); const unassigned = createReservation(testDb, trip.id, { title: 'Unassigned' }); const assignedToOther = createReservation(testDb, trip.id, { title: 'Assigned to someone else' }); - addTraveler(assignedToOther.id, travelerB.id); + await addTraveler(assignedToOther.id, travelerB.id); const legacy = await legacyOwnedIds(trip.id, viewer.id); const typedQb = await typedQbOwnedIds(trip.id, viewer.id); diff --git a/server/tests/unit/db/repositories/addons-config-encryption.test.ts b/server/tests/unit/db/repositories/addons-config-encryption.test.ts index cb4e55e6d..02f3418ff 100644 --- a/server/tests/unit/db/repositories/addons-config-encryption.test.ts +++ b/server/tests/unit/db/repositories/addons-config-encryption.test.ts @@ -12,6 +12,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { Addons } from '../../../../src/db/entities/Addons.entity'; +import { deleteRows, insertRow } from '../../../helpers/factories/rows'; import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository'; import { prepareLlmAddonConfigForWrite } from '../../../../src/nest/llm-parse/llm-config'; import { decrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto'; @@ -24,12 +25,18 @@ beforeAll(async () => { t = await createTestOrm(testDb); addons = t.repo(Addons); }); -beforeEach(() => { +beforeEach(async () => { resetTestDb(testDb); - testDb.exec('DELETE FROM addons'); - testDb - .prepare(`INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('llm_parsing', 'AI Parsing', NULL, 'global', 'Sparkles', 0, 0)`) - .run(); + await deleteRows(t, Addons); + await insertRow(t, Addons, { + id: 'llm_parsing', + name: 'AI Parsing', + description: null, + type: 'global', + icon: 'Sparkles', + enabled: false, + sort_order: 0, + }); t.clear(); }); afterAll(async () => { diff --git a/server/tests/unit/mcp/tools-plugin-pois.test.ts b/server/tests/unit/mcp/tools-plugin-pois.test.ts index ee12d36ce..0208e2219 100644 --- a/server/tests/unit/mcp/tools-plugin-pois.test.ts +++ b/server/tests/unit/mcp/tools-plugin-pois.test.ts @@ -4,7 +4,7 @@ * counterparts of the GET /api/plugins feed and GET /api/plugin-pois. Both go through * PluginPoisService, so the gate, the window and the normalization are the REST ones. */ -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) })); @@ -25,6 +25,10 @@ import { createUser } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { resetTestDb } from '../../helpers/test-db'; import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { deleteRows } from '../../helpers/factories/rows'; +import { makePlugin } from '../../helpers/factories/plugins'; const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf'); const categoryPoisMock = vi.spyOn(PluginHooks.prototype, 'categoryPois'); @@ -32,18 +36,28 @@ const categoryPoisMock = vi.spyOn(PluginHooks.prototype, 'categoryPois'); const trailheads = { id: 'trailheads', label: 'Trailheads', labels: { de: 'Wanderparkplätze' }, icon: 'Signpost', color: '#2f855a' }; const bbox = { south: 47, west: 11, north: 47.5, east: 11.5 }; -beforeEach(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +beforeEach(async () => { resetTestDb(testDb); - testDb.prepare('DELETE FROM plugins').run(); - testDb - .prepare("INSERT INTO plugins (id, name, status, capabilities, granted_permissions) VALUES (?, ?, 'active', ?, ?)") - .run('trail-finder', 'Trail Finder', JSON.stringify({ poiCategories: [trailheads] }), JSON.stringify(['hook:poi-category-provider'])); + await deleteRows(orm, Plugins); + await makePlugin(orm, 'trail-finder', { + name: 'Trail Finder', + status: 'active', + capabilities: JSON.stringify({ poiCategories: [trailheads] }), + granted_permissions: JSON.stringify(['hook:poi-category-provider']), + }); pluginsEnabled.mockReturnValue(true); providersOfMock.mockReset().mockReturnValue(['trail-finder']); categoryPoisMock.mockReset().mockResolvedValue([]); }); -afterAll(() => { +afterAll(async () => { + await orm.close(); testDb.close(); }); diff --git a/server/tests/unit/nest/backup-archive-version.test.ts b/server/tests/unit/nest/backup-archive-version.test.ts index e44eca119..304ecd585 100644 --- a/server/tests/unit/nest/backup-archive-version.test.ts +++ b/server/tests/unit/nest/backup-archive-version.test.ts @@ -19,6 +19,7 @@ describe('checkBackupDatabase and the release that made the backup', () => { const db = new Database(path.join(dir, 'travel.db')); for (const table of ['users', 'trips', 'trip_members', 'places', 'days']) db.exec(`CREATE TABLE ${table} (id INTEGER)`); db.exec('CREATE TABLE mikro_orm_migrations (id INTEGER PRIMARY KEY, name TEXT, executed_at TEXT)'); + // test-sql-allow: a bare backup file before any boot, which no ORM is bound to. const insert = db.prepare('INSERT INTO mikro_orm_migrations (name) VALUES (?)'); for (const name of recorded) insert.run(name); db.close(); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index d68333927..c0788fee5 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -4,18 +4,14 @@ * freeze the FX rate before the raw write — incl. the #1445 stored-currency * thread-through) and the wrapper-only syncReservationPrice SQL. The SQL-heavy * paths themselves are covered by budget.service.db.test.ts (real :memory: DB) - * and budget.service.calc.test.ts (settlement math over a prepare-stub mock). + * and budget.service.calc.test.ts (settlement math over a stubbed database). */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock the data + side-effect dependencies the service reaches into directly. -const { dbMock } = vi.hoisted(() => { - const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }; - return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } }; -}); const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); vi.mock('../../../src/db/database', () => ({ - db: dbMock, + db: {}, closeDb: () => {}, reinitialize: () => {}, canAccessTrip, @@ -49,9 +45,9 @@ import type { TripsRepository } from '../../../src/db/repositories/Trips.reposit import type { TripMembersRepository } from '../../../src/db/repositories/TripMembers.repository'; /** - * There is no database behind this suite — every statement is served by the - * prepare stub above — so the UnitOfWork is a pass-through: it runs the callback - * as MikroORM would, with no transaction to open on. + * There is no database behind this suite (every repository is a stub), so the + * UnitOfWork is a pass-through: it runs the callback as MikroORM would, with no + * transaction to open on. */ const uowStub = { transactional: (fn: () => Promise) => fn() } as unknown as UnitOfWork; @@ -61,16 +57,19 @@ const uowStub = { transactional: (fn: () => Promise) => fn() } as unknown * and update and delete wrappers, setItemPayers, insertSettlement, * getSettlement, applySettlementUpdate — the composite-wrapper tests only * assert the freeze-then-write ORDER, never the SQL itself) or, for - * `syncReservationPrice`, still needs the `dbMock`/`prepare` stub above to - * answer — so this repo proxies to it instead of a real - * `ReservationsRepository`, keeping that describe block's assertions - * (incl. "prepare throws" leading to a swallowed error) unchanged. + * `syncReservationPrice`, answered by these three repository stubs: the + * lookup, the metadata write and the reload the service makes, in that order. + * A lookup that rejects stands for the database being gone. */ -const reservationsRepoStub = { - getIdAndMetadata: async (id: number, tripId: unknown) => dbMock.prepare().get(id, tripId), - setMetadata: async (id: number, metadata: string) => { dbMock.prepare().run(metadata, id); }, - getFull: async (id: number) => dbMock.prepare().get(id), -} as unknown as ReservationsRepository; +const reservationsRepo = { + getIdAndMetadata: vi.fn(), + setMetadata: vi.fn<(id: number, metadata: string) => Promise>(async () => {}), + getFull: vi.fn(), +}; +const reservationsRepoStub = reservationsRepo as unknown as ReservationsRepository; + +/** The metadata JSON the service wrote on its first setMetadata call. */ +const writtenMetadata = (): unknown => JSON.parse(reservationsRepo.setMetadata.mock.calls[0][1]); function svc( tripMembersRepo: TripMembersRepository = {} as unknown as TripMembersRepository, @@ -101,7 +100,7 @@ describe('BudgetService', () => { // Plan 4 Task 2: `verifyTripAccess` no longer rides `DatabaseService // .canAccessTrip` — it reads through the injected `TripsRepository` // directly, consistent with this suite's "no database behind it" - // design (everything else here is a `prepare` stub). + // design (everything else here is a stub). const findAccessible = vi.fn(async () => ({ id: 5, user_id: 2 })); const fakeTripsRepo = { findAccessible } as unknown as TripsRepository; expect(await svc(undefined, fakeTripsRepo).verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 }); @@ -261,52 +260,54 @@ describe('BudgetService', () => { describe('syncReservationPrice', () => { it('returns early when the reservation is not found', async () => { - dbMock._stmt.get.mockReturnValueOnce(undefined); + reservationsRepo.getIdAndMetadata.mockResolvedValueOnce(undefined); await svc().syncReservationPrice('5', 42, 250, 'sock'); - expect(dbMock._stmt.run).not.toHaveBeenCalled(); + expect(reservationsRepo.setMetadata).not.toHaveBeenCalled(); expect(broadcast).not.toHaveBeenCalled(); }); it('merges into existing metadata and broadcasts reservation:updated', async () => { - dbMock._stmt.get - .mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup - .mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload + reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }); // lookup + reservationsRepo.getFull.mockResolvedValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload await svc().syncReservationPrice('5', 42, 250, 'sock'); - const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string); - expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' }); + expect(reservationsRepo.setMetadata.mock.calls[0][0]).toBe(42); + expect(writtenMetadata()).toEqual({ vendor: 'ACME', price: '250' }); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock'); }); it('starts from an empty object when the reservation has no metadata', async () => { - dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 }); + reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: null }); + reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 }); await svc().syncReservationPrice('5', 42, 99, undefined); - const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string); - expect(writtenMeta).toEqual({ price: '99' }); + expect(writtenMetadata()).toEqual({ price: '99' }); }); it('swallows errors so a sync failure never breaks the budget update', async () => { - dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); }); + reservationsRepo.getIdAndMetadata.mockRejectedValueOnce(new Error('db gone')); await expect(svc().syncReservationPrice('5', 42, 250, 'sock')).resolves.toBeUndefined(); expect(broadcast).not.toHaveBeenCalled(); }); // #2084: the card names the currency beside the mirrored figure. it('writes the currency beside the price, upper-cased', async () => { - dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"seat":"1A"}' }).mockReturnValueOnce({ id: 42 }); + reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"seat":"1A"}' }); + reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 }); await svc().syncReservationPrice('5', 42, 99.5, 'sock', 'usd'); - expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ seat: '1A', price: '99.5', priceCurrency: 'USD' }); + expect(writtenMetadata()).toEqual({ seat: '1A', price: '99.5', priceCurrency: 'USD' }); }); it('drops a stored currency when the expense is in the trip currency (null)', async () => { - dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }).mockReturnValueOnce({ id: 42 }); + reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }); + reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 }); await svc().syncReservationPrice('5', 42, 12, undefined, null); - expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ price: '12' }); + expect(writtenMetadata()).toEqual({ price: '12' }); }); it('leaves a stored currency alone when none is passed at all', async () => { - dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }).mockReturnValueOnce({ id: 42 }); + reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }); + reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 }); await svc().syncReservationPrice('5', 42, 12, undefined); - expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ price: '12', priceCurrency: 'CNY' }); + expect(writtenMetadata()).toEqual({ price: '12', priceCurrency: 'CNY' }); }); }); diff --git a/server/tests/unit/nest/dawarich.service.test.ts b/server/tests/unit/nest/dawarich.service.test.ts index a855862e7..2857c3ee4 100644 --- a/server/tests/unit/nest/dawarich.service.test.ts +++ b/server/tests/unit/nest/dawarich.service.test.ts @@ -72,6 +72,8 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ import { DAWARICH_KEY_MASK, type DawarichCapabilities } from '@trek/shared'; import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; +import { DawarichConnections } from '../../../src/db/entities/DawarichConnections.entity'; +import { findRow, upsertRow } from '../../helpers/factories/rows'; import type { DawarichConnectionsRepository } from '../../../src/db/repositories/DawarichConnections.repository'; import { DawarichService } from '../../../src/nest/integrations/dawarich.service'; import type { TestOrm } from '../../helpers/test-orm'; @@ -121,7 +123,7 @@ interface ConnFixture { capabilities: string | null; } -function connect(userId: number, over: Partial = {}): void { +async function connect(userId: number, over: Partial = {}): Promise { const row: ConnFixture = { url: HOST, apiKey: 'stored-key', @@ -133,41 +135,21 @@ function connect(userId: number, over: Partial = {}): void { capabilities: null, ...over, }; - testDb - .prepare( - `INSERT OR REPLACE INTO dawarich_connections - (user_id, url, api_key, allow_insecure_tls, sync_enabled, - last_sync_at, last_sync_state, last_sync_error, capabilities) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - userId, - row.url, - row.apiKey, - row.allowInsecureTls, - row.syncEnabled, - row.lastSyncAt, - row.lastSyncState, - row.lastSyncError, - row.capabilities, - ); + await upsertRow(t, DawarichConnections, { + user: userId, + url: row.url, + api_key: row.apiKey, + allow_insecure_tls: row.allowInsecureTls, + sync_enabled: row.syncEnabled, + last_sync_at: row.lastSyncAt, + last_sync_state: row.lastSyncState, + last_sync_error: row.lastSyncError, + capabilities: row.capabilities, + }); } -interface StoredRow { - url: string | null; - api_key: string | null; - allow_insecure_tls: number; - sync_enabled: number; - last_sync_at: string | null; - last_sync_state: string; - last_sync_error: string | null; - capabilities: string | null; -} - -function row(userId = USER): StoredRow | undefined { - return testDb - .prepare('SELECT * FROM dawarich_connections WHERE user_id = ?') - .get(userId) as StoredRow | undefined; +function row(userId = USER) { + return findRow(t, DawarichConnections, { user: userId }); } const FULL_CAPS: DawarichCapabilities = { @@ -271,24 +253,24 @@ describe('DawarichService getCredentials', () => { }); it('DAWARICH-SVC-002: a row without an address is not a connection', async () => { - connect(USER, { url: null }); + await connect(USER, { url: null }); expect(await svc.getCredentials(USER)).toBeNull(); }); it('DAWARICH-SVC-003: a row without a key is not a connection', async () => { - connect(USER, { apiKey: null }); + await connect(USER, { apiKey: null }); expect(await svc.getCredentials(USER)).toBeNull(); }); it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', async () => { // What a rotated ENCRYPTION_KEY or a half-restored backup leaves behind: // the envelope is intact, the ciphertext is not ours. - connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); + await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); expect(await svc.getCredentials(USER)).toBeNull(); }); it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', async () => { - connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 }); + await connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 }); expect(await svc.getCredentials(USER)).toEqual({ baseUrl: HOST, apiKey: 'plain-legacy-key', @@ -312,12 +294,12 @@ describe('DawarichService getCredentials', () => { describe('DawarichService isConnected', () => { it('DAWARICH-SVC-010: both halves present means connected', async () => { - connect(USER); + await connect(USER); expect(await svc.isConnected(USER)).toBe(true); }); it('DAWARICH-SVC-011: an address without a key is not connected', async () => { - connect(USER, { apiKey: null }); + await connect(USER, { apiKey: null }); expect(await svc.isConnected(USER)).toBe(false); }); @@ -329,7 +311,7 @@ describe('DawarichService isConnected', () => { // Deliberate difference from getCredentials: the settings card has to keep // showing a connection the user can repair, and only the outbound path // cares that the ciphertext is unusable. - connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); + await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); expect(await svc.isConnected(USER)).toBe(true); expect(await svc.getCredentials(USER)).toBeNull(); }); @@ -343,14 +325,14 @@ describe('DawarichService listSyncableUserIds', () => { const blankUrl = createUser(testDb).user.id; const noKey = createUser(testDb).user.id; - connect(syncable); - connect(pollOff, { syncEnabled: 0 }); - connect(noUrl, { url: null }); + await connect(syncable); + await connect(pollOff, { syncEnabled: 0 }); + await connect(noUrl, { url: null }); // An empty string is a distinct case from NULL: the save path stores NULL, // but an older row or a hand-edited one can hold '' and it must not be // polled, because the client would then request /api/v1 on no host at all. - connect(blankUrl, { url: '' }); - connect(noKey, { apiKey: null }); + await connect(blankUrl, { url: '' }); + await connect(noKey, { apiKey: null }); expect(await svc.listSyncableUserIds()).toEqual([syncable]); }); @@ -365,9 +347,9 @@ describe('DawarichService listSyncableUserIds', () => { // settings card would still say the poll was on for all of them. const second = createUser(testDb).user.id; const pollOff = createUser(testDb).user.id; - connect(USER); - connect(second); - connect(pollOff, { syncEnabled: 0 }); + await connect(USER); + await connect(second); + await connect(pollOff, { syncEnabled: 0 }); const ids = await svc.listSyncableUserIds(); @@ -397,7 +379,7 @@ describe('DawarichService getConnection', () => { }); it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', async () => { - connect(USER, { + await connect(USER, { apiKey: 'stored-key', allowInsecureTls: 1, syncEnabled: 0, @@ -423,24 +405,24 @@ describe('DawarichService getConnection', () => { }); it('DAWARICH-SVC-032: an address without a key reports the address and no mask', async () => { - connect(USER, { apiKey: null }); + await connect(USER, { apiKey: null }); expect(await svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false }); }); it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', async () => { - connect(USER, { url: null, apiKey: null }); + await connect(USER, { url: null, apiKey: null }); expect(await svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true }); }); it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', async (state) => { - connect(USER, { lastSyncState: state }); + await connect(USER, { lastSyncState: state }); expect((await svc.getConnection(USER)).lastSyncState).toBe(state); }); it.each(['syncing', 'OK', 'success', ''])( 'DAWARICH-SVC-035: the unrecognised sync state %j reads as "never" rather than reaching a client that has no branch for it', async (state) => { - connect(USER, { lastSyncState: state }); + await connect(USER, { lastSyncState: state }); expect((await svc.getConnection(USER)).lastSyncState).toBe('never'); }, ); @@ -448,7 +430,7 @@ describe('DawarichService getConnection', () => { it.each(['{not json', '', 'undefined'])( 'DAWARICH-SVC-036: the malformed capabilities blob %j reads as "not probed yet", never as a crash on the settings page', async (raw) => { - connect(USER, { capabilities: raw }); + await connect(USER, { capabilities: raw }); expect((await svc.getConnection(USER)).capabilities).toBeNull(); }, ); @@ -456,7 +438,7 @@ describe('DawarichService getConnection', () => { it.each(['null', '42', '"visits"', 'true'])( 'DAWARICH-SVC-037: the JSON scalar %s is not a capabilities object and reads as not probed', async (raw) => { - connect(USER, { capabilities: raw }); + await connect(USER, { capabilities: raw }); expect((await svc.getConnection(USER)).capabilities).toBeNull(); }, ); @@ -465,7 +447,7 @@ describe('DawarichService getConnection', () => { // The column survives an upgrade, so the shape is whatever was current when // the last probe ran. Missing flags are off and a missing probe timestamp // is the epoch, which reads as "long ago" everywhere it is compared. - connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) }); + await connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) }); expect((await svc.getConnection(USER)).capabilities).toEqual({ visits: true, @@ -481,7 +463,7 @@ describe('DawarichService getConnection', () => { }); it('DAWARICH-SVC-039: a complete blob round-trips unchanged', async () => { - connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); + await connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); expect((await svc.getConnection(USER)).capabilities).toEqual(FULL_CAPS); }); }); @@ -492,12 +474,12 @@ describe('DawarichService getCapabilities', () => { }); it('DAWARICH-SVC-041: null when the connection has never been probed', async () => { - connect(USER); + await connect(USER); expect(await svc.getCapabilities(USER)).toBeNull(); }); it('DAWARICH-SVC-042: the stored probe otherwise', async () => { - connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); + await connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); }); }); @@ -511,7 +493,7 @@ describe('DawarichService saveSettings', () => { const out = await svc.saveSettings(USER, ` ${HOST} `, 'super-secret', true, true, IP); expect(out).toEqual({ success: true }); - const stored = row(); + const stored = await row(); expect(stored?.url).toBe(HOST); expect(stored?.allow_insecure_tls).toBe(1); expect(stored?.sync_enabled).toBe(1); @@ -524,31 +506,31 @@ describe('DawarichService saveSettings', () => { it('DAWARICH-SVC-051: turning the background poll off is persisted, and the card reports it', async () => { await svc.saveSettings(USER, HOST, 'k', false, false, IP); - expect(row()?.sync_enabled).toBe(0); - expect(row()?.allow_insecure_tls).toBe(0); + expect((await row())?.sync_enabled).toBe(0); + expect((await row())?.allow_insecure_tls).toBe(0); expect((await svc.getConnection(USER)).syncEnabled).toBe(false); expect(await svc.listSyncableUserIds()).toEqual([]); }); it('DAWARICH-SVC-052: a blank key field keeps the stored key, because the field is never prefilled', async () => { await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); - const encrypted = row()?.api_key; + const encrypted = (await row())?.api_key; await svc.saveSettings(USER, `${HOST}`, undefined, true, true, IP); - expect(row()?.api_key).toBe(encrypted); - expect(row()?.allow_insecure_tls).toBe(1); + expect((await row())?.api_key).toBe(encrypted); + expect((await row())?.allow_insecure_tls).toBe(1); }); it.each([DAWARICH_KEY_MASK, ' ', ''])( 'DAWARICH-SVC-053: the key field %j means "keep what is stored"', async (typed) => { await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); - const encrypted = row()?.api_key; + const encrypted = (await row())?.api_key; await svc.saveSettings(USER, HOST, typed, false, true, IP); - expect(row()?.api_key).toBe(encrypted); + expect((await row())?.api_key).toBe(encrypted); expect((await svc.getCredentials(USER))?.apiKey).toBe('first-key'); }, ); @@ -561,7 +543,7 @@ describe('DawarichService saveSettings', () => { }); it('DAWARICH-SVC-055: moving to a different host without retyping the key drops the key and every artefact of the old instance', async () => { - connect(USER, { + await connect(USER, { url: 'https://old.example', apiKey: 'stored-key', lastSyncAt: '2026-09-12T04:15:00.000Z', @@ -572,7 +554,7 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); - const stored = row(); + const stored = await row(); expect(stored?.url).toBe('https://new.example'); expect(stored?.api_key).toBeNull(); expect(stored?.capabilities).toBeNull(); @@ -582,9 +564,9 @@ describe('DawarichService saveSettings', () => { }); it('DAWARICH-SVC-056: a different port is a different instance, so the key goes with it', async () => { - connect(USER, { url: 'https://d.example' }); + await connect(USER, { url: 'https://d.example' }); await svc.saveSettings(USER, 'https://d.example:8443', undefined, false, true, IP); - expect(row()?.api_key).toBeNull(); + expect((await row())?.api_key).toBeNull(); }); it.each([ @@ -594,15 +576,15 @@ describe('DawarichService saveSettings', () => { ])( 'DAWARICH-SVC-057: %s to %s is the same instance and keeps the key', async (before, after) => { - connect(USER, { url: before, apiKey: 'stored-key' }); + await connect(USER, { url: before, apiKey: 'stored-key' }); await svc.saveSettings(USER, after, undefined, false, true, IP); - expect(row()?.api_key).toBe('stored-key'); - expect(row()?.url).toBe(after); + expect((await row())?.api_key).toBe('stored-key'); + expect((await row())?.url).toBe(after); }, ); it('DAWARICH-SVC-058: a key typed at the same time as the host change survives, because it was minted for the new host', async () => { - connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP); @@ -617,32 +599,32 @@ describe('DawarichService saveSettings', () => { // No scheme, so the URL constructor throws and the comparison falls back to // string equality. Erring towards keeping the key here is safe precisely // because the text did not change. - connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' }); + await connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' }); await svc.saveSettings(USER, 'dawarich.example', undefined, false, true, IP); - expect(row()?.api_key).toBe('stored-key'); + expect((await row())?.api_key).toBe('stored-key'); }); it('DAWARICH-SVC-060: an unparseable stored address against a different one drops the key', async () => { - connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' }); + await connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' }); await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); - expect(row()?.api_key).toBeNull(); + expect((await row())?.api_key).toBeNull(); }); it('DAWARICH-SVC-061: a row whose address column is null counts as no previous host, so nothing is dropped', async () => { - connect(USER, { url: null, apiKey: 'stored-key' }); + await connect(USER, { url: null, apiKey: 'stored-key' }); await svc.saveSettings(USER, HOST, undefined, false, true, IP); - expect(row()?.api_key).toBe('stored-key'); - expect(row()?.url).toBe(HOST); + expect((await row())?.api_key).toBe('stored-key'); + expect((await row())?.url).toBe(HOST); }); it('DAWARICH-SVC-062: clearing the address wipes the key, because a live credential for a server nobody named is just a stored secret', async () => { - connect(USER, { + await connect(USER, { apiKey: 'stored-key', lastSyncAt: '2026-09-12T04:15:00.000Z', lastSyncState: 'ok', @@ -653,7 +635,7 @@ describe('DawarichService saveSettings', () => { const out = await svc.saveSettings(USER, ' ', undefined, false, true, IP); expect(out).toEqual({ success: true }); - const stored = row(); + const stored = await row(); expect(stored?.url).toBeNull(); expect(stored?.api_key).toBeNull(); expect(stored?.capabilities).toBeNull(); @@ -663,9 +645,9 @@ describe('DawarichService saveSettings', () => { }); it('DAWARICH-SVC-063: a key typed while the address is cleared is discarded with it', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); await svc.saveSettings(USER, '', 'pointless-key', false, true, IP); - expect(row()?.api_key).toBeNull(); + expect((await row())?.api_key).toBeNull(); }); it('DAWARICH-SVC-064: an empty address is never resolved, so clearing the connection cannot fail on DNS', async () => { @@ -683,7 +665,7 @@ describe('DawarichService saveSettings', () => { code: 'invalid_url', error: 'Only HTTP and HTTPS URLs are allowed', }); - expect(row()).toBeUndefined(); + expect(await row()).toBeNull(); expect(audit.writeAudit).not.toHaveBeenCalled(); }); @@ -708,7 +690,7 @@ describe('DawarichService saveSettings', () => { warningCode: 'private_ip', warningIp: '192.168.0.5', }); - expect(row()?.url).toBe('http://dawarich.lan:3000'); + expect((await row())?.url).toBe('http://dawarich.lan:3000'); expect(audit.writeAudit).toHaveBeenCalledWith({ userId: USER, action: 'dawarich.private_ip_configured', @@ -730,7 +712,7 @@ describe('DawarichService saveSettings', () => { const out = await svc.saveSettings(USER, 'http://10.0.0.9:3000', 'k', false, true, IP); expect(out).toMatchObject({ success: true, warningCode: 'private_ip', warningIp: '10.0.0.9' }); - expect(row()?.url).toBe('http://10.0.0.9:3000'); + expect((await row())?.url).toBe('http://10.0.0.9:3000'); }); it('DAWARICH-SVC-069: a private result the resolver could not name still warns, with no IP in the warning payload', async () => { @@ -744,7 +726,7 @@ describe('DawarichService saveSettings', () => { }); it('DAWARICH-SVC-070: the address write and the key write are one transaction, so a failed key write cannot leave the old key pointed at the new host', async () => { - connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); // Rollback proof (R7's transaction, one of the plan's eight): spy the // SECOND write (the key) to throw and confirm the FIRST write (the // address, already committed to the transactional connection) is rolled @@ -760,7 +742,7 @@ describe('DawarichService saveSettings', () => { spy.mockRestore(); } - const stored = row(); + const stored = await row(); expect(stored?.url).toBe('https://old.example'); expect(stored?.api_key).toBe('stored-key'); }); @@ -770,8 +752,8 @@ describe('DawarichService saveSettings', () => { // file and then logs the whole install out of Dawarich the first time one // person edits their own URL. Two victims, one bystander, both statements. const bystander = createUser(testDb).user.id; - connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); - connect(bystander, { + await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + await connect(bystander, { apiKey: 'bystander-key', capabilities: JSON.stringify(FULL_CAPS), lastSyncState: 'ok', @@ -780,8 +762,8 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); await svc.saveSettings(USER, '', undefined, false, true, IP); - expect(row()?.api_key).toBeNull(); - expect(row(bystander)).toMatchObject({ + expect((await row())?.api_key).toBeNull(); + expect(await row(bystander)).toMatchObject({ url: HOST, api_key: 'bystander-key', last_sync_state: 'ok', @@ -794,7 +776,7 @@ describe('DawarichService saveSettings', () => { // point at. Carrying them over would show the old instance's version and // "last sync ok" on a connection that has never been asked anything, and // the tracks layer would skip an endpoint the new instance does have. - connect(USER, { + await connect(USER, { url: 'https://old.example', apiKey: 'stored-key', lastSyncAt: '2026-09-12T04:15:00.000Z', @@ -806,7 +788,7 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP); expect((await svc.getCredentials(USER))?.apiKey).toBe('minted-for-new'); - const stored = row(); + const stored = await row(); expect(stored?.url).toBe('https://new.example'); expect(stored?.capabilities).toBeNull(); expect(stored?.last_sync_state).toBe('never'); @@ -815,7 +797,7 @@ describe('DawarichService saveSettings', () => { }); it('DAWARICH-SVC-073: a new key on the same host keeps the probe, because it describes the host and not the key', async () => { - connect(USER, { + await connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key', lastSyncState: 'ok', @@ -825,8 +807,8 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, 'https://d.example/', 'rotated-key', false, true, IP); expect((await svc.getCredentials(USER))?.apiKey).toBe('rotated-key'); - expect(row()?.capabilities).toBe(JSON.stringify(FULL_CAPS)); - expect(row()?.last_sync_state).toBe('ok'); + expect((await row())?.capabilities).toBe(JSON.stringify(FULL_CAPS)); + expect((await row())?.last_sync_state).toBe('ok'); }); }); @@ -836,11 +818,11 @@ describe('DawarichService saveSettings', () => { describe('DawarichService disconnect', () => { it('DAWARICH-SVC-080: forgets the credential and says so in the audit log', async () => { - connect(USER); + await connect(USER); await svc.disconnect(USER, '198.51.100.7'); - expect(row()).toBeUndefined(); + expect(await row()).toBeNull(); expect((await svc.getConnection(USER)).connected).toBe(false); expect(audit.writeAudit).toHaveBeenCalledWith({ userId: USER, @@ -853,18 +835,18 @@ describe('DawarichService disconnect', () => { it('DAWARICH-SVC-081: disconnecting something that was never connected is a no-op that still leaves a trail', async () => { await svc.disconnect(USER, null); - expect(row()).toBeUndefined(); + expect(await row()).toBeNull(); expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ ip: null })); }); it('DAWARICH-SVC-082: only the calling user loses their connection', async () => { const other = createUser(testDb).user.id; - connect(USER); - connect(other); + await connect(USER); + await connect(other); await svc.disconnect(USER, null); - expect(row(other)).toBeDefined(); + expect(await row(other)).not.toBeNull(); }); }); @@ -890,7 +872,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-092: a key typed with no address falls back to the stored address', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); await svc.testConnection(USER, '', 'typed-key', false); @@ -898,7 +880,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-093: the mask means "use the stored key", which is what a form that only changed the TLS flag posts back', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); await svc.testConnection(USER, HOST, DAWARICH_KEY_MASK, true); @@ -906,7 +888,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-094: a stored connection tested as it stands persists the freshly probed capabilities', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); const out = await svc.testConnection(USER, HOST, undefined, false); @@ -915,7 +897,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-095: a test run against a different address than the stored one must not overwrite what is on file', async () => { - connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) }); + await connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) }); await svc.testConnection(USER, 'https://someone-elses.example', 'their-key', false); @@ -926,11 +908,11 @@ describe('DawarichService testConnection', () => { const out = await svc.testConnection(USER, HOST, 'typed-key', false); expect(out.connected).toBe(true); - expect(row()).toBeUndefined(); + expect(await row()).toBeNull(); }); it('DAWARICH-SVC-097: the visit count is the last 30 days, and it is only asked for when the visits endpoint exists', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); allEndpointsAnswer([visit({ id: 1 }), visit({ id: 2 }), visit({ id: 3 })]); const out = await svc.testConnection(USER, HOST, undefined, false); @@ -942,7 +924,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-098: an instance without a visits endpoint reports zero rather than asking a route that is not there', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); client.listVisits.mockRejectedValue(notFound()); const out = await svc.testConnection(USER, HOST, undefined, false); @@ -953,7 +935,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-099: a count that fails after the connection is already proven is not a failed connection', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); client.listVisits .mockResolvedValueOnce({ visits: [visit()], truncated: false, version: '1.14.4' }) .mockRejectedValueOnce(new DawarichError('rate_limited', 'HTTP 429', 429)); @@ -964,7 +946,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-100: a rejected key is a 200 the form can render, carrying the code the client translates', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); client.probe.mockRejectedValue(new DawarichError('unauthorized', 'Dawarich answered HTTP 401', 401)); const out = await svc.testConnection(USER, HOST, undefined, false); @@ -974,7 +956,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-101: the free-text detail rides along when there is one, for the person debugging their own reverse proxy', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); client.probe.mockRejectedValue( new DawarichError('invalid_response', 'Not JSON', 200, 'login'), ); @@ -987,7 +969,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-102: anything that is not a DawarichError reads as unreachable rather than leaking the message', async () => { - connect(USER, { apiKey: 'stored-key' }); + await connect(USER, { apiKey: 'stored-key' }); client.probe.mockRejectedValue(new Error('ECONNRESET on https://dawarich.example?api_key=secret')); expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({ @@ -997,7 +979,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-103: a failed probe leaves the stored capabilities untouched', async () => { - connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) }); + await connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) }); client.probe.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401)); await svc.testConnection(USER, HOST, undefined, false); @@ -1009,7 +991,7 @@ describe('DawarichService testConnection', () => { // The rotated-ENCRYPTION_KEY case reached from the outbound side rather than // through getCredentials: the row still reads as connected on the card, and // the test button has to say what is wrong without sending the blob anywhere. - connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); + await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({ connected: false, @@ -1023,7 +1005,7 @@ describe('DawarichService testConnection', () => { // press Test" is the ordinary way to move an instance. Falling back to the // stored key here would carry it to whatever host was just typed, which is // exactly what saveSettings refuses to persist. - connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); const out = await svc.testConnection(USER, 'https://someone-elses.example', undefined, false); @@ -1033,7 +1015,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-106: the mask against a different host is a blank field, not a key', async () => { - connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); const out = await svc.testConnection(USER, 'https://old.example:8443', DAWARICH_KEY_MASK, false); @@ -1045,7 +1027,7 @@ describe('DawarichService testConnection', () => { // The mirror of 105: the same instance under a corrected path is where the // key was issued, and refusing it would make every trailing-slash edit // demand the key again. - connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key' }); + await connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key' }); const out = await svc.testConnection(USER, 'https://d.example/', undefined, false); @@ -1054,7 +1036,7 @@ describe('DawarichService testConnection', () => { }); it('DAWARICH-SVC-108: a key typed for the new host is used as typed, so moving an instance and testing it first still works', async () => { - connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); const out = await svc.testConnection(USER, 'https://new.example', 'minted-for-new', false); @@ -1176,33 +1158,33 @@ describe('DawarichService probeCapabilities', () => { describe('DawarichService storeCapabilities', () => { it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', async () => { - connect(USER); + await connect(USER); await svc.storeCapabilities(USER, FULL_CAPS); expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); - expect(JSON.parse(row()?.capabilities ?? 'null')).toEqual(FULL_CAPS); + expect(JSON.parse((await row())?.capabilities ?? 'null')).toEqual(FULL_CAPS); }); it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', async () => { await svc.storeCapabilities(USER, FULL_CAPS); - expect(row()).toBeUndefined(); + expect(await row()).toBeNull(); }); it('DAWARICH-SVC-132: only the named user is touched', async () => { const other = createUser(testDb).user.id; - connect(USER); - connect(other); + await connect(USER); + await connect(other); await svc.storeCapabilities(USER, FULL_CAPS); - expect(row(other)?.capabilities).toBeNull(); + expect((await row(other))?.capabilities).toBeNull(); }); }); describe('DawarichService recordSyncResult', () => { it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', async () => { - connect(USER); + await connect(USER); const before = Date.now(); await svc.recordSyncResult(USER, 'failed', 'unreachable'); @@ -1214,7 +1196,7 @@ describe('DawarichService recordSyncResult', () => { }); it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', async () => { - connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' }); + await connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' }); await svc.recordSyncResult(USER, 'ok', null); @@ -1222,13 +1204,13 @@ describe('DawarichService recordSyncResult', () => { }); it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', async () => { - connect(USER); + await connect(USER); await svc.recordSyncResult(USER, 'partial', 'too_large'); expect((await svc.getConnection(USER)).lastSyncState).toBe('partial'); }); it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', async () => { await svc.recordSyncResult(USER, 'ok', null); - expect(row()).toBeUndefined(); + expect(await row()).toBeNull(); }); }); diff --git a/server/tests/unit/nest/day-boundaries.test.ts b/server/tests/unit/nest/day-boundaries.test.ts index 7094519bd..4f5fe845c 100644 --- a/server/tests/unit/nest/day-boundaries.test.ts +++ b/server/tests/unit/nest/day-boundaries.test.ts @@ -16,6 +16,7 @@ import { DayBoundariesController } from '../../../src/nest/roadtrip/day-boundari import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp'; import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity'; +import { deleteRows } from '../../helpers/factories/rows'; import type { McpContext } from '../../../src/nest-mcp'; const testDb = createSnapshotTestDb(); @@ -71,7 +72,7 @@ it('replaces one day only, persists place snapping and cascades deleted visits', expect(await service.list(tripB.id)).toEqual([]); await service.remove(tripB.id, 1); expect(await service.list(tripA.id)).toHaveLength(2); - testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(toId); + await deleteRows(t, DayAssignments, { id: toId }); expect(await service.list(tripA.id)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]); }); diff --git a/server/tests/unit/nest/places.directions.test.ts b/server/tests/unit/nest/places.directions.test.ts index f741f339d..395d4dbaf 100644 --- a/server/tests/unit/nest/places.directions.test.ts +++ b/server/tests/unit/nest/places.directions.test.ts @@ -12,18 +12,8 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); - // DatabaseService delegates the joined read straight through to the module, so the - // mock has to answer it: without this the insert half of every import throws. - const mock = { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: (placeId: any) => { - const place: any = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId); - return place ? { ...place, category: null, tags: [] } : null; - }, - }; - return mock; + // The joined place read goes through PlacesRepository now; the module only hands out the handle. + return { db, closeDb: () => {}, reinitialize: () => {} }; }); diff --git a/server/tests/unit/nest/roadtrip.service.test.ts b/server/tests/unit/nest/roadtrip.service.test.ts index d27994e16..cee398726 100644 --- a/server/tests/unit/nest/roadtrip.service.test.ts +++ b/server/tests/unit/nest/roadtrip.service.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createDay, createPlace, createTrip, createUser } from '../../helpers/factories'; import { createTestUnitOfWork } from '../../helpers/test-uow'; +import { updateRows } from '../../helpers/factories/rows'; import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service'; import { Days } from '../../../src/db/entities/Days.entity'; import { Places } from '../../../src/db/entities/Places.entity'; @@ -33,7 +34,7 @@ async function makeService() { ); } -function fixture() { +async function fixture() { const { user } = createUser(testDb); const tripA = createTrip(testDb, user.id); const tripB = createTrip(testDb, user.id); @@ -43,17 +44,17 @@ function fixture() { const track = createPlace(testDb, tripA.id, { name: 'Atlantic Road' }); const hotel = createPlace(testDb, tripA.id, { name: 'A hotel' }); const otherTrack = createPlace(testDb, tripB.id, { name: 'Somebody else’s track' }); - testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[52,13],[52,14]]', track.id); - testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[40,2],[40,3]]', otherTrack.id); + await updateRows(t, Places, { id: track.id }, { route_geometry: '[[52,13],[52,14]]' }); + await updateRows(t, Places, { id: otherTrack.id }, { route_geometry: '[[40,2],[40,3]]' }); return { tripA, tripB, dayA1, dayA2, dayB1, track, hotel, otherTrack }; } describe('RoadtripService', () => { let service: RoadtripService; - let f: ReturnType; + let f: Awaited>; beforeEach(async () => { service = await makeService(); - f = fixture(); + f = await fixture(); }); it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', async () => { diff --git a/server/tests/unit/nest/storage/storage-admin.service.test.ts b/server/tests/unit/nest/storage/storage-admin.service.test.ts index 72cba1f03..66eed2a7f 100644 --- a/server/tests/unit/nest/storage/storage-admin.service.test.ts +++ b/server/tests/unit/nest/storage/storage-admin.service.test.ts @@ -24,6 +24,9 @@ import { StorageRegistryService, BACKENDS_KEY, CATEGORIES_KEY } from '../../../. import { StorageService } from '../../../../src/nest/storage/storage.service'; import { StorageConflictError } from '../../../../src/nest/storage/storage.types'; import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow'; +import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings'; +import { deleteRows } from '../../../helpers/factories/rows'; +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; const tmpDirs: string[] = []; function makeTmpDir(): string { @@ -31,12 +34,11 @@ function makeTmpDir(): string { tmpDirs.push(dir); return dir; } -function setSetting(key: string, value: string): void { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value); +async function setSetting(key: string, value: string): Promise { + await setAppSetting(await sharedTestOrm(testDb), key, value); } -function readRow(key: string): string | undefined { - const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined; - return row?.value; +async function readRow(key: string): Promise { + return (await readAppSetting(await sharedTestOrm(testDb), key)) ?? undefined; } const S3_OPTIONS = { @@ -53,7 +55,7 @@ const S3_OPTIONS = { /** Real registry + real service over the in-memory DB. */ async function makeService(opts: { uploadsRoot?: string } = {}) { const uploadsRoot = opts.uploadsRoot ?? makeTmpDir(); - setSetting(BACKENDS_KEY, JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }])); + await setSetting(BACKENDS_KEY, JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }])); const env = { placePhotoDir: undefined }; const uow = await createTestUnitOfWork(testDb); const appSettings = await createTestAppSettingsRepo(testDb); @@ -92,8 +94,8 @@ async function put(service: StorageAdminService, config: StorageConfig): Promise await service.applyConfig({ ...config, version: (await (await service.state())).version }); } -beforeEach(() => { - testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run(); +beforeEach(async () => { + await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } }); }); afterEach(() => { vi.restoreAllMocks(); @@ -138,7 +140,7 @@ describe('StorageAdminService.state', () => { const { service, registry } = await makeService(); expect((await service.state()).configError).toBeNull(); - setSetting(CATEGORIES_KEY, 'garbage {'); + await setSetting(CATEGORIES_KEY, 'garbage {'); await registry.reload(); expect((await service.state()).configError).toBe(registry.lastLoadError()); expect((await service.state()).configError).not.toBeNull(); @@ -163,7 +165,7 @@ describe('StorageAdminService.applyConfig', () => { categories: { backups: 'nas-backups' }, })); expect(registry.resolve('backups').backendName).toBe('nas-backups'); // reload() ran - expect(JSON.parse(readRow(CATEGORIES_KEY)!)).toEqual({ backups: 'nas-backups' }); + expect(JSON.parse((await readRow(CATEGORIES_KEY))!)).toEqual({ backups: 'nas-backups' }); }); it('STORADM-011 encrypts plaintext secrets at rest', async () => { @@ -172,7 +174,7 @@ describe('StorageAdminService.applyConfig', () => { backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }], categories: { backups: 'off-box' }, })); - const stored = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record }>; + const stored = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record }>; const secret = String(stored.find((b) => b.name === 'off-box')!.options.secretAccessKey); expect(secret.startsWith('enc:v1:')).toBe(true); }); @@ -183,27 +185,27 @@ describe('StorageAdminService.applyConfig', () => { backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }], categories: { backups: 'off-box' }, })); - const before = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record }>; + const before = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record }>; const cipherBefore = before.find((b) => b.name === 'off-box')!.options.secretAccessKey; await put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }], categories: { backups: 'off-box' }, })); - const after = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record }>; + const after = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record }>; expect(after.find((b) => b.name === 'off-box')!.options.secretAccessKey).toBe(cipherBefore); }); it('STORADM-013 a mask on a renamed/new backend throws the re-enter error, persists nothing', async () => { const { service, uploadsRoot } = await makeService(); - const before = readRow(BACKENDS_KEY); + const before = await readRow(BACKENDS_KEY); await expect( put(service, configWith(uploadsRoot, { backends: [{ name: 'brand-new', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }], categories: {}, })), ).rejects.toThrow("re-enter the secret 'secretAccessKey' for 'brand-new'"); - expect(readRow(BACKENDS_KEY)).toBe(before); + expect(await readRow(BACKENDS_KEY)).toBe(before); }); it('STORADM-014 a plaintext secret saves without an explicit ENCRYPTION_KEY and is still encrypted at rest', async () => { @@ -213,7 +215,7 @@ describe('StorageAdminService.applyConfig', () => { backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }], categories: {}, })); - const stored = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record }>; + const stored = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record }>; const offBox = stored.find((b) => b.name === 'off-box')!; expect(offBox.options.secretAccessKey.startsWith('enc:v1:')).toBe(true); }); @@ -225,29 +227,29 @@ describe('StorageAdminService.applyConfig', () => { backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: encrypt_api_key('sk') } }], categories: { backups: 'off-box' }, })); - expect(readRow(BACKENDS_KEY)).toBeDefined(); + expect(await readRow(BACKENDS_KEY)).toBeDefined(); }); it('STORADM-016 preview() refusals surface verbatim and persist nothing', async () => { const { service, uploadsRoot, registry } = await makeService(); - const before = readRow(CATEGORIES_KEY); + const before = await readRow(CATEGORIES_KEY); await expect( put(service, configWith(uploadsRoot, { categories: { backups: 'nope' } })), ).rejects.toThrow("category 'backups' maps to unknown backend 'nope'"); - expect(readRow(CATEGORIES_KEY)).toBe(before); + expect(await readRow(CATEGORIES_KEY)).toBe(before); expect(registry.resolve('backups').backendName).toBe('backups-local'); // live state untouched }); it('STORADM-018 a mask sentinel in a NON-secret field throws and persists nothing', async () => { const { service, uploadsRoot } = await makeService(); - const before = readRow(BACKENDS_KEY); + const before = await readRow(BACKENDS_KEY); await expect( put(service, configWith(uploadsRoot, { backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, accessKeyId: MASKED_SETTING_VALUE } }], categories: {}, })), ).rejects.toThrow("backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value"); - expect(readRow(BACKENDS_KEY)).toBe(before); + expect(await readRow(BACKENDS_KEY)).toBe(before); }); it('STORADM-017 persists both rows in ONE transaction and reloads once', async () => { @@ -284,8 +286,8 @@ describe('StorageAdminService.applyConfig', () => { it('STORADM-031 a stale submitted version throws StorageConflictError and persists nothing (→ 409, audit #7)', async () => { const { service, uploadsRoot } = await makeService(); await put(service, configWith(uploadsRoot)); // version is now 1 - const beforeBackends = readRow(BACKENDS_KEY); - const beforeCategories = readRow(CATEGORIES_KEY); + const beforeBackends = await readRow(BACKENDS_KEY); + const beforeCategories = await readRow(CATEGORIES_KEY); await expect( service.applyConfig({ ...configWith(uploadsRoot), version: 0 }), // stale — current is 1 ).rejects.toThrow(StorageConflictError); @@ -297,8 +299,8 @@ describe('StorageAdminService.applyConfig', () => { expect((err as StorageConflictError).currentVersion).toBe(1); expect((err as StorageConflictError).submittedVersion).toBe(0); } - expect(readRow(BACKENDS_KEY)).toBe(beforeBackends); - expect(readRow(CATEGORIES_KEY)).toBe(beforeCategories); + expect(await readRow(BACKENDS_KEY)).toBe(beforeBackends); + expect(await readRow(CATEGORIES_KEY)).toBe(beforeCategories); expect((await service.state()).version).toBe(1); // unchanged — the conflicting save never wrote }); @@ -452,7 +454,7 @@ describe('StorageAdminService.testBackend', () => { // applyConfig encrypted the secret at rest — the snapshot the mirror // expansion reads holds ciphertext, so this probe exercises the full // snapshot → decryptBackendSecrets → ephemeral S3Driver chain. - expect(readRow(BACKENDS_KEY)).toContain('enc:v1:'); + expect(await readRow(BACKENDS_KEY)).toContain('enc:v1:'); const result = await service.testBackend({ name: 'cand-mirror', type: 'mirror', diff --git a/server/tests/unit/nest/thumbnail.service.test.ts b/server/tests/unit/nest/thumbnail.service.test.ts index 064edcb8f..eebf76aae 100644 --- a/server/tests/unit/nest/thumbnail.service.test.ts +++ b/server/tests/unit/nest/thumbnail.service.test.ts @@ -19,6 +19,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; +import { deleteRows, insertRow } from '../../helpers/factories/rows'; import { makeStorageFixture } from '../../helpers/storage-fixture'; // Category-addressed since slice 4: originals + thumbs are ('journey', ) @@ -136,13 +137,13 @@ describe('sweepOrphanThumbs (spec fix #2)', () => { return fp; }; - beforeEach(() => { - thumbsDb.prepare('DELETE FROM trek_photos').run(); + beforeEach(async () => { + await deleteRows(t, TrekPhotos); fs.rmSync(thumbsDir, { recursive: true, force: true }); }); it('THUMB-SWEEP-001: deletes strays, spares thumbs derivable from live journey rows', async () => { - thumbsDb.prepare("INSERT INTO trek_photos (provider, file_path) VALUES ('local', ?)").run('journey/live.jpg'); + await insertRow(t, TrekPhotos, { provider: 'local', file_path: 'journey/live.jpg' }); const liveName = journeyThumbName('journey/live.jpg'); // 'thumbs/.jpg' const livePath = writeThumb(path.basename(liveName)); const strayPath = writeThumb('deadbeefdeadbeef.jpg'); @@ -155,8 +156,11 @@ describe('sweepOrphanThumbs (spec fix #2)', () => { }); it('THUMB-SWEEP-002: spares a recorded thumbnail_path even without a matching file_path hash', async () => { - thumbsDb.prepare("INSERT INTO trek_photos (provider, file_path, thumbnail_path) VALUES ('local', ?, ?)") - .run('elsewhere/x.jpg', 'journey/thumbs/recorded00000000.jpg'); + await insertRow(t, TrekPhotos, { + provider: 'local', + file_path: 'elsewhere/x.jpg', + thumbnail_path: 'journey/thumbs/recorded00000000.jpg', + }); const recorded = writeThumb('recorded00000000.jpg'); const removed = await svc.sweepOrphanThumbs(); diff --git a/server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts b/server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts index a166068c9..a47de6010 100644 --- a/server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts +++ b/server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts @@ -20,6 +20,7 @@ import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user import { createTestAddonsService } from '../../helpers/test-addons'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { makePlugin } from '../../helpers/factories/plugins'; import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; @@ -51,13 +52,17 @@ afterAll(async () => { await t.close(); testDb.close(); }); beforeEach(() => vi.clearAllMocks()); /** installedDepRows() reads the `plugins` table directly — no discovery needed. */ -function installEnabledPlugin(id: string): void { - testDb - .prepare( - `INSERT INTO plugins (id, name, status, enabled, version, api_version, permissions, granted_permissions, capabilities, config, dependencies) - VALUES (?, ?, 'inactive', 1, '1.0.0', 1, '[]', '[]', '{}', '{}', '{}')`, - ) - .run(id, id); +async function installEnabledPlugin(id: string): Promise { + await makePlugin(t, id, { + status: 'inactive', + enabled: 1, + api_version: 1, + permissions: '[]', + granted_permissions: '[]', + capabilities: '{}', + config: '{}', + dependencies: '{}', + }); } async function buildRuntime(withOrm: boolean): Promise { @@ -94,7 +99,7 @@ async function buildRuntime(withOrm: boolean): Promise { describe('PluginRuntimeService.onApplicationBootstrap — absent-ORM boot branch (task-6-rereview2.md I-A)', () => { it('RT-BOOT-NOORM-001: without an ORM, boot logs the skip line and never calls activate', async () => { - installEnabledPlugin('rtboot-noorm'); + await installEnabledPlugin('rtboot-noorm'); const rt = await buildRuntime(false); const activateSpy = vi.spyOn(rt, 'activate'); @@ -113,7 +118,7 @@ describe('PluginRuntimeService.onApplicationBootstrap — absent-ORM boot branch }); it('RT-BOOT-NOORM-002: WITH an ORM, the same arrangement activates the plugin — proves case 001 is not vacuous', async () => { - installEnabledPlugin('rtboot-withorm'); + await installEnabledPlugin('rtboot-withorm'); const rt = await buildRuntime(true); const activateSpy = vi.spyOn(rt, 'activate').mockResolvedValue(undefined); diff --git a/server/tests/unit/plugins/rpc-host-inv-strip.test.ts b/server/tests/unit/plugins/rpc-host-inv-strip.test.ts index db9fa852a..48dde9b98 100644 --- a/server/tests/unit/plugins/rpc-host-inv-strip.test.ts +++ b/server/tests/unit/plugins/rpc-host-inv-strip.test.ts @@ -41,7 +41,7 @@ const tripsRegistry = () => { } as unknown as TripsRepository; const guards = new PluginGuards(db, {} as never, {} as never, {} as never); // Plan 3c Task 7: trips.getById (RP1) now reads through - // `EntityManager.getRepository(Trips).findRaw(...)`, not `db.prepare(...)`. + // `EntityManager.getRepository(Trips).findRaw(...)`, not a raw statement on the handle. const em = { getRepository: () => ({ findRaw: async () => ({ id: 1, title: 'Japan', feed_token: null }) }), } as unknown as EntityManager; diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index 8094c4e5c..cb67de691 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -8,16 +8,10 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); - function getPlaceWithTags(placeId: number | string) { - const p = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId); - if (!p) return null; - return { ...(p as object), category: null, tags: [] }; - } return { db, closeDb: () => {}, reinitialize: () => {}, - getPlaceWithTags, canAccessTrip: async () => null, isOwner: async () => false, }; @@ -30,6 +24,8 @@ vi.mock('../../../src/config', () => ({ import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; +import { findRow } from '../../helpers/factories/rows'; +import { Places } from '../../../src/db/entities/Places.entity'; import { createUser, createTrip } from '../../helpers/factories'; import { accommodationsOver } from '../../helpers/accommodations-service'; import { isUpdateConflict } from '../../../src/nest/common/conflictResult'; @@ -190,8 +186,8 @@ describe('PlacesService.update — optimistic concurrency', () => { expect((result.server as { name: string }).name).toBe('Original'); } // The row must NOT have been overwritten. - const row = testDb.prepare('SELECT name FROM places WHERE id = ?').get(place.id) as { name: string }; - expect(row.name).toBe('Original'); + const row = await findRow(await sharedTestOrm(testDb), Places, { id: place.id }); + expect(row?.name).toBe('Original'); }); it('returns null for a place that does not exist', async () => { @@ -203,6 +199,7 @@ describe('PlacesService.update — optimistic concurrency', () => { describe('updateItem (packing) — optimistic concurrency', () => { it('migration added updated_at and createItem stamps it', async () => { + // test-sql-allow: schema introspection, which no entity or repository maps. const cols = testDb.prepare("PRAGMA table_info('packing_items')").all() as { name: string }[]; expect(cols.map(c => c.name)).toContain('updated_at');