From e2ce2ad05c0ca6d239872d91fd5ff4c3a37a4dc7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 15:49:34 +0200 Subject: [PATCH] test(server): seed the service, MCP and route specs through the ORM factories Service, MCP, integration and e2e suites with up to a dozen raw statements now seed and read through the factories. A buildApp() suite uses the app's own ORM, a partial-module e2e or a unit suite a createTestOrm() or sharedTestOrm() handle on its database. Reads that used to come back undefined now come back null, and the assertions say so; where a projected SELECT was compared whole, the same columns are picked off the row. Two reads inside the scheduler's delivery callback stay synchronous raw statements, marked, so they keep seeing the row at the moment of delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in the module no longer exports. --- server/tests/e2e/auth.e2e.test.ts | 68 ++++++------- server/tests/e2e/files.e2e.test.ts | 20 ++-- server/tests/e2e/push.e2e.test.ts | 80 ++++++++------- server/tests/integration/atlas.test.ts | 45 ++++++--- .../integration/memories-unified.test.ts | 77 +++++++-------- .../tests/integration/notifications.test.ts | 98 +++++++++++-------- server/tests/integration/oidc.test.ts | 24 +++-- .../plugins/plugin-scheduler-sweep.test.ts | 25 ++--- server/tests/integration/security.test.ts | 30 +++--- server/tests/unit/idempotency-cleanup.test.ts | 49 ++++++---- .../unit/mcp/tools-leg-transport-mode.test.ts | 23 +++-- server/tests/unit/mcp/tools-todos.test.ts | 30 ++++-- server/tests/unit/nest/audit.service.test.ts | 58 ++++++----- .../unit/nest/doc-sync/doc-sync.job.test.ts | 69 +++++++------ server/tests/unit/nest/immich.service.test.ts | 97 ++++++++++-------- .../unit/nest/journey-book.service.test.ts | 41 ++++---- .../notifications/web-push-dispatch.test.ts | 46 ++++----- .../unit/nest/photo-resolver.service.test.ts | 68 +++++++------ .../tests/unit/nest/public-api-scopes.test.ts | 59 +++++------ .../unit/nest/route-usage.service.test.ts | 46 +++++++-- .../tests/unit/nest/synology.service.test.ts | 55 +++++++---- server/tests/unit/nest/todo.service.test.ts | 31 +++--- .../trek-photo-registration.service.test.ts | 58 +++++------ .../trip-membership.service.test.ts | 23 +++-- .../unit/services/airtrailImport.test.ts | 62 +++++++----- 25 files changed, 727 insertions(+), 555 deletions(-) diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts index e1218242d..45462916e 100644 --- a/server/tests/e2e/auth.e2e.test.ts +++ b/server/tests/e2e/auth.e2e.test.ts @@ -50,7 +50,12 @@ import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.i import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { countRows, deleteRows, findRow, findRows, updateRows } from '../helpers/factories/rows'; +import { readAppSetting, setAppSetting } from '../helpers/factories/settings'; +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; +import { AuditLog } from '../../src/db/entities/AuditLog.entity'; +import { Users } from '../../src/db/entities/Users.entity'; describe('Auth e2e (real auth guard + real service + real cookie service + temp SQLite)', () => { let server: Server; @@ -79,10 +84,11 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp return nest; } - const auditRows = (action: string) => - (db.prepare('SELECT COUNT(*) AS n FROM audit_log WHERE action = ?').get(action) as { n: number }).n; + let orm: TestOrm; + const auditRows = (action: string) => countRows(orm, AuditLog, { action }); beforeAll(async () => { + orm = await createTestOrm(db); const seeded = createUser(db as never, { username: 'auth-e2e', email: 'u@example.test' }); userId = seeded.user.id; userEmail = seeded.user.email; @@ -93,6 +99,7 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp afterAll(async () => { await app.close(); + await orm.close(); }); it('GET /app-config is optional-auth (200 without a cookie, real toggles)', async () => { @@ -119,22 +126,22 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp }); it('POST /login sets the httpOnly trek_session cookie and audits user.login', async () => { - const before = auditRows('user.login'); + const before = await auditRows('user.login'); const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: userPassword }); expect(res.status).toBe(200); expect(typeof res.body.token).toBe('string'); expect(res.body.user.id).toBe(userId); const setCookie = res.headers['set-cookie'] as unknown as string[]; expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true); - expect(auditRows('user.login')).toBe(before + 1); + expect(await auditRows('user.login')).toBe(before + 1); }, 10000); it('POST /login with a wrong password answers the generic 401 and audits user.login_failed', async () => { - const before = auditRows('user.login_failed'); + const before = await auditRows('user.login_failed'); const res = await request(server).post('/api/auth/login').send({ email: userEmail, password: 'wrong-password' }); expect(res.status).toBe(401); expect(res.body).toEqual({ error: 'Invalid email or password' }); - expect(auditRows('user.login_failed')).toBe(before + 1); + expect(await auditRows('user.login_failed')).toBe(before + 1); }, 10000); it('POST /login with remember_me sets a persistent cookie (Max-Age present)', async () => { @@ -227,7 +234,7 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp }, 10000); it('POST /register creates the user, sets the cookie and audits user.register', async () => { - const before = auditRows('user.register'); + const before = await auditRows('user.register'); const res = await request(server) .post('/api/auth/register') .send({ username: 'fresh-e2e', email: 'fresh-e2e@example.test', password: 'Str0ng!Pass1' }); @@ -236,9 +243,9 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp expect(res.body.user.username).toBe('fresh-e2e'); const setCookie = res.headers['set-cookie'] as unknown as string[]; expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true); - expect(auditRows('user.register')).toBe(before + 1); - const row = db.prepare('SELECT id FROM users WHERE email = ?').get('fresh-e2e@example.test'); - expect(row).toBeDefined(); + expect(await auditRows('user.register')).toBe(before + 1); + const row = await findRow(orm, Users, { email: 'fresh-e2e@example.test' }); + expect(row).not.toBeNull(); }); it('POST /login with a shapeless body answers the pipe 400 envelope', async () => { @@ -250,7 +257,7 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp it('PUT /me/api-keys stores the key instance-wide and audits it once (#1939)', async () => { const admin = createUser(db as never, { username: 'keys-admin', email: 'keys-admin@example.test', role: 'admin' }); - const before = auditRows('settings.api_keys_update'); + const before = await auditRows('settings.api_keys_update'); const res = await request(server) .put('/api/auth/me/api-keys') @@ -263,16 +270,14 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp expect(res.body).not.toHaveProperty('changedKeys'); // What every member's search will now resolve to, encrypted at rest. - const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'maps_api_key'").get() as { value: string }; - expect(stored.value).toMatch(/^enc:v1:/); + const stored = await readAppSetting(orm, 'maps_api_key'); + expect(stored).toMatch(/^enc:v1:/); // And the admin panel reads back what the search uses. const settings = await request(server).get('/api/auth/me/settings').set('Cookie', sessionCookie(admin.user.id)); expect(settings.body.settings.maps_api_key).toBe('e2e-google-key'); - expect(auditRows('settings.api_keys_update')).toBe(before + 1); - const row = db - .prepare("SELECT resource, details FROM audit_log WHERE action = 'settings.api_keys_update' ORDER BY id DESC LIMIT 1") - .get() as { resource: string; details: string }; + expect(await auditRows('settings.api_keys_update')).toBe(before + 1); + const [row] = await findRows(orm, AuditLog, { action: 'settings.api_keys_update' }, { id: 'desc' }); expect(row.resource).toBe('api_keys'); expect(row.details).toContain('maps_api_key'); expect(row.details).not.toContain('e2e-google-key'); @@ -282,7 +287,7 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp .put('/api/auth/me/api-keys') .set('Cookie', sessionCookie(admin.user.id)) .send({ maps_api_key: 'e2e-google-key' }); - expect(auditRows('settings.api_keys_update')).toBe(before + 1); + expect(await auditRows('settings.api_keys_update')).toBe(before + 1); }, 10000); it('GET /me/settings names the variable behind a key set in the environment, never its value (#1881)', async () => { @@ -306,15 +311,12 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp const admin = createUser(db as never, { username: 'keys-cfg-admin', email: 'keys-cfg-admin@example.test', role: 'admin' }); // Seeded here instead of riding on the save above, so running this case on // its own asserts the same thing. - const setInstanceKey = (value: string | null) => { + const setInstanceKey = async (value: string | null) => { if (value === null) { - db.prepare("DELETE FROM app_settings WHERE key = 'maps_api_key'").run(); + await deleteRows(orm, AppSettings, { key: 'maps_api_key' }); return; } - db.prepare( - `INSERT INTO app_settings (key, value) VALUES ('maps_api_key', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value` - ).run(encrypt_api_key(value)); + await setAppSetting(orm, 'maps_api_key', encrypt_api_key(value)); }; const hasMapsKey = async (userId: number) => { const res = await request(server).get('/api/auth/app-config').set('Cookie', sessionCookie(userId)); @@ -322,8 +324,8 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp return res.body.has_maps_key; }; - setInstanceKey('instance-configured-key'); - db.prepare('UPDATE users SET maps_api_key = NULL WHERE id = ?').run(admin.user.id); + await setInstanceKey('instance-configured-key'); + await updateRows(orm, Users, { id: admin.user.id }, { maps_api_key: null }); // Instance key, no column anywhere: the member searches with it, so the // client is told the feature is there. expect(await hasMapsKey(member.user.id)).toBe(true); @@ -331,22 +333,22 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp // The reported half of #1939: the key exists only in one admin's column. // It is not the member's to spend, so they are told there is none instead // of being offered a Google search that answers 403. - setInstanceKey(null); - db.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run(encrypt_api_key('admins-own-key'), admin.user.id); + await setInstanceKey(null); + await updateRows(orm, Users, { id: admin.user.id }, { maps_api_key: encrypt_api_key('admins-own-key') }); expect(await hasMapsKey(member.user.id)).toBe(false); // That same column still counts for the admin themselves. expect(await hasMapsKey(admin.user.id)).toBe(true); }); describe('passkey origin gate (#2147)', () => { - beforeAll(() => { - db.prepare("INSERT INTO app_settings (key, value) VALUES ('passkey_login', 'true') ON CONFLICT(key) DO UPDATE SET value = 'true'").run(); + beforeAll(async () => { + await setAppSetting(orm, 'passkey_login', 'true'); // What getAppUrl() really yields with APP_URL unset — the phantom config. appUrlRef.value = 'http://localhost:3001'; }); - afterAll(() => { - db.prepare("DELETE FROM app_settings WHERE key = 'passkey_login'").run(); + afterAll(async () => { + await deleteRows(orm, AppSettings, { key: 'passkey_login' }); appUrlRef.value = 'https://x'; }); diff --git a/server/tests/e2e/files.e2e.test.ts b/server/tests/e2e/files.e2e.test.ts index 6f3378925..e543106dc 100644 --- a/server/tests/e2e/files.e2e.test.ts +++ b/server/tests/e2e/files.e2e.test.ts @@ -84,11 +84,15 @@ import { FilesModule } from '../../src/nest/files/files.module'; import { PhotosModule } from '../../src/nest/photos/photos.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { deleteRows, insertRow, updateRows } from '../helpers/factories/rows'; +import { Trips } from '../../src/db/entities/Trips.entity'; +import { TripFiles } from '../../src/db/entities/TripFiles.entity'; describe('Files + photos e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; + let orm: TestOrm; async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, FilesModule, PhotosModule] }).compile(); @@ -101,9 +105,10 @@ describe('Files + photos e2e (real auth guard + temp SQLite)', () => { beforeAll(async () => { seedUser(db as never, { id: 1 }); - db.prepare('INSERT INTO trips (id, user_id, title) VALUES (5, 1, ?)').run('Trip'); - db.prepare("INSERT INTO trip_files (id, trip_id, filename, original_name, uploaded_by) VALUES (1, 5, 'stored-a.pdf', 'a.pdf', 1)").run(); - db.prepare("INSERT INTO trip_files (id, trip_id, filename, original_name, uploaded_by, starred) VALUES (9, 5, 'stored-b.pdf', 'b.pdf', 1, 0)").run(); + orm = await createTestOrm(db); + await insertRow(orm, Trips, { id: 5, user: 1, title: 'Trip' }); + await insertRow(orm, TripFiles, { id: 1, trip: 5, filename: 'stored-a.pdf', original_name: 'a.pdf', uploadedByRef: 1 }); + await insertRow(orm, TripFiles, { id: 9, trip: 5, filename: 'stored-b.pdf', original_name: 'b.pdf', uploadedByRef: 1, starred: 0 }); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); server = app.getHttpServer(); @@ -118,6 +123,7 @@ describe('Files + photos e2e (real auth guard + temp SQLite)', () => { afterAll(async () => { await app.close(); + await orm.close(); }); it('401 listing files without a session cookie', async () => { @@ -140,13 +146,13 @@ describe('Files + photos e2e (real auth guard + temp SQLite)', () => { // longer intercepts it. Trip 5 is a persistent row seeded once in // `beforeAll` (not re-seeded per test), so it is removed and restored // around this one assertion instead. - db.prepare('DELETE FROM trips WHERE id = 5').run(); + await deleteRows(orm, Trips, { id: 5 }); try { const res = await request(server).get('/api/trips/5/files').set('Cookie', sessionCookie(1)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); } finally { - db.prepare("INSERT INTO trips (id, user_id, title) VALUES (5, 1, 'Trip')").run(); + await insertRow(orm, Trips, { id: 5, user: 1, title: 'Trip' }); } }); @@ -156,7 +162,7 @@ describe('Files + photos e2e (real auth guard + temp SQLite)', () => { expect(res.body.file.id).toBe(9); expect(res.body.file.starred).toBe(1); // put it back so the case is order-independent - db.prepare('UPDATE trip_files SET starred = 0 WHERE id = 9').run(); + await updateRows(orm, TripFiles, { id: 9 }, { starred: 0 }); }); it('403 deleting without file_delete permission', async () => { diff --git a/server/tests/e2e/push.e2e.test.ts b/server/tests/e2e/push.e2e.test.ts index df792e76a..b62fa6a7f 100644 --- a/server/tests/e2e/push.e2e.test.ts +++ b/server/tests/e2e/push.e2e.test.ts @@ -32,7 +32,11 @@ import { NotificationsModule } from '../../src/nest/notifications/notifications. 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, findRows } from '../helpers/factories/rows'; +import { readAppSetting, setAppSetting } from '../helpers/factories/settings'; +import { makeUser } from '../helpers/factories/users'; +import { PushSubscriptions } from '../../src/db/entities/PushSubscriptions.entity'; const ENDPOINT = 'https://fcm.googleapis.com/fcm/send/e2e-device'; @@ -46,15 +50,22 @@ function subscription(endpoint = ENDPOINT) { }; } -function rows() { - return db - .prepare('SELECT user_id, endpoint, vapid_public_key, user_agent FROM push_subscriptions ORDER BY id') - .all() as { - user_id: number; - endpoint: string; - vapid_public_key: string; - user_agent: string | null; - }[]; +let orm: TestOrm; + +async function rows() { + return (await findRows(orm, PushSubscriptions, {}, { id: 'asc' })).map((r) => ({ + user_id: r.user_id, + endpoint: r.endpoint, + vapid_public_key: r.vapid_public_key, + user_agent: r.user_agent, + })); +} + +/** An app setting the push service keeps; fails the case when it is not there. */ +async function storedSetting(key: string): Promise { + const value = await readAppSetting(orm, key); + if (value === null) throw new Error(`no app setting ${key}`); + return value; } describe('Web Push e2e (real auth guard + temp SQLite)', () => { @@ -74,24 +85,22 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { } beforeAll(async () => { - // harness.ts's seedUser() omits password_hash, which the migrated schema - // requires NOT NULL: a raw insert of the same SeededUser shape instead. - const seed = db.prepare( - "INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (?, ?, ?, 'x', 'user', 0)", - ); - seed.run(1, 'e2e-user-1', 'one@example.test'); - seed.run(2, 'e2e-user-2', 'two@example.test'); + // The two users the session cookies are signed for, pinned to ids 1 and 2. + orm = await createTestOrm(db); + await makeUser(orm, { id: 1, username: 'e2e-user-1', email: 'one@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { id: 2, username: 'e2e-user-2', email: 'two@example.test', role: 'user', password_version: 0 }); app = await build(); server = app.getHttpServer(); }); - beforeEach(() => { - db.prepare('DELETE FROM push_subscriptions').run(); + beforeEach(async () => { + await deleteRows(orm, PushSubscriptions); safeFetchFollow.mockReset(); }); afterAll(async () => { await app.close(); + await orm.close(); }); it('401 on every push route without a session cookie', async () => { @@ -106,12 +115,10 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { }); it('GET public-key: the pair exists from bootstrap on, so the route only reads it', async () => { - const stored = db.prepare("SELECT value FROM app_settings WHERE key = 'web_push_vapid_public_key'").get() as { - value: string; - }; + const stored = await storedSetting('web_push_vapid_public_key'); const res = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toEqual({ publicKey: stored.value }); + expect(res.body).toEqual({ publicKey: stored }); const raw = Buffer.from(res.body.publicKey, 'base64url'); expect(raw.length).toBe(65); expect(raw[0]).toBe(0x04); @@ -128,7 +135,7 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { .send({ subscription: subscription() }); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, devices: 1 }); - expect(rows()).toEqual([ + expect(await rows()).toEqual([ { user_id: 1, endpoint: ENDPOINT, vapid_public_key: key.publicKey, user_agent: 'e2e-browser/1.0' }, ]); }); @@ -143,7 +150,7 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { .set('Cookie', sessionCookie(2)) .send({ subscription: subscription() }); expect(res.body).toEqual({ success: true, devices: 1 }); - expect(rows().map((r) => r.user_id)).toEqual([2]); + expect((await rows()).map((r) => r.user_id)).toEqual([2]); }); it('POST subscriptions: 400 with the bespoke message for a host that is not a push service', async () => { @@ -153,7 +160,7 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { .send({ subscription: subscription('https://example.com/collect') }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Push endpoint is not a known push service' }); - expect(rows()).toEqual([]); + expect(await rows()).toEqual([]); }); it('POST subscriptions: 400 from the contract when the keys are missing', async () => { @@ -176,7 +183,7 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { .set('Cookie', sessionCookie(2)) .send({ endpoint: ENDPOINT }); expect(stranger.status).toBe(200); - expect(rows()).toHaveLength(1); + expect(await rows()).toHaveLength(1); const owner = await request(server) .delete('/api/notifications/push/subscriptions') @@ -184,7 +191,7 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { .send({ endpoint: ENDPOINT }); expect(owner.status).toBe(200); expect(owner.body).toEqual({ success: true }); - expect(rows()).toEqual([]); + expect(await rows()).toEqual([]); const again = await request(server) .delete('/api/notifications/push/subscriptions') @@ -225,16 +232,15 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { }); it('a stored private key that no longer decrypts turns push off with a 503 and is never replaced', async () => { - const readKey = (key: string) => - (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string }).value; + const readKey = storedSetting; const unreadable = 'enc:v1:bm90IGEgY2lwaGVydGV4dA'; - const publicKey = readKey('web_push_vapid_public_key'); - const ciphertext = readKey('web_push_vapid_private_key'); + const publicKey = await readKey('web_push_vapid_public_key'); + const ciphertext = await readKey('web_push_vapid_private_key'); await request(server) .post('/api/notifications/push/subscriptions') .set('Cookie', sessionCookie(1)) .send({ subscription: subscription() }); - db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(unreadable, 'web_push_vapid_private_key'); + await setAppSetting(orm, 'web_push_vapid_private_key', unreadable); try { const key = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1)); expect(key.status).toBe(503); @@ -249,11 +255,11 @@ describe('Web Push e2e (real auth guard + temp SQLite)', () => { expect(test.body).toEqual({ success: false, error: 'Web Push is currently unavailable on this server' }); expect(safeFetchFollow).not.toHaveBeenCalled(); // The device registered before stays, and neither stored row was touched. - expect(rows().map((r) => r.endpoint)).toEqual([ENDPOINT]); - expect(readKey('web_push_vapid_public_key')).toBe(publicKey); - expect(readKey('web_push_vapid_private_key')).toBe(unreadable); + expect((await rows()).map((r) => r.endpoint)).toEqual([ENDPOINT]); + expect(await readKey('web_push_vapid_public_key')).toBe(publicKey); + expect(await readKey('web_push_vapid_private_key')).toBe(unreadable); } finally { - db.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, 'web_push_vapid_private_key'); + await setAppSetting(orm, 'web_push_vapid_private_key', ciphertext); } const back = await request(server).get('/api/notifications/push/public-key').set('Cookie', sessionCookie(1)); expect(back.body).toEqual({ publicKey }); diff --git a/server/tests/integration/atlas.test.ts b/server/tests/integration/atlas.test.ts index fb586cf09..56ca4c3d7 100644 --- a/server/tests/integration/atlas.test.ts +++ b/server/tests/integration/atlas.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'); @@ -27,10 +28,16 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { makePlace } from '../helpers/factories/places'; +import { findRow } from '../helpers/factories/rows'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { PlaceRegions } from '../../src/db/entities/PlaceRegions.entity'; import { getRegionGeo } from '../../src/nest/atlas/atlas-geo'; let nestApp: INestApplication; let app: Application; +/** The app's own ORM, which the factories seed and read through. */ +const orm = (): FactoryOrm => nestApp.get(MikroORM); beforeAll(async () => { // Stub the admin-1 GeoJSON download so /regions/geo is deterministic and never @@ -93,9 +100,10 @@ describe('Atlas stats', () => { const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); const past = createTrip(testDb, user.id, { title: 'Rome, last month', start_date: iso(-40), end_date: iso(-30) }); const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) }); - const insertPlace = testDb.prepare('INSERT INTO places (trip_id, name, address) VALUES (?, ?, ?)'); - insertPlace.run(past.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy'); - insertPlace.run(future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan'); + // No coordinates and no category: the country has to come from the address. + const unplaced = { lat: null, lng: null, category: null }; + await makePlace(orm(), past.id, { ...unplaced, name: 'Colosseum', address: 'Piazza del Colosseo, Rome, Italy' }); + await makePlace(orm(), future.id, { ...unplaced, name: 'Senso-ji', address: 'Asakusa, Tokyo, Japan' }); const res = await request(app) .get('/api/addons/atlas/stats') @@ -516,11 +524,14 @@ describe('Regions geo', () => { describe('A place that moves takes its Atlas country with it (#2527)', () => { // The region cache is filled by a background task that GET /regions only starts, // so wait for the row the way the next Atlas load would find it. + async function readRegion(placeId: number): Promise<{ country_code: string; region_code: string } | undefined> { + const row = await findRow(orm(), PlaceRegions, { place: placeId }); + return row ? { country_code: row.country_code, region_code: row.region_code } : undefined; + } + async function regionRowOf(placeId: number): Promise<{ country_code: string; region_code: string } | undefined> { for (let i = 0; i < 100; i++) { - const row = testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(placeId) as - | { country_code: string; region_code: string } - | undefined; + const row = await readRegion(placeId); if (row) return row; await new Promise((resolve) => setTimeout(resolve, 20)); } @@ -536,9 +547,13 @@ describe('A place that moves takes its Atlas country with it (#2527)', () => { it('ATLAS-015: correcting a place from France to Germany moves it on the Atlas', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Wrongly imported', start_date: '2025-05-01', end_date: '2025-05-05' }); - const place = testDb - .prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id') - .get(trip.id, 'Hotel', 48.8566, 2.3522, 'Rue de Rivoli, Paris, France') as { id: number }; + const place = await makePlace(orm(), trip.id, { + name: 'Hotel', + lat: 48.8566, + lng: 2.3522, + address: 'Rue de Rivoli, Paris, France', + category: null, + }); // The place is seen on the Atlas once, which caches France for it. await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id)); @@ -565,9 +580,13 @@ describe('A place that moves takes its Atlas country with it (#2527)', () => { it('ATLAS-015: an edit that leaves the location alone keeps the cached country', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris', start_date: '2025-05-01', end_date: '2025-05-05' }); - const place = testDb - .prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id') - .get(trip.id, 'Louvre', 48.8606, 2.3376, 'Rue de Rivoli, Paris, France') as { id: number }; + const place = await makePlace(orm(), trip.id, { + name: 'Louvre', + lat: 48.8606, + lng: 2.3376, + address: 'Rue de Rivoli, Paris, France', + category: null, + }); await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id)); const cached = await regionRowOf(place.id); expect(cached?.country_code).toBe('FR'); @@ -579,6 +598,6 @@ describe('A place that moves takes its Atlas country with it (#2527)', () => { .send({ notes: 'Closed on Tuesdays', lat: 48.8606, lng: 2.3376, address: 'Rue de Rivoli, Paris, France' }); expect(put.status).toBe(200); - expect(testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(place.id)).toEqual(cached); + expect(await readRegion(place.id)).toEqual(cached); }); }); diff --git a/server/tests/integration/memories-unified.test.ts b/server/tests/integration/memories-unified.test.ts index 210f69263..0ffe78751 100644 --- a/server/tests/integration/memories-unified.test.ts +++ b/server/tests/integration/memories-unified.test.ts @@ -10,6 +10,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'; // ── Hoisted DB mock ────────────────────────────────────────────────────────── @@ -41,6 +42,11 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db'; import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { findRow, findRows, updateRows } from '../helpers/factories/rows'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity'; +import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity'; +import { TripPhotos } from '../../src/db/entities/TripPhotos.entity'; let nestApp: INestApplication; let app: Application; @@ -52,6 +58,22 @@ beforeAll(async () => { app = nestApp.getHttpAdapter().getInstance(); }); +/** The app's own ORM, which the factories seed and read through. */ +const orm = (): FactoryOrm => nestApp.get(MikroORM); + +/** The trip_photos row pointing at the registered photo `assetId`, on `tripId` when given. */ +async function tripPhotoOf(assetId: string, tripId?: number) { + const photoIds = (await findRows(orm(), TrekPhotos, { asset_id: assetId })).map((p) => p.id); + return findRow(orm(), TripPhotos, { photo: { $in: photoIds }, ...(tripId !== undefined ? { trip: tripId } : {}) }); +} + +/** The trip_photos row for `assetId` on `tripId`; fails the case when there is none. */ +async function requireTripPhoto(assetId: string, tripId: number) { + const row = await tripPhotoOf(assetId, tripId); + if (!row) throw new Error(`no trip photo ${assetId} on trip ${tripId}`); + return row; +} + beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); @@ -61,7 +83,7 @@ beforeEach(async () => { // configure it before it's usable in production); the legacy test helper always // seeded it enabled, which is what these tests assume. Same convention // memories-synology.test.ts already uses for its own provider. - testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'immich'").run(); + await updateRows(orm(), PhotoProviders, { id: 'immich' }, { enabled: 1 }); }); afterAll(async () => { @@ -143,12 +165,9 @@ describe('Unified photo management', () => { expect(res.status).toBe(200); expect(res.body.added).toBe(2); - const rows = testDb.prepare(` - SELECT tkp.asset_id FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.trip_id = ? - `).all(trip.id) as any[]; - expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b'])); + const photoIds = (await findRows(orm(), TripPhotos, { trip: trip.id })).map((r) => r.photo_id); + const rows = await findRows(orm(), TrekPhotos, { id: { $in: photoIds } }); + expect(rows.map((r) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b'])); }); it('UNIFIED-005 — POST photos with empty selections returns 400', async () => { @@ -179,11 +198,7 @@ describe('Unified photo management', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false }); - const trekRef = testDb.prepare(` - SELECT tp.photo_id FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.trip_id = ? AND tkp.asset_id = ? - `).get(trip.id, 'asset-tog') as any; + const trekRef = await requireTripPhoto('asset-tog', trip.id); const res = await request(app) .put(`${photosUrl(trip.id)}/sharing`) @@ -191,12 +206,8 @@ describe('Unified photo management', () => { .send({ photo_id: trekRef.photo_id, shared: true }); expect(res.status).toBe(200); - const row = testDb.prepare(` - SELECT tp.shared FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tkp.asset_id = ? - `).get('asset-tog') as any; - expect(row.shared).toBe(1); + const row = await tripPhotoOf('asset-tog'); + expect(row?.shared).toBe(1); }); it('UNIFIED-008 — PUT photos/sharing on non-member trip returns 404', async () => { @@ -216,11 +227,7 @@ describe('Unified photo management', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich'); - const trekRef = testDb.prepare(` - SELECT tp.photo_id FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.trip_id = ? AND tkp.asset_id = ? - `).get(trip.id, 'asset-del') as any; + const trekRef = await requireTripPhoto('asset-del', trip.id); const res = await request(app) .delete(photosUrl(trip.id)) @@ -228,23 +235,14 @@ describe('Unified photo management', () => { .send({ photo_id: trekRef.photo_id }); expect(res.status).toBe(200); - const row = testDb.prepare(` - SELECT tp.* FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tkp.asset_id = ? - `).get('asset-del'); - expect(row).toBeUndefined(); + expect(await tripPhotoOf('asset-del')).toBeNull(); }); it('UNIFIED-009a — DELETE photos is held to the body contract, and still takes a numeric string', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); addTripPhoto(testDb, trip.id, user.id, 'asset-contract', 'immich'); - const trekRef = testDb.prepare(` - SELECT tp.photo_id FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.trip_id = ? AND tkp.asset_id = ? - `).get(trip.id, 'asset-contract') as any; + const trekRef = await requireTripPhoto('asset-contract', trip.id); // A DELETE that reads a body validates it like any other write, so a // photo_id that is neither a number nor a string never reaches the handler. @@ -264,12 +262,7 @@ describe('Unified photo management', () => { .send({ photo_id: String(trekRef.photo_id) }); expect(ok.status).toBe(200); - const row = testDb.prepare(` - SELECT tp.* FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tkp.asset_id = ? - `).get('asset-contract'); - expect(row).toBeUndefined(); + expect(await tripPhotoOf('asset-contract')).toBeNull(); }); it('UNIFIED-010 — DELETE photos on non-member trip returns 404', async () => { @@ -336,14 +329,14 @@ describe('Unified album-link management', () => { addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-enabled'); // Disable the immich provider - testDb.prepare('UPDATE photo_providers SET enabled = 0 WHERE id = ?').run('immich'); + await updateRows(orm(), PhotoProviders, { id: 'immich' }, { enabled: 0 }); const res = await request(app) .get(albumLinksUrl(trip.id)) .set('Cookie', authCookie(user.id)); // Re-enable for future tests - testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich'); + await updateRows(orm(), PhotoProviders, { id: 'immich' }, { enabled: 1 }); expect(res.status).toBe(400); // no providers enabled → error }); diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts index 44eda1fae..a846bef73 100644 --- a/server/tests/integration/notifications.test.ts +++ b/server/tests/integration/notifications.test.ts @@ -9,6 +9,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'); @@ -43,9 +44,17 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { findRow, updateRows } from '../helpers/factories/rows'; +import { makeNotification } from '../helpers/factories/notifications'; +import { setUserSetting } from '../helpers/factories/settings'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity'; +import { Notifications } from '../../src/db/entities/Notifications.entity'; let nestApp: INestApplication; let app: Application; +/** The app's own ORM, which the factories seed and read through. */ +const orm = (): FactoryOrm => nestApp.get(MikroORM); beforeAll(async () => { nestApp = await buildApp(); @@ -249,10 +258,8 @@ describe('PUT /api/notifications/preferences — matrix format', () => { expect(res.status).toBe(200); expect(res.body.preferences['trip_invite']['email']).toBe(true); - const row = testDb.prepare( - 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ).get(user.id, 'trip_invite', 'email'); - expect(row).toBeUndefined(); + const row = await findRow(orm(), NotificationChannelPreferences, { user: user.id, event_type: 'trip_invite', channel: 'email' }); + expect(row).toBeNull(); }); it('NROUTE-009 — partial update does not affect other preferences', async () => { @@ -375,7 +382,7 @@ describe('Notification test endpoints', () => { it('NOTIF-009 — POST /api/notifications/test-ntfy falls back to user saved topic', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', 'saved-user-topic')").run(user.id); + await setUserSetting(orm(), user.id, 'ntfy_topic', 'saved-user-topic'); const res = await request(app) .post('/api/notifications/test-ntfy') @@ -391,28 +398,34 @@ describe('Notification test endpoints', () => { // Helper: insert a boolean notification directly into the DB // ───────────────────────────────────────────────────────────────────────────── -function insertBooleanNotification(recipientId: number): number { - const result = testDb.prepare(` - INSERT INTO notifications ( - type, scope, target, sender_id, recipient_id, - title_key, title_params, text_key, text_params, - positive_text_key, negative_text_key, positive_callback, negative_callback - ) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}', - 'notif.action.accept', 'notif.action.decline', - '{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}' - ) - `).run(recipientId, recipientId); - return result.lastInsertRowid as number; +async function insertBooleanNotification(recipientId: number): Promise { + const row = await makeNotification(orm(), recipientId, { + type: 'boolean', + target: recipientId, + sender: null, + title_key: 'notif.test.title', + title_params: '{}', + text_key: 'notif.test.text', + text_params: '{}', + positive_text_key: 'notif.action.accept', + negative_text_key: 'notif.action.decline', + positive_callback: '{"action":"test_approve","payload":{}}', + negative_callback: '{"action":"test_deny","payload":{}}', + }); + return row.id; } -function insertSimpleNotification(recipientId: number): number { - const result = testDb.prepare(` - INSERT INTO notifications ( - type, scope, target, sender_id, recipient_id, - title_key, title_params, text_key, text_params - ) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}') - `).run(recipientId, recipientId); - return result.lastInsertRowid as number; +async function insertSimpleNotification(recipientId: number): Promise { + const row = await makeNotification(orm(), recipientId, { + type: 'simple', + target: recipientId, + sender: null, + title_key: 'notif.test.title', + title_params: '{}', + text_key: 'notif.test.text', + text_params: '{}', + }); + return row.id; } // ───────────────────────────────────────────────────────────────────────────── @@ -422,7 +435,7 @@ function insertSimpleNotification(recipientId: number): number { describe('POST /api/notifications/in-app/:id/respond', () => { it('NROUTE-011 — valid positive response returns success and updated notification', async () => { const { user } = createUser(testDb); - const id = insertBooleanNotification(user.id); + const id = await insertBooleanNotification(user.id); const res = await request(app) .post(`/api/notifications/in-app/${id}/respond`) @@ -437,7 +450,7 @@ describe('POST /api/notifications/in-app/:id/respond', () => { it('NROUTE-012 — invalid response value returns 400', async () => { const { user } = createUser(testDb); - const id = insertBooleanNotification(user.id); + const id = await insertBooleanNotification(user.id); const res = await request(app) .post(`/api/notifications/in-app/${id}/respond`) @@ -460,7 +473,7 @@ describe('POST /api/notifications/in-app/:id/respond', () => { it('NROUTE-014 — double response returns 400', async () => { const { user } = createUser(testDb); - const id = insertBooleanNotification(user.id); + const id = await insertBooleanNotification(user.id); await request(app) .post(`/api/notifications/in-app/${id}/respond`) @@ -519,8 +532,8 @@ describe('PUT /api/admin/notification-preferences', () => { describe('In-app notifications — CRUD with data', () => { it('NROUTE-017 — GET /in-app returns created notifications', async () => { const { user } = createUser(testDb); - insertSimpleNotification(user.id); - insertSimpleNotification(user.id); + await insertSimpleNotification(user.id); + await insertSimpleNotification(user.id); const res = await request(app) .get('/api/notifications/in-app') @@ -534,8 +547,8 @@ describe('In-app notifications — CRUD with data', () => { it('NROUTE-018 — unread count reflects actual unread notifications', async () => { const { user } = createUser(testDb); - insertSimpleNotification(user.id); - insertSimpleNotification(user.id); + await insertSimpleNotification(user.id); + await insertSimpleNotification(user.id); const res = await request(app) .get('/api/notifications/in-app/unread-count') @@ -547,7 +560,7 @@ describe('In-app notifications — CRUD with data', () => { it('NROUTE-019 — mark-read on existing notification succeeds and decrements unread count', async () => { const { user } = createUser(testDb); - const id = insertSimpleNotification(user.id); + const id = await insertSimpleNotification(user.id); const markRes = await request(app) .put(`/api/notifications/in-app/${id}/read`) @@ -563,9 +576,9 @@ describe('In-app notifications — CRUD with data', () => { it('NROUTE-020 — mark-unread on a read notification succeeds', async () => { const { user } = createUser(testDb); - const id = insertSimpleNotification(user.id); + const id = await insertSimpleNotification(user.id); // Mark read first - testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id); + await updateRows(orm(), Notifications, { id }, { is_read: 1 }); const res = await request(app) .put(`/api/notifications/in-app/${id}/unread`) @@ -573,13 +586,13 @@ describe('In-app notifications — CRUD with data', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(id) as { is_read: number }; - expect(row.is_read).toBe(0); + const row = await findRow(orm(), Notifications, { id }); + expect(row?.is_read).toBe(0); }); it('NROUTE-021 — DELETE on existing notification removes it', async () => { const { user } = createUser(testDb); - const id = insertSimpleNotification(user.id); + const id = await insertSimpleNotification(user.id); const res = await request(app) .delete(`/api/notifications/in-app/${id}`) @@ -587,16 +600,15 @@ describe('In-app notifications — CRUD with data', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const row = testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(id); - expect(row).toBeUndefined(); + expect(await findRow(orm(), Notifications, { id })).toBeNull(); }); it('NROUTE-022 — unread_only=true filter returns only unread notifications', async () => { const { user } = createUser(testDb); - const id1 = insertSimpleNotification(user.id); - insertSimpleNotification(user.id); + const id1 = await insertSimpleNotification(user.id); + await insertSimpleNotification(user.id); // Mark first one read - testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id1); + await updateRows(orm(), Notifications, { id: id1 }, { is_read: 1 }); const res = await request(app) .get('/api/notifications/in-app?unread_only=true') diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index e1548140d..75758d52d 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; +import { MikroORM } from '@mikro-orm/core'; // ── DB mock (async vi.mock factory over the migrated schema snapshot) ──────── @@ -37,6 +38,12 @@ import type { MockInstance } from 'vitest'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; +import { findRow, findRows } from '../helpers/factories/rows'; +import { readUser } from '../helpers/factories/users'; +import { setAppSetting } from '../helpers/factories/settings'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { AuditLog } from '../../src/db/entities/AuditLog.entity'; +import { Users } from '../../src/db/entities/Users.entity'; import { OidcService } from '../../src/nest/oidc/oidc.service'; /** Read one cookie's value out of a response's Set-Cookie header, as a browser would. */ @@ -57,6 +64,8 @@ const MOCK_DISCOVERY_DOC = { let nestApp: INestApplication; let app: Application; +/** The app's own ORM, which the factories seed and read through. */ +const orm = (): FactoryOrm => nestApp.get(MikroORM); let oidcSvc: OidcService; let mockDiscover: MockInstance; let mockExchangeCode: MockInstance; @@ -183,13 +192,11 @@ describe('GET /api/auth/oidc/callback', () => { expect(res.status).toBe(302); // The query from the report, against the same table. - const rows = testDb.prepare('SELECT user_id, action, details FROM audit_log WHERE user_id = ? ORDER BY id DESC').all(user.id) as - { user_id: number; action: string; details: string | null }[]; + const rows = await findRows(orm(), AuditLog, { user: user.id }, { id: 'desc' }); expect(rows.map(r => r.action)).toContain('user.login'); const login = rows.find(r => r.action === 'user.login')!; expect(JSON.parse(login.details || '{}')).toEqual({ method: 'oidc' }); - const counted = testDb.prepare('SELECT login_count FROM users WHERE id = ?').get(user.id) as { login_count: number }; - expect(counted.login_count).toBe(1); + expect((await readUser(orm(), user.id)).login_count).toBe(1); }); it('OIDC-005: new user gets created when registration is open', async () => { @@ -210,12 +217,11 @@ describe('GET /api/auth/oidc/callback', () => { expect(res.headers.location).toContain('/login?oidc_code='); // Verify user was created in DB - const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get() as { id: number; username: string; role: string } | undefined; - expect(newUser).toBeDefined(); + const newUser = await findRow(orm(), Users, { email: 'newuser@example.com' }); + expect(newUser).not.toBeNull(); // Registered, the way the audit log reports a password signup, and then logged in: // an admin reading user.register for who got an account sees the SSO ones too. - const rows = testDb.prepare('SELECT action, details FROM audit_log WHERE user_id = ? ORDER BY id').all(newUser!.id) as - { action: string; details: string | null }[]; + const rows = await findRows(orm(), AuditLog, { user: newUser!.id }, { id: 'asc' }); expect(rows.map(r => r.action)).toEqual(['user.register', 'user.login']); expect(JSON.parse(rows[0].details || '{}')).toEqual({ username: newUser!.username, email: 'newuser@example.com', role: newUser!.role, method: 'oidc' }); }); @@ -300,7 +306,7 @@ describe('GET /api/auth/oidc/callback', () => { // Need at least one existing user so isFirstUser=false createUser(testDb, { email: 'existing@example.com' }); // Disable registration - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await setAppSetting(orm(), 'allow_registration', 'false'); mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC); mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 }); diff --git a/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts b/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts index 4223c2a8e..5fea533cf 100644 --- a/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts +++ b/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts @@ -97,6 +97,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 { findRow, insertRow } from '../../helpers/factories/rows'; import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; @@ -297,12 +298,10 @@ describe('PluginRuntimeService scheduler sweep — registered through CronRegist // disk — a real installed plugin always has (at least) a `plugins` row, so this // fixture needs one too, or the erasure row would be reaped before ever // reaching the ACK-gated delivery path this test is actually proving. - testDb.prepare("INSERT INTO plugins (id, name) VALUES (?, ?)").run(pluginId, pluginId); - testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?,?,?,?,?)') - .run(pluginId, 'oneshot', oneShotSeedDueAt, 'null', null); - testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?,?,?,?,?)') - .run(pluginId, 'recurring', recurringSeedDueAt, 'null', 60_000); - testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?,?)').run(pluginId, 4242); + await insertRow(t, Plugins, { id: pluginId, name: pluginId }); + await insertRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'oneshot', due_at: oneShotSeedDueAt, payload: 'null', every_ms: null }); + await insertRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'recurring', due_at: recurringSeedDueAt, payload: 'null', every_ms: 60_000 }); + await insertRow(t, PluginUserErasureQueue, { plugin_id: pluginId, user_id: 4242 }); // Mark the plugin active without a real child spawn — the same technique // tests/unit/plugins/supervisor-lifecycle.test.ts already uses to drive @@ -321,9 +320,11 @@ describe('PluginRuntimeService scheduler sweep — registered through CronRegist // the row's disposition write must already have landed by the moment // delivery is attempted, since a crash here must never double-fire. if (name === 'oneshot') { + // test-sql-allow: read synchronously inside the delivery call, so it sees the row at that very moment. const row = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(id, 'oneshot'); expect(row).toBeUndefined(); // already deleted before delivery } else if (name === 'recurring') { + // test-sql-allow: read synchronously inside the delivery call, so it sees the row at that very moment. const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(id, 'recurring') as { due_at: number } | undefined; expect(row).toBeDefined(); expect(row!.due_at).toBeGreaterThan(recurringSeedDueAt); // already re-armed before delivery @@ -341,17 +342,17 @@ describe('PluginRuntimeService scheduler sweep — registered through CronRegist .toEqual(['oneshot', 'recurring']); expect(invokeCalls).toContainEqual({ method: 'invoke.deleteUserData', payload: { userId: 4242 } }); - const oneShotRow = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'oneshot'); - expect(oneShotRow).toBeUndefined(); - const recurringRow = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'recurring') as { due_at: number } | undefined; - expect(recurringRow).toBeDefined(); + const oneShotRow = await findRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'oneshot' }); + expect(oneShotRow).toBeNull(); + const recurringRow = await findRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'recurring' }); + expect(recurringRow).not.toBeNull(); // Re-armed to Date.now() (read fresh, at fire time — not the seed timestamp) // + every_ms: bounded rather than exact, since real wall-clock time elapses // between seeding the row and the tick firing it. expect(recurringRow!.due_at).toBeGreaterThanOrEqual(now + 60_000); expect(recurringRow!.due_at).toBeLessThan(now + 60_000 + 30_000); - const erasureRow = testDb.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?').get(pluginId, 4242); - expect(erasureRow).toBeUndefined(); // drained on ACK + const erasureRow = await findRow(t, PluginUserErasureQueue, { plugin_id: pluginId, user_id: 4242 }); + expect(erasureRow).toBeNull(); // drained on ACK } finally { invokeSpy.mockRestore(); supervisor.running.delete(pluginId); diff --git a/server/tests/integration/security.test.ts b/server/tests/integration/security.test.ts index 89f05115f..fba41443c 100644 --- a/server/tests/integration/security.test.ts +++ b/server/tests/integration/security.test.ts @@ -11,6 +11,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'; import path from 'path'; import fs from 'fs'; @@ -34,9 +35,17 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip } from '../helpers/factories'; import { authCookie, authHeader, generateToken } from '../helpers/auth'; +import { findRows, updateRows } from '../helpers/factories/rows'; +import { readUser } from '../helpers/factories/users'; +import { setAppSetting } from '../helpers/factories/settings'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { AuditLog } from '../../src/db/entities/AuditLog.entity'; +import { TripFiles } from '../../src/db/entities/TripFiles.entity'; let nestApp: INestApplication; let app: Application; +/** The app's own ORM, which the factories seed and read through. */ +const orm = (): FactoryOrm => nestApp.get(MikroORM); const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg'); const uploadsDir = path.join(__dirname, '../../uploads/files'); @@ -44,13 +53,13 @@ beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); + await setAppSetting(orm(), 'allowed_file_types', '*'); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); + await setAppSetting(orm(), 'allowed_file_types', '*'); }); afterAll(async () => { @@ -108,7 +117,7 @@ describe('API key encryption', () => { .set('Cookie', authCookie(user.id)) .send({ openweather_api_key: 'test-api-key-12345' }); - const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any; + const row = await readUser(orm(), user.id); expect(row.openweather_api_key).toMatch(/^enc:v1:/); }); @@ -126,19 +135,16 @@ describe('API key encryption', () => { // changedKeys is internal: the client body is what it always was. expect(first.body).not.toHaveProperty('changedKeys'); - const rows = () => - testDb - .prepare("SELECT details FROM audit_log WHERE action = 'settings.api_keys_update'") - .all() as { details: string | null }[]; - expect(rows()).toHaveLength(1); - expect(rows()[0].details).toContain('openweather_api_key'); - expect(rows()[0].details).not.toContain('test-api-key-12345'); + const rows = () => findRows(orm(), AuditLog, { action: 'settings.api_keys_update' }); + expect(await rows()).toHaveLength(1); + expect((await rows())[0].details).toContain('openweather_api_key'); + expect((await rows())[0].details).not.toContain('test-api-key-12345'); // The same value again writes no second row. This is the real test of the // cleartext comparison: encryption uses a random IV, so the stored blob // differs on every save even when the key does not. await save(); - expect(rows()).toHaveLength(1); + expect(await rows()).toHaveLength(1); }); it('SEC-008 — GET /api/auth/me does not return plaintext API key', async () => { @@ -192,7 +198,7 @@ describe('File download path traversal', () => { expect(upload.status).toBe(201); const fileId = upload.body.file.id; - testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId); + await updateRows(orm(), TripFiles, { id: fileId }, { filename: '../../etc/passwd' }); const res = await request(app) .get(`/api/trips/${trip.id}/files/${fileId}/download`) diff --git a/server/tests/unit/idempotency-cleanup.test.ts b/server/tests/unit/idempotency-cleanup.test.ts index 4584d8b4b..ab61156f9 100644 --- a/server/tests/unit/idempotency-cleanup.test.ts +++ b/server/tests/unit/idempotency-cleanup.test.ts @@ -12,6 +12,7 @@ import { createTestOrm, type TestOrm } from '../helpers/test-orm'; import { purgeExpiredIdempotencyKeys } from '../../src/nest/common/idempotency-cleanup'; import { IdempotencyCleanupJob } from '../../src/nest/common/idempotency-cleanup.job'; import { IdempotencyKeys } from '../../src/db/entities/IdempotencyKeys.entity'; +import { countRows, deleteRows, findRows, insertRow } from '../helpers/factories/rows'; import type { IdempotencyKeysRepository } from '../../src/db/repositories/IdempotencyKeys.repository'; import type { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service'; @@ -29,46 +30,55 @@ beforeAll(async () => { }); afterAll(async () => { await t.close(); testDb.close(); }); -function insertKey(key: string, ageSeconds: number, nowSec = NOW_SEC): void { - testDb.prepare( - `INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) - VALUES (?, 1, 'POST', '/x', 200, '{}', ?)`, - ).run(key, nowSec - ageSeconds); +async function insertKey(key: string, ageSeconds: number, nowSec = NOW_SEC): Promise { + await insertRow(t, IdempotencyKeys, { + key, + user: 1, + method: 'POST', + path: '/x', + status_code: 200, + response_body: '{}', + created_at: nowSec - ageSeconds, + }); } -beforeEach(() => { +/** The keys left in the table. */ +async function remainingKeys(): Promise { + return (await findRows(t, IdempotencyKeys)).map((r) => r.key); +} + +beforeEach(async () => { testDb.pragma('foreign_keys = OFF'); // fixtures reference a user we don't seed here - testDb.prepare('DELETE FROM idempotency_keys').run(); + await deleteRows(t, IdempotencyKeys); t.clear(); }); -afterEach(() => { - testDb.prepare('DELETE FROM idempotency_keys').run(); +afterEach(async () => { + await deleteRows(t, IdempotencyKeys); testDb.pragma('foreign_keys = ON'); delete process.env.IDEMPOTENCY_TTL_SECONDS; }); describe('purgeExpiredIdempotencyKeys', () => { it('removes keys older than the 30-day default, keeps recent ones', async () => { - insertKey('old', 31 * DAY); - insertKey('fresh', 5 * DAY); + await insertKey('old', 31 * DAY); + await insertKey('fresh', 5 * DAY); const removed = await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys); expect(removed).toBe(1); - const keys = testDb.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key); - expect(keys).toEqual(['fresh']); + expect(await remainingKeys()).toEqual(['fresh']); }); it('keeps a 25-day-old key that the old 24h TTL would have dropped', async () => { - insertKey('offline-trip', 25 * DAY); + await insertKey('offline-trip', 25 * DAY); expect(await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys)).toBe(0); - expect(testDb.prepare('SELECT COUNT(*) c FROM idempotency_keys').get()).toMatchObject({ c: 1 }); + expect(await countRows(t, IdempotencyKeys)).toBe(1); }); it('respects the IDEMPOTENCY_TTL_SECONDS override', async () => { process.env.IDEMPOTENCY_TTL_SECONDS = String(DAY); - insertKey('twoDays', 2 * DAY); + await insertKey('twoDays', 2 * DAY); expect(await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys)).toBe(1); }); }); @@ -106,13 +116,12 @@ describe('IdempotencyCleanupJob', () => { // The tick uses the live clock, so these fixtures age against Date.now() // (the pure-function cases above pin their own fixed NOW instead). const liveNowSec = Math.floor(Date.now() / 1000); - insertKey('old', 31 * DAY, liveNowSec); - insertKey('fresh', 5 * DAY, liveNowSec); + await insertKey('old', 31 * DAY, liveNowSec); + await insertKey('fresh', 5 * DAY, liveNowSec); const { job } = makeJob(); await job.tick(); - const keys = testDb.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key); - expect(keys).toEqual(['fresh']); + expect(await remainingKeys()).toEqual(['fresh']); }); it('a failing purge is contained to the Idempotency cleanup log line', async () => { diff --git a/server/tests/unit/mcp/tools-leg-transport-mode.test.ts b/server/tests/unit/mcp/tools-leg-transport-mode.test.ts index a1f70b625..e4dd125de 100644 --- a/server/tests/unit/mcp/tools-leg-transport-mode.test.ts +++ b/server/tests/unit/mcp/tools-leg-transport-mode.test.ts @@ -22,6 +22,10 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAssignment } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { findRow, updateRows } from '../../helpers/factories/rows'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { Days } from '../../../src/db/entities/Days.entity'; beforeEach(() => { resetTestDb(testDb); @@ -29,7 +33,14 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -52,7 +63,7 @@ describe('Tool: set_assignment_route_excluded (#2532)', () => { await withHarness(user.id, async (h) => { const out = parseToolResult(await h.client.callTool({ name: 'set_assignment_route_excluded', arguments: { tripId: trip.id, assignmentId: assignment.id, excluded: true } })) as any; expect(out.assignment.route_excluded).toBe(true); - expect(testDb.prepare('SELECT route_excluded FROM day_assignments WHERE id = ?').get(assignment.id)).toEqual({ route_excluded: 1 }); + expect((await findRow(orm, DayAssignments, { id: assignment.id }))?.route_excluded).toBe(1); const back = parseToolResult(await h.client.callTool({ name: 'set_assignment_route_excluded', arguments: { tripId: trip.id, assignmentId: assignment.id, excluded: false } })) as any; expect(back.assignment.route_excluded).toBe(false); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object)); @@ -82,7 +93,7 @@ describe('Tool: set_leg_transport_mode', () => { const assignment = createDayAssignment(testDb, day.id, place.id); // Seed the incoming column so the assertion below proves it survives; the factory // leaves it NULL, so without this it would pass whether or not the tool touched it. - testDb.prepare('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?').run('walking', assignment.id); + await updateRows(orm, DayAssignments, { id: assignment.id }, { incoming_leg_transport_mode: 'walking' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -103,7 +114,7 @@ describe('Tool: set_leg_transport_mode', () => { const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); // Seed the outgoing column so we prove incoming does NOT touch it (panel item 4). - testDb.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run('driving', assignment.id); + await updateRows(orm, DayAssignments, { id: assignment.id }, { leg_transport_mode: 'driving' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -139,7 +150,7 @@ describe('Tool: set_leg_transport_mode', () => { const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); - testDb.prepare('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?').run('cycling', assignment.id); + await updateRows(orm, DayAssignments, { id: assignment.id }, { leg_transport_mode: 'cycling' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -221,7 +232,7 @@ describe('Tool: set_day_default_transport_mode', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - testDb.prepare('UPDATE days SET default_transport_mode = ? WHERE id = ?').run('driving', day.id); + await updateRows(orm, Days, { id: day.id }, { default_transport_mode: 'driving' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ diff --git a/server/tests/unit/mcp/tools-todos.test.ts b/server/tests/unit/mcp/tools-todos.test.ts index 78a35f643..83b6abe79 100644 --- a/server/tests/unit/mcp/tools-todos.test.ts +++ b/server/tests/unit/mcp/tools-todos.test.ts @@ -29,6 +29,12 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createTodoItem } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { ADDON_IDS } from '../../../src/addons'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { findRow, insertRow } from '../../helpers/factories/rows'; +import { makeTodoItem } from '../../helpers/factories/todos'; +import { setAddonEnabled } from '../../helpers/factories/settings'; +import { TodoItems } from '../../../src/db/entities/TodoItems.entity'; +import { TodoCategoryAssignees } from '../../../src/db/entities/TodoCategoryAssignees.entity'; beforeEach(() => { resetTestDb(testDb); @@ -36,7 +42,14 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -180,8 +193,7 @@ describe('Tool: update_todo', () => { it('clears due_date when passed null', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')").run(trip.id); - const item = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as any; + const item = await makeTodoItem(orm, trip.id, { name: 'Task', checked: 0, sort_order: 0, due_date: '2025-01-01' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_todo', @@ -289,7 +301,7 @@ describe('Tool: delete_todo', () => { const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); - expect(testDb.prepare('SELECT id FROM todo_items WHERE id = ?').get(item.id)).toBeUndefined(); + expect(await findRow(orm, TodoItems, { id: item.id })).toBeNull(); }); }); @@ -342,8 +354,8 @@ describe('Tool: reorder_todos', () => { const data = parseToolResult(result) as any; expect(data.success).toBe(true); // item2 should now have sort_order 0 - const updated = testDb.prepare('SELECT sort_order FROM todo_items WHERE id = ?').get(item2.id) as any; - expect(updated.sort_order).toBe(0); + const updated = await findRow(orm, TodoItems, { id: item2.id }); + expect(updated?.sort_order).toBe(0); }); }); @@ -397,7 +409,7 @@ describe('Tool: set_todo_category_assignees', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // Set then clear - testDb.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Booking', user.id); + await insertRow(orm, TodoCategoryAssignees, { trip: trip.id, category_name: 'Booking', user: user.id }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'set_todo_category_assignees', @@ -468,7 +480,7 @@ describe('Todo tools — packing addon gating', () => { it('registers nothing (tools or resource) when the packing addon is disabled', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run(ADDON_IDS.PACKING); + await setAddonEnabled(orm, ADDON_IDS.PACKING, false); try { await withHarness(user.id, async (h) => { const names = (await h.client.listTools()).tools.map((t) => t.name); @@ -477,7 +489,7 @@ describe('Todo tools — packing addon gating', () => { await expect(h.client.readResource({ uri: `trek://trips/${trip.id}/todos` })).rejects.toThrow(); }); } finally { - testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.PACKING); + await setAddonEnabled(orm, ADDON_IDS.PACKING, true); } }); }); diff --git a/server/tests/unit/nest/audit.service.test.ts b/server/tests/unit/nest/audit.service.test.ts index 4482be48c..90537d2e0 100644 --- a/server/tests/unit/nest/audit.service.test.ts +++ b/server/tests/unit/nest/audit.service.test.ts @@ -27,6 +27,7 @@ import { Users } from '../../../src/db/entities/Users.entity'; import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; import { AuditService } from '../../../src/nest/audit/audit.service'; +import { deleteRows, findRow, insertRow } from '../../helpers/factories/rows'; import { getClientIp } from '../../../src/nest/audit/client-ip'; import { logInfo, logDebug, logError } from '../../../src/nest/audit/audit-log.logger'; @@ -44,10 +45,10 @@ beforeAll(async () => { svc = new AuditService(auditLogRepo, usersRepo); }); -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); - testDb.prepare('DELETE FROM audit_log').run(); - testDb.prepare('DELETE FROM users').run(); + await deleteRows(t, AuditLog); + await deleteRows(t, Users); t.clear(); }); @@ -109,37 +110,48 @@ describe('getClientIp', () => { // ── writeAudit (real DB, through the repositories) ──────────────────────────── -function seedUser(id: number, email: string): void { - testDb.prepare( - "INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, 'x', 'user')" - ).run(id, `u${id}`, email); +async function seedUser(id: number, email: string): Promise { + await insertRow(t, Users, { id, username: `u${id}`, email, password_hash: 'x', role: 'user' }); +} + +/** The one audit row the case wrote. */ +async function auditRow() { + const row = await findRow(t, AuditLog, {}); + if (!row) throw new Error('no audit row was written'); + return row; } describe('writeAudit', () => { it('AUDIT-SVC-008: inserts the row and logs the labeled summary line', async () => { - seedUser(1, 'a@b.c'); + await seedUser(1, 'a@b.c'); await svc.writeAudit({ userId: 1, action: 'trip.create', resource: 'trip', details: { title: 'Rome' }, ip: '1.2.3.4' }); - const row = testDb.prepare('SELECT user_id, action, resource, details, ip FROM audit_log').get(); - expect(row).toEqual({ user_id: 1, action: 'trip.create', resource: 'trip', details: '{"title":"Rome"}', ip: '1.2.3.4' }); + const row = await auditRow(); + expect({ user_id: row.user_id, action: row.action, resource: row.resource, details: row.details, ip: row.ip }).toEqual({ + user_id: 1, + action: 'trip.create', + resource: 'trip', + details: '{"title":"Rome"}', + ip: '1.2.3.4', + }); expect(logInfo).toHaveBeenCalledWith('a@b.c created trip "Rome" ip=1.2.3.4'); }); it('AUDIT-SVC-009: empty details object stores NULL details and skips the debug fallback', async () => { - seedUser(1, 'a@b.c'); + await seedUser(1, 'a@b.c'); await svc.writeAudit({ userId: 1, action: 'user.login', details: {}, ip: '1.2.3.4' }); - const row = testDb.prepare('SELECT details FROM audit_log').get() as { details: string | null }; + const row = await auditRow(); expect(row.details).toBeNull(); expect(logDebug).not.toHaveBeenCalled(); }); it('AUDIT-SVC-010: unknown action keeps the raw key; empty-email/zero/null userIds resolve', async () => { - seedUser(1, 'a@b.c'); + await seedUser(1, 'a@b.c'); await svc.writeAudit({ userId: 1, action: 'custom.thing', ip: '9.9.9.9' }); expect(logInfo).toHaveBeenLastCalledWith('a@b.c custom.thing ip=9.9.9.9'); - seedUser(42, ''); // falsy email → the `row?.email || uid:N` fallback + await seedUser(42, ''); // falsy email → the `row?.email || uid:N` fallback await svc.writeAudit({ userId: 42, action: 'user.login', ip: '9.9.9.9' }); expect(logInfo).toHaveBeenLastCalledWith('uid:42 logged in ip=9.9.9.9'); - seedUser(0, 'zero@b.c'); // since the quirk fix, a real id 0 resolves via the DB + await seedUser(0, 'zero@b.c'); // since the quirk fix, a real id 0 resolves via the DB await svc.writeAudit({ userId: 0, action: 'user.login', ip: '9.9.9.9' }); expect(logInfo).toHaveBeenLastCalledWith('zero@b.c logged in ip=9.9.9.9'); await svc.writeAudit({ userId: null, action: 'user.login', ip: '9.9.9.9' }); @@ -147,7 +159,7 @@ describe('writeAudit', () => { }); it('AUDIT-SVC-019: a trip title with a line break cannot forge a second log line', async () => { - seedUser(1, 'a@b.c'); + await seedUser(1, 'a@b.c'); await svc.writeAudit({ userId: 1, action: 'trip.create', @@ -160,15 +172,15 @@ describe('writeAudit', () => { }); it('AUDIT-SVC-011: omitted resource/ip store NULL and the log line ends ip=-', async () => { - seedUser(1, 'a@b.c'); + await seedUser(1, 'a@b.c'); await svc.writeAudit({ userId: 1, action: 'user.login' }); - const row = testDb.prepare('SELECT resource, ip FROM audit_log').get(); - expect(row).toEqual({ resource: null, ip: null }); + const row = await auditRow(); + expect({ resource: row.resource, ip: row.ip }).toEqual({ resource: null, ip: null }); expect(logInfo).toHaveBeenCalledWith('a@b.c logged in ip=-'); }); it('AUDIT-SVC-012: debugDetails wins the debug line; detailsJson is the fallback', async () => { - seedUser(1, 'a@b.c'); + await seedUser(1, 'a@b.c'); await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true }, debugDetails: { raw: 1 } }); expect(logDebug).toHaveBeenLastCalledWith('AUDIT settings.app_update userId=1 {"raw":1}'); await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { require_mfa: true } }); @@ -191,7 +203,7 @@ describe('writeAudit', () => { }); it('AUDIT-SVC-021: a MikroORM ValidationError from the email lookup still resolves to uid:, but is logged with a distinct message', async () => { - seedUser(1, 'a@b.c'); + await seedUser(1, 'a@b.c'); const spy = vi.spyOn(usersRepo, 'getEmail').mockRejectedValueOnce(ValidationError.cannotUseGlobalContext()); await svc.writeAudit({ userId: 1, action: 'user.login' }); expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('uid:1 logged in')); @@ -200,7 +212,7 @@ describe('writeAudit', () => { }); it('AUDIT-SVC-018: settings.api_keys_update names the changed keys and nothing else (#1939)', async () => { - seedUser(1, 'admin@b.c'); + await seedUser(1, 'admin@b.c'); await svc.writeAudit({ userId: 1, action: 'settings.api_keys_update', @@ -218,7 +230,7 @@ describe('writeAudit', () => { }); it('AUDIT-SVC-014: buildInfoSummary variants (settings parts, login empty brief)', async () => { - seedUser(1, 'a@b.c'); + await seedUser(1, 'a@b.c'); await svc.writeAudit({ userId: 1, action: 'settings.app_update', details: { notification_channel: 'smtp', require_mfa: false }, ip: '1.1.1.1' }); expect(logInfo).toHaveBeenLastCalledWith('a@b.c updated settings (channel=smtp, mfa=false) ip=1.1.1.1'); await svc.writeAudit({ userId: 1, action: 'user.login', details: { anything: true }, ip: '1.1.1.1' }); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts index 11b667865..5b07fbc2c 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts @@ -40,7 +40,11 @@ import type { FilesService } from '../../../../src/nest/files/files.service'; import type { StorageService } from '../../../../src/nest/storage/storage.service'; import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service'; import { createTrip, createUser } from '../../../helpers/factories'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo } from '../../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, sharedTestOrm } from '../../../helpers/test-uow'; +import { findRow, insertRow, updateRows } from '../../../helpers/factories/rows'; +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 { createTestDocumentConnectionsRepo, createTestDocumentProviderFieldsRepo, @@ -443,9 +447,9 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => { /** A job of its own per pass, so the interval never decides whether a tick runs. */ const tick = () => new DocSyncJob(appSettingsRepo, service, config, addons, registrar).tick(); - const switchProvider = (id: string, on: boolean) => - testDb.prepare('UPDATE document_providers SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id); - const linkRow = (id: number) => testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id); + const switchProvider = async (id: string, on: boolean) => + updateRows(await sharedTestOrm(testDb), DocumentProviders, { id }, { enabled: on ? 1 : 0 }); + const linkRow = async (id: number) => findRow(await sharedTestOrm(testDb), TripDocumentLinks, { id }); beforeAll(async () => { appSettingsRepo = await createTestAppSettingsRepo(testDb); @@ -474,53 +478,60 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => { ); const ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync-job.test' }).user.id; const tripId = createTrip(testDb, ownerId, { title: 'Japan' }).id; - const bind = (providerId: string) => { - const conn = testDb - .prepare( - `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings) - VALUES (?, ?, ?, 'https://docs.example.com', NULL, '{}')`, - ) - .run(tripId, providerId, ownerId); - return Number(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, created_by) - VALUES (?, ?, ?, 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, ?)`, - ) - .run(tripId, conn.lastInsertRowid, providerId, ownerId).lastInsertRowid); + const orm = await sharedTestOrm(testDb); + const bind = async (providerId: string) => { + const connectionId = await insertRow(orm, DocumentConnections, { + trip: tripId, + provider: providerId, + ownerUser: ownerId, + base_url: 'https://docs.example.com', + secrets: null, + settings: '{}', + }); + return insertRow(orm, TripDocumentLinks, { + trip: tripId, + connection: connectionId, + provider_id: providerId, + remote_scope_key: 'tag:1', + remote_label: 'Japan', + direction: 'both', + delete_policy: 'unlink', + conflict_policy: 'manual', + sync_enabled: 1, + createdByRef: ownerId, + }); }; - paperlessLink = bind('paperless'); - nextcloudLink = bind('nextcloud'); + paperlessLink = await bind('paperless'); + nextcloudLink = await bind('nextcloud'); }); afterAll(() => testDb.close()); - beforeEach(() => { - switchProvider('paperless', false); - switchProvider('nextcloud', true); + beforeEach(async () => { + await switchProvider('paperless', false); + await switchProvider('nextcloud', true); }); it('runs the bindings that may run and leaves the switched-off one exactly as it was', async () => { - const before = linkRow(paperlessLink); + const before = await linkRow(paperlessLink); await tick(); expect(nextcloud.list).toHaveBeenCalledTimes(1); expect(paperless.resolveScope).not.toHaveBeenCalled(); expect(paperless.list).not.toHaveBeenCalled(); - expect(linkRow(paperlessLink)).toEqual(before); - expect(linkRow(nextcloudLink)).toMatchObject({ last_sync_state: 'ok' }); + expect(await linkRow(paperlessLink)).toEqual(before); + expect(await linkRow(nextcloudLink)).toMatchObject({ last_sync_state: 'ok' }); }); it('picks the binding up again on the next tick once the provider is back on', async () => { await tick(); expect(paperless.list).not.toHaveBeenCalled(); - switchProvider('paperless', true); + await switchProvider('paperless', true); await tick(); expect(paperless.list).toHaveBeenCalledTimes(1); - expect(linkRow(paperlessLink)).toMatchObject({ last_sync_state: 'ok', failure_count: 0 }); + expect(await linkRow(paperlessLink)).toMatchObject({ last_sync_state: 'ok', failure_count: 0 }); }); }); diff --git a/server/tests/unit/nest/immich.service.test.ts b/server/tests/unit/nest/immich.service.test.ts index 30db60650..5671ea0bd 100644 --- a/server/tests/unit/nest/immich.service.test.ts +++ b/server/tests/unit/nest/immich.service.test.ts @@ -48,6 +48,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { makeStorageFixture } from '../../helpers/storage-fixture'; import { createTestUnitOfWork, createTestUsersRepo } from '../../helpers/test-uow'; +import { createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { deleteRows, upsertRow } from '../../helpers/factories/rows'; +import { readUser } from '../../helpers/factories/users'; +import { Users } from '../../../src/db/entities/Users.entity'; import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared'; const audit = { writeAudit: vi.fn() }; @@ -57,9 +61,23 @@ let svc: ImmichService; const USER = 1; -function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0, allowInsecureTls = 0): void { - testDb.prepare("INSERT OR REPLACE INTO users (id, username, email, password_hash, immich_url, immich_api_key, immich_auto_upload, immich_allow_insecure_tls) VALUES (?, ?, ?, 'x', ?, ?, ?, ?)") - .run(id, `u${id}`, `u${id}@example.test`, url, key, autoUpload, allowInsecureTls); +async function seedUser(id: number, url: string | null, key: string | null, autoUpload = 0, allowInsecureTls = 0): Promise { + await upsertRow(await sharedTestOrm(testDb), Users, { + id, + username: `u${id}`, + email: `u${id}@example.test`, + password_hash: 'x', + immich_url: url, + immich_api_key: key, + immich_auto_upload: autoUpload, + immich_allow_insecure_tls: allowInsecureTls, + }); +} + +/** The user's stored Immich URL and TLS switch, as the settings page round-trips them. */ +async function storedConnection(id: number): Promise<{ immich_url: string | null | undefined; immich_allow_insecure_tls: number }> { + const row = await readUser(await sharedTestOrm(testDb), id); + return { immich_url: row.immich_url, immich_allow_insecure_tls: row.immich_allow_insecure_tls }; } /** A Response-ish object with only what the service reads. */ @@ -79,13 +97,13 @@ beforeAll(async () => { svc = new ImmichService(audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage, users, await createTestUnitOfWork(testDb)); }); -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); decryptMock.mockImplementation((v: string) => v); maybeEncryptMock.mockImplementation((v: string) => v); checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }); - testDb.prepare('DELETE FROM users').run(); - seedUser(USER, 'https://immich.test', 'key-1'); + await deleteRows(await sharedTestOrm(testDb), Users); + await seedUser(USER, 'https://immich.test', 'key-1'); }); afterAll(() => testDb.close()); @@ -96,12 +114,12 @@ describe('getImmichCredentials', () => { }); it('IMMICH-002: returns null without a URL', async () => { - seedUser(2, null, 'key'); + await seedUser(2, null, 'key'); expect(await svc.getImmichCredentials(2)).toBeNull(); }); it('IMMICH-003: returns null without an API key', async () => { - seedUser(3, 'https://immich.test', null); + await seedUser(3, 'https://immich.test', null); expect(await svc.getImmichCredentials(3)).toBeNull(); }); @@ -129,7 +147,7 @@ describe('getConnectionSettings / setImmichAutoUpload', () => { }); it('IMMICH-008: reports an empty URL and not connected when it is not', async () => { - seedUser(4, null, null); + await seedUser(4, null, null); expect(await svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false, allow_insecure_tls: false }); }); @@ -220,9 +238,9 @@ describe('saveImmichSettings', () => { const plaintext = 'synthetic-test-immich-key-001'; await svc.saveImmichSettings(USER, 'https://immich.test', plaintext, null); - const row = testDb.prepare('SELECT immich_api_key FROM users WHERE id = ?').get(USER) as { immich_api_key: string }; + const row = await readUser(await sharedTestOrm(testDb), USER); expect(row.immich_api_key).not.toBe(plaintext); - expect(row.immich_api_key.startsWith('enc:v1:')).toBe(true); + expect(row.immich_api_key?.startsWith('enc:v1:')).toBe(true); }); }); @@ -275,7 +293,7 @@ describe('testConnection', () => { describe('getConnectionStatus', () => { it('IMMICH-022: says "Not configured" without credentials', async () => { - seedUser(5, null, null); + await seedUser(5, null, null); expect(await svc.getConnectionStatus(5)).toEqual({ connected: false, error: 'Not configured' }); }); @@ -293,7 +311,7 @@ describe('getConnectionStatus', () => { describe('browseTimeline', () => { it('IMMICH-024: 400s without credentials', async () => { - seedUser(6, null, null); + await seedUser(6, null, null); expect(await svc.browseTimeline(6)).toEqual({ error: 'Immich not configured', status: 400 }); }); @@ -331,7 +349,7 @@ const DAY_TAIL = [ describe('searchPhotos', () => { it('IMMICH-027: 400s without credentials and 502s when the server is unreachable', async () => { - seedUser(7, null, null); + await seedUser(7, null, null); expect(await svc.searchPhotos(7)).toEqual({ error: 'Immich not configured', status: 400 }); safeFetch.mockRejectedValue(new Error('down')); @@ -622,12 +640,12 @@ describe('searchPhotos', () => { describe('getAssetInfo', () => { it('IMMICH-033: 404s when the owner has no credentials', async () => { - seedUser(8, null, null); + await seedUser(8, null, null); expect(await svc.getAssetInfo(USER, 'a1', 8)).toEqual({ error: 'Not found', status: 404 }); }); it('IMMICH-034: reads the OWNER\'s credentials, not the requester\'s', async () => { - seedUser(9, 'https://owner.test', 'owner-key'); + await seedUser(9, 'https://owner.test', 'owner-key'); safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } })); await svc.getAssetInfo(USER, 'a1', 9); @@ -677,7 +695,7 @@ describe('getAssetInfo', () => { describe('getAlbumPhotos', () => { it('IMMICH-037c: 400s without credentials', async () => { - seedUser(11, null, null); + await seedUser(11, null, null); expect(await svc.getAlbumPhotos(11, 'alb-1')).toEqual({ error: 'Immich not configured', status: 400 }); }); @@ -707,7 +725,7 @@ describe('getAlbumPhotos', () => { describe('fetchImmichThumbnailBytes', () => { it('IMMICH-038: 404s without credentials for the owner', async () => { - seedUser(10, null, null); + await seedUser(10, null, null); expect(await svc.fetchImmichThumbnailBytes(USER, 'a1', 10)).toEqual({ error: 'Not found', status: 404 }); }); @@ -741,7 +759,7 @@ describe('streamImmichAsset', () => { // It used to return { error, status } and touch nothing. All four callers // ignored that return, so the client got no status line at all and waited // out its own timeout. Synology's counterpart always wrote; this matches. - seedUser(11, null, null); + await seedUser(11, null, null); const res = makeRes(); await svc.streamImmichAsset(res as never, USER, 'a1', 'thumbnail', 11); @@ -755,8 +773,8 @@ describe('streamImmichAsset', () => { // A shared album is proxied with the owner's key on the viewer's behalf, so // a viewer who happens to have Immich configured must not paper over an // owner who does not. - seedUser(USER, 'https://immich.example', 'viewer-key'); - seedUser(12, null, null); + await seedUser(USER, 'https://immich.example', 'viewer-key'); + await seedUser(12, null, null); const res = makeRes(); await svc.streamImmichAsset(res as never, USER, 'a1', 'original', 12); @@ -766,7 +784,7 @@ describe('streamImmichAsset', () => { }); it('IMMICH-041d: proxies with the owner key once a connection exists', async () => { - seedUser(13, 'https://immich.example', 'owner-key'); + await seedUser(13, 'https://immich.example', 'owner-key'); safeFetch.mockResolvedValue(upstream({ contentType: 'image/jpeg', body: 'bytes' })); const res = makeRes(); @@ -787,7 +805,7 @@ describe('collectAlbumSelection', () => { it('IMMICH-043: 400s when the user has no Immich credentials', async () => { access.getAlbumIdFromLink.mockReturnValue({ success: true, data: 'album-1' }); - seedUser(11, null, null); + await seedUser(11, null, null); expect(await svc.collectAlbumSelection('1', 'l1', 11)).toEqual({ error: 'Immich not configured', status: 400 }); }); @@ -827,19 +845,19 @@ describe('uploadToImmich', () => { }); it('IMMICH-UP-002: returns null for a path outside the journey category', async () => { - seedUser(USER, 'https://immich.test', 'key-1'); + await seedUser(USER, 'https://immich.test', 'key-1'); expect(await svc.uploadToImmich(USER, 'files/doc.pdf', 'doc.pdf')).toBeNull(); expect(safeFetch).not.toHaveBeenCalled(); }); it('IMMICH-UP-003: returns null when the object is missing (old existsSync guard)', async () => { - seedUser(USER, 'https://immich.test', 'key-1'); + await seedUser(USER, 'https://immich.test', 'key-1'); expect(await svc.uploadToImmich(USER, 'journey/gone.jpg', 'gone.jpg')).toBeNull(); expect(safeFetch).not.toHaveBeenCalled(); }); it('IMMICH-UP-004: posts the bytes as multipart and answers the created asset id', async () => { - seedUser(USER, 'https://immich.test', 'key-1'); + await seedUser(USER, 'https://immich.test', 'key-1'); writeJourneyObject('up.jpg', 'jpeg-bytes-here'); safeFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'immich-42' }) }); @@ -853,7 +871,7 @@ describe('uploadToImmich', () => { }); it('IMMICH-UP-005: a rejected upstream answers null, not a throw', async () => { - seedUser(USER, 'https://immich.test', 'key-1'); + await seedUser(USER, 'https://immich.test', 'key-1'); writeJourneyObject('rej.jpg', 'bytes'); safeFetch.mockResolvedValueOnce({ ok: false, status: 500 }); expect(await svc.uploadToImmich(USER, 'journey/rej.jpg', 'rej.jpg')).toBeNull(); @@ -910,19 +928,19 @@ describe('self-signed certificates', () => { const EVERY_PATH_CALLS = 13; it('IMMICH-TLS-001: only a stored 1 turns the switch on', async () => { - seedUser(USER, 'https://immich.test', 'key-1', 0, 1); + await seedUser(USER, 'https://immich.test', 'key-1', 0, 1); expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(true); - seedUser(USER, 'https://immich.test', 'key-1', 0, 0); + await seedUser(USER, 'https://immich.test', 'key-1', 0, 0); expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(false); // Anything the column should never hold reads as off, not as truthy. - seedUser(USER, 'https://immich.test', 'key-1', 0, 2); + await seedUser(USER, 'https://immich.test', 'key-1', 0, 2); expect((await svc.getImmichCredentials(USER))!.allow_insecure_tls).toBe(false); }); it('IMMICH-TLS-002: with the switch on, every request to the server skips the certificate check', async () => { - seedUser(USER, 'https://immich.test', 'key-1', 0, 1); + await seedUser(USER, 'https://immich.test', 'key-1', 0, 1); await exerciseEveryPath(USER); @@ -940,8 +958,8 @@ describe('self-signed certificates', () => { it('IMMICH-TLS-004: a shared photo follows the switch of its owner, not of the viewer', async () => { // The request goes to the owner's server with the owner's key, so the // owner decided whether that server's certificate is trusted. - seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 0); - seedUser(20, 'https://owner.test', 'owner-key', 0, 1); + await seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 0); + await seedUser(20, 'https://owner.test', 'owner-key', 0, 1); safeFetch.mockResolvedValue(upstream({ json: { id: 'a1' } })); await svc.getAssetInfo(USER, 'a1', 20); @@ -955,8 +973,8 @@ describe('self-signed certificates', () => { } safeFetch.mockClear(); - seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1); - seedUser(20, 'https://owner.test', 'owner-key', 0, 0); + await seedUser(USER, 'https://viewer.test', 'viewer-key', 0, 1); + await seedUser(20, 'https://owner.test', 'owner-key', 0, 0); await svc.getAssetInfo(USER, 'a1', 20); expect(safeFetch.mock.calls[0][2]).toMatchObject(STRICT); }); @@ -974,19 +992,18 @@ describe('self-signed certificates', () => { await svc.saveImmichSettings(USER, 'https://immich.test', 'key-1', null, true); await svc.saveImmichSettings(USER, undefined, undefined, null, true); - const row = testDb.prepare('SELECT immich_url, immich_allow_insecure_tls FROM users WHERE id = ?').get(USER); - expect(row).toEqual({ immich_url: null, immich_allow_insecure_tls: 0 }); + expect(await storedConnection(USER)).toEqual({ immich_url: null, immich_allow_insecure_tls: 0 }); }); it('IMMICH-TLS-010: the switch trusts one server, so a new URL saved without it starts off', async () => { - seedUser(USER, 'https://nas.local', 'key-1', 0, 1); + await seedUser(USER, 'https://nas.local', 'key-1', 0, 1); // Same server with a trailing slash: still the same connection, the switch stays. await svc.saveImmichSettings(USER, 'https://nas.local/', 'key-1', null); expect((await svc.getConnectionSettings(USER)).allow_insecure_tls).toBe(true); await svc.saveImmichSettings(USER, 'https://photos.example.com', 'key-2', null); - expect(testDb.prepare('SELECT immich_url, immich_allow_insecure_tls FROM users WHERE id = ?').get(USER)).toEqual({ + expect(await storedConnection(USER)).toEqual({ immich_url: 'https://photos.example.com', immich_allow_insecure_tls: 0, }); @@ -996,7 +1013,7 @@ describe('self-signed certificates', () => { }); it('IMMICH-TLS-006: a URL the guard refuses leaves the stored switch alone', async () => { - seedUser(USER, 'https://immich.test', 'key-1', 0, 1); + await seedUser(USER, 'https://immich.test', 'key-1', 0, 1); checkSsrf.mockResolvedValue({ allowed: false, error: 'blocked host' }); await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null, false); diff --git a/server/tests/unit/nest/journey-book.service.test.ts b/server/tests/unit/nest/journey-book.service.test.ts index e540e371b..804e23d68 100644 --- a/server/tests/unit/nest/journey-book.service.test.ts +++ b/server/tests/unit/nest/journey-book.service.test.ts @@ -46,6 +46,9 @@ import { createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, } from '../../helpers/journey-repos'; import { createTestJourneyBooksRepo } from '../../helpers/journey-share-repos'; +import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { JourneyBooks } from '../../../src/db/entities/JourneyBooks.entity'; +import { Journeys } from '../../../src/db/entities/Journeys.entity'; let domain: JourneyDomainService; let books: JourneyBookService; @@ -190,11 +193,7 @@ describe('creating and reading', () => { it('opens a book whose stored JSON is broken, rather than throwing', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - testDb - .prepare( - "INSERT INTO journey_books (journey_id, title, document, version) VALUES (?, 'T', '{not json', 1)", - ) - .run(journey.id); + await insertRow(await sharedTestOrm(testDb), JourneyBooks, { journey: journey.id, title: 'T', document: '{not json', version: 1 }); const read = await books.getBook(journey.id, user.id); expect(read).not.toBeNull(); @@ -334,14 +333,18 @@ describe('concurrency', () => { const stale = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('three'), baseVersion: 1 }); expect(stale && 'conflict' in stale).toBe(true); - // The same statement with the `AND version = ?` guard stripped — proving + // The same write with the `AND version = ?` guard stripped — proving // that guard, not something else, is what makes the refusal above real. - const row = testDb.prepare('SELECT id FROM journey_books WHERE journey_id = ?').get(journey.id) as { id: number }; - testDb - .prepare('UPDATE journey_books SET title = ?, document = ?, version = version + 1 WHERE id = ?') - .run('Clobbered', JSON.stringify(doc('clobbered')), row.id); - const afterMutation = testDb.prepare('SELECT title FROM journey_books WHERE id = ?').get(row.id) as { title: string }; - expect(afterMutation.title).toBe('Clobbered'); + const orm = await sharedTestOrm(testDb); + const row = await findRow(orm, JourneyBooks, { journey: journey.id }); + if (!row) throw new Error('the save above should have left a book'); + await updateRows(orm, JourneyBooks, { id: row.id }, { + title: 'Clobbered', + document: JSON.stringify(doc('clobbered')), + version: row.version + 1, + }); + const afterMutation = await findRow(orm, JourneyBooks, { id: row.id }); + expect(afterMutation?.title).toBe('Clobbered'); }); // M1 (task-5-review.md) — JB3's existing-link read and JB4's insert used @@ -368,10 +371,10 @@ describe('concurrency', () => { // other either also lands as {record} (version 2, having taken the // update branch against baseVersion undefined -> existing.version) or // sees a stale conflict — either way, never a second inserted row. - const rows = testDb.prepare('SELECT COUNT(*) AS n FROM journey_books WHERE journey_id = ?').get(journey.id) as { n: number }; - expect(rows.n).toBe(1); + const orm = await sharedTestOrm(testDb); + expect(await countRows(orm, JourneyBooks, { journey: journey.id })).toBe(1); - const finalVersion = (testDb.prepare('SELECT version FROM journey_books WHERE journey_id = ?').get(journey.id) as { version: number }).version; + const finalVersion = (await findRow(orm, JourneyBooks, { journey: journey.id }))?.version; expect(finalVersion).toBe(2); }); }); @@ -421,11 +424,9 @@ describe('deleting', () => { const journey = createJourney(testDb, user.id); await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); - testDb.prepare('DELETE FROM journeys WHERE id = ?').run(journey.id); + const orm = await sharedTestOrm(testDb); + await deleteRows(orm, Journeys, { id: journey.id }); - const left = testDb - .prepare('SELECT COUNT(*) AS n FROM journey_books WHERE journey_id = ?') - .get(journey.id) as { n: number }; - expect(left.n).toBe(0); + expect(await countRows(orm, JourneyBooks, { journey: journey.id })).toBe(0); }); }); diff --git a/server/tests/unit/nest/notifications/web-push-dispatch.test.ts b/server/tests/unit/nest/notifications/web-push-dispatch.test.ts index bce4694dc..66c30d815 100644 --- a/server/tests/unit/nest/notifications/web-push-dispatch.test.ts +++ b/server/tests/unit/nest/notifications/web-push-dispatch.test.ts @@ -42,6 +42,12 @@ vi.mock('../../../../src/utils/ssrfGuard', () => { import { db as testDb } from '../../../../src/db/database'; import { resetTestDb } from '../../../helpers/test-db'; +import { sharedTestOrm } from '../../../helpers/test-uow'; +import { countRows, deleteRows, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { readAppSetting } from '../../../helpers/factories/settings'; +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; +import { Notifications } from '../../../../src/db/entities/Notifications.entity'; +import { Settings } from '../../../../src/db/entities/Settings.entity'; import { createAdmin, createUser, disableNotificationPref, setNotificationChannels } from '../../../helpers/factories'; import { makeNotificationPreferencesService, @@ -94,15 +100,17 @@ const pushedTo = () => safeFetchFollow.mock.calls.map((c) => c[0] as string); /** What a restore under a different ENCRYPTION_KEY leaves behind: a private key that no longer opens. */ const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA'; -function breakStoredPrivateKey(): void { - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); +async function breakStoredPrivateKey(): Promise { + await updateRows(await sharedTestOrm(testDb), AppSettings, { key: VAPID_PRIVATE_KEY_SETTING }, { value: UNREADABLE }); } const pushColumn = async (userId: number) => (await prefs.getPreferencesMatrix(userId, 'user')).channels.find((c) => c.id === 'push'); -const storedKeyRows = () => - testDb.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'web_push_vapid_%' ORDER BY key").all(); +const storedKeyRows = async () => + (await findRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'web_push_vapid_%' } }, { key: 'asc' })).map( + (r) => ({ key: r.key, value: r.value }), + ); beforeAll(async () => { notifications = await makeNotificationsService(testDb); @@ -216,20 +224,15 @@ describe('Web Push while the stored key pair cannot be used', () => { const { user } = createUser(testDb); const { user: actor } = createUser(testDb); const endpoint = await addDevice(user.id); - testDb - .prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)") - .run(user.id, 'https://hooks.example.test/trek'); + await insertRow(await sharedTestOrm(testDb), Settings, { user: user.id, key: 'webhook_url', value: 'https://hooks.example.test/trek' }); setNotificationChannels(testDb, 'webhook,push'); - breakStoredPrivateKey(); + await breakStoredPrivateKey(); // In-app and the webhook go out; the skipped push is no failed delivery. await expect(invite(user.id, actor.id)).resolves.toEqual({ attempted: 2, delivered: 2 }); expect(pushedTo()).toEqual(['https://hooks.example.test/trek']); - const inApp = testDb.prepare('SELECT COUNT(*) AS n FROM notifications WHERE recipient_id = ?').get(user.id) as { - n: number; - }; - expect(inApp.n).toBe(1); + expect(await countRows(await sharedTestOrm(testDb), Notifications, { recipient: user.id })).toBe(1); // The device is kept for when the key is back, and nothing failed on the way. expect((await subscriptions.listForUser(user.id)).map((r) => r.endpoint)).toEqual([endpoint]); expect(logError).not.toHaveBeenCalledWith(expect.stringContaining('dispatch failed')); @@ -240,16 +243,15 @@ describe('Web Push while the stored key pair cannot be used', () => { const { user } = createUser(testDb); setNotificationChannels(testDb, 'email,push'); await addDevice(user.id); - const ciphertext = ( - testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PRIVATE_KEY_SETTING) as { value: string } - ).value; - breakStoredPrivateKey(); + const ciphertext = await readAppSetting(await sharedTestOrm(testDb), VAPID_PRIVATE_KEY_SETTING); + if (ciphertext === null) throw new Error('the private key should be stored by now'); + await breakStoredPrivateKey(); expect(await pushColumn(user.id)).toMatchObject({ active: false, configured: true }); // Only push: email keeps its column, SMTP or not, as it always has. expect((await prefs.getPreferencesMatrix(user.id, 'user')).channels.find((c) => c.id === 'email')?.active).toBe(true); - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING); + await updateRows(await sharedTestOrm(testDb), AppSettings, { key: VAPID_PRIVATE_KEY_SETTING }, { value: ciphertext }); expect(await pushColumn(user.id)).toMatchObject({ active: true, configured: true }); }); @@ -258,16 +260,16 @@ describe('Web Push while the stored key pair cannot be used', () => { setNotificationChannels(testDb, 'email,push'); await addDevice(user.id); // What deleting both rows to start over leaves under a running server. - testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'web_push_vapid_%'").run(); + await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'web_push_vapid_%' } }); expect(await pushColumn(user.id)).toMatchObject({ active: true, configured: true }); - expect(storedKeyRows()).toEqual([]); + expect(await storedKeyRows()).toEqual([]); }); it('WPDISP-010: the test send says push is unavailable and sends nothing', async () => { const { user } = createUser(testDb); await addDevice(user.id); - breakStoredPrivateKey(); + await breakStoredPrivateKey(); await expect(notifications.testChannel(user.id, 'push')).resolves.toEqual({ success: false, error: PUSH_UNAVAILABLE_ERROR, @@ -283,7 +285,7 @@ describe('Web Push while the VAPID_* pair is broken and an older pair is stored' setNotificationChannels(testDb, 'push'); // The first start without VAPID_* stored a pair; the operator then brought their own. await keys.getPublicKey(); - const stored = storedKeyRows(); + const stored = await storedKeyRows(); const pair = generateVapidKeyPair(); vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey); vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); @@ -298,7 +300,7 @@ describe('Web Push while the VAPID_* pair is broken and an older pair is stored' expect((await subscriptions.listForUser(user.id)).map((r) => [r.endpoint, r.vapid_public_key])).toEqual([ [endpoint, pair.publicKey], ]); - expect(storedKeyRows()).toEqual(stored); + expect(await storedKeyRows()).toEqual(stored); expect(await pushColumn(user.id)).toMatchObject({ active: false, configured: true }); vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); diff --git a/server/tests/unit/nest/photo-resolver.service.test.ts b/server/tests/unit/nest/photo-resolver.service.test.ts index 9b0f3a10a..fb30fc8e3 100644 --- a/server/tests/unit/nest/photo-resolver.service.test.ts +++ b/server/tests/unit/nest/photo-resolver.service.test.ts @@ -34,6 +34,8 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity'; import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { deleteRows, findRow, insertRow, insertRowIgnoringConflict } from '../../helpers/factories/rows'; +import { Users } from '../../../src/db/entities/Users.entity'; import { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service'; import type { ImmichService } from '../../../src/nest/memories/immich.service'; import type { SynologyService } from '../../../src/nest/memories/synology.service'; @@ -83,12 +85,17 @@ function makeRes() { } /** Insert a trek_photos row directly so each case controls the exact shape. */ -function insertPhoto(cols: Record): number { - const keys = Object.keys(cols); - const res = testDb.prepare( - `INSERT INTO trek_photos (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})` - ).run(...keys.map(k => cols[k] as never)); - return Number(res.lastInsertRowid); +async function insertPhoto(cols: { + provider: string; + file_path?: string; + thumbnail_path?: string; + media_type?: string; + asset_id?: string; + owner_id?: number; + passphrase?: string; +}): Promise { + const { owner_id, ...rest } = cols; + return insertRow(t, TrekPhotos, { ...rest, ...(owner_id !== undefined ? { owner: owner_id } : {}) }); } beforeAll(async () => { @@ -103,13 +110,12 @@ beforeAll(async () => { ); }); -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); // owner_id carries a FK to users; the cases pick fixed ids, so seed them. - testDb.prepare('DELETE FROM trek_photos').run(); + await deleteRows(t, TrekPhotos); for (const id of [2, 3, 4, 5, 9]) { - testDb.prepare("INSERT OR IGNORE INTO users (id, username, email, password_hash) VALUES (?, ?, ?, 'x')") - .run(id, `u${id}`, `u${id}@example.test`); + await insertRowIgnoringConflict(t, Users, { id, username: `u${id}`, email: `u${id}@example.test`, password_hash: 'x' }); } cache.serveFresh.mockReturnValue(false); cache.getInFlight.mockReturnValue(undefined); @@ -132,7 +138,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-002: a local row whose file is gone answers "File not found", not a provider call', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'nope/missing.jpg' }); + const id = await insertPhoto({ provider: 'local', file_path: 'nope/missing.jpg' }); const res = makeRes(); await svc.streamPhoto(res as never, 1, id, 'original'); @@ -145,7 +151,7 @@ describe('streamPhoto — dispatch', () => { it('RESOLVE-003: a poster-less video 404s instead of streaming the whole file as a thumbnail', async () => { // #823: falling through here would push a full video down a thumbnail request. - const id = insertPhoto({ provider: 'local', file_path: 'nope/clip.mp4', media_type: 'video' }); + const id = await insertPhoto({ provider: 'local', file_path: 'nope/clip.mp4', media_type: 'video' }); const res = makeRes(); await svc.streamPhoto(res as never, 1, id, 'thumbnail'); @@ -156,7 +162,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-004: a local image with no thumbnail asks for one to be generated', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' }); + const id = await insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' }); thumbnails.ensureLocalThumbnail.mockResolvedValue(null); const res = makeRes(); @@ -166,18 +172,22 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-005: a generated thumbnail is recorded on the row', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' }); + const id = await insertPhoto({ provider: 'local', file_path: 'nope/photo.jpg' }); thumbnails.ensureLocalThumbnail.mockResolvedValue({ thumbnailRelPath: 'journey/thumbs/abc.jpg', width: 800, height: 600 }); const res = makeRes(); await svc.streamPhoto(res as never, 1, id, 'thumbnail'); - const row = testDb.prepare('SELECT thumbnail_path, width, height FROM trek_photos WHERE id = ?').get(id) as { thumbnail_path: string; width: number; height: number }; - expect(row).toEqual({ thumbnail_path: 'journey/thumbs/abc.jpg', width: 800, height: 600 }); + const row = await findRow(t, TrekPhotos, { id }); + expect({ thumbnail_path: row?.thumbnail_path, width: row?.width, height: row?.height }).toEqual({ + thumbnail_path: 'journey/thumbs/abc.jpg', + width: 800, + height: 600, + }); }); it('RESOLVE-014: a local thumbnail hit streams through storage with the immutable headers', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' }); + const id = await insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' }); storage.exists.mockResolvedValue(true); const res = makeRes(); @@ -190,7 +200,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-015: a local original hit streams through storage with the day-long cache header', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg' }); + const id = await insertPhoto({ provider: 'local', file_path: 'journey/x.jpg' }); storage.exists.mockResolvedValue(true); const res = makeRes(); @@ -202,7 +212,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-016: a missing thumbnail falls through to the original for images', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' }); + const id = await insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' }); storage.exists.mockResolvedValueOnce(false).mockResolvedValueOnce(true); const res = makeRes(); @@ -212,7 +222,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-017: a video whose recorded poster is gone 404s instead of falling through (#823)', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'journey/clip.mp4', thumbnail_path: 'journey/thumbs/t.jpg', media_type: 'video' }); + const id = await insertPhoto({ provider: 'local', file_path: 'journey/clip.mp4', thumbnail_path: 'journey/thumbs/t.jpg', media_type: 'video' }); storage.exists.mockResolvedValue(false); const res = makeRes(); @@ -224,7 +234,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-019: a rejecting exists check (invalid key) reads as a local miss for thumb and original', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' }); + const id = await insertPhoto({ provider: 'local', file_path: 'journey/x.jpg', thumbnail_path: 'journey/thumbs/h.jpg' }); storage.exists.mockRejectedValue(new Error('invalid storage key')); const res = makeRes(); @@ -236,7 +246,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-018: a file_path outside journey/ reads as a local miss without touching storage', async () => { - const id = insertPhoto({ provider: 'local', file_path: 'nope/other.jpg' }); + const id = await insertPhoto({ provider: 'local', file_path: 'nope/other.jpg' }); const res = makeRes(); await svc.streamPhoto(res as never, 1, id, 'original'); @@ -247,7 +257,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-006: an immich original goes straight to the provider with the range header', async () => { - const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); + const id = await insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); const res = makeRes(); await svc.streamPhoto(res as never, 3, id, 'original', 'bytes=0-99'); @@ -256,7 +266,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-007: an immich thumbnail served from cache never reaches the provider', async () => { - const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); + const id = await insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); cache.serveFresh.mockReturnValue(true); const res = makeRes(); @@ -267,7 +277,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-008: a synology original forwards the decrypted passphrase', async () => { - const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9, passphrase: 'secret' }); + const id = await insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9, passphrase: 'secret' }); const res = makeRes(); await svc.streamPhoto(res as never, 4, id, 'original'); @@ -276,7 +286,7 @@ describe('streamPhoto — dispatch', () => { }); it('RESOLVE-009: a synology row without a passphrase forwards undefined, not an empty string', async () => { - const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's2', owner_id: 9 }); + const id = await insertPhoto({ provider: 'synologyphotos', asset_id: 's2', owner_id: 9 }); const res = makeRes(); await svc.streamPhoto(res as never, 4, id, 'original'); @@ -292,7 +302,7 @@ describe('getPhotoInfo — dispatch', () => { }); it('RESOLVE-011: asks immich for an immich asset', async () => { - const id = insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); + const id = await insertPhoto({ provider: 'immich', asset_id: 'a1', owner_id: 5 }); immich.getAssetInfo.mockResolvedValue({ success: true, data: { id: 'a1' } }); await svc.getPhotoInfo(3, id); @@ -302,7 +312,7 @@ describe('getPhotoInfo — dispatch', () => { }); it('RESOLVE-012: asks synology for a synology asset', async () => { - const id = insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9 }); + const id = await insertPhoto({ provider: 'synologyphotos', asset_id: 's1', owner_id: 9 }); synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: 's1' } }); await svc.getPhotoInfo(4, id); @@ -312,7 +322,7 @@ describe('getPhotoInfo — dispatch', () => { }); it('RESOLVE-013: an unknown provider is an error, not a crash', async () => { - const id = insertPhoto({ provider: 'picasa', asset_id: 'p1', owner_id: 2 }); + const id = await insertPhoto({ provider: 'picasa', asset_id: 'p1', owner_id: 2 }); const result = await svc.getPhotoInfo(1, id); expect(result.success).toBe(false); }); diff --git a/server/tests/unit/nest/public-api-scopes.test.ts b/server/tests/unit/nest/public-api-scopes.test.ts index b95e752b0..1c64da638 100644 --- a/server/tests/unit/nest/public-api-scopes.test.ts +++ b/server/tests/unit/nest/public-api-scopes.test.ts @@ -64,7 +64,9 @@ import { createUser } from '../../helpers/factories'; import { TokenService } from '../../../src/nest/tokens/token.service'; import type { McpTokensRepository } from '../../../src/db/repositories/McpTokens.repository'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; -import { createTestMcpTokensRepo, createTestUsersRepo } from '../../helpers/test-uow'; +import { createTestMcpTokensRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { findRow, updateRows } from '../../helpers/factories/rows'; +import { McpTokens } from '../../../src/db/entities/McpTokens.entity'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard'; import { PublicApiController } from '../../../src/nest/public-api/public-api.controller'; @@ -402,12 +404,15 @@ describe('TokenService — storing and resolving a grant', () => { testDb.close(); }); - const rowFor = (id: number) => - testDb.prepare('SELECT kind, scope_mode, api_scopes FROM mcp_tokens WHERE id = ?').get(id) as { - kind: string; - scope_mode: string | null; - api_scopes: string | null; - }; + /** The grant columns of the stored token row. */ + const rowFor = async (id: number) => { + const row = await findRow(await sharedTestOrm(testDb), McpTokens, { id }); + if (!row) throw new Error(`no token ${id}`); + return { kind: row.kind, scope_mode: row.scope_mode, api_scopes: row.api_scopes }; + }; + const setApiScopes = async (id: number, apiScopes: string) => + updateRows(await sharedTestOrm(testDb), McpTokens, { id }, { api_scopes: apiScopes }); + const lastUsedAt = async (id: number) => (await findRow(await sharedTestOrm(testDb), McpTokens, { id }))?.last_used_at; const mint = async (userId: number, name: string, scopes?: readonly string[]) => (await tokens.createApiToken(userId, name, scopes)).token as { @@ -422,7 +427,7 @@ describe('TokenService — storing and resolving a grant', () => { const created = await mint(user.id, 'Dawarich'); // 'all' is written explicitly: a NULL sentinel here would mean a bug that // drops the column silently grants everything. - expect(rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null }); + expect(await rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null }); expect(created.scope_mode).toBe('all'); expect(created.scopes).toEqual([...PUBLIC_API_SCOPES]); }); @@ -463,7 +468,7 @@ describe('TokenService — storing and resolving a grant', () => { it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', async () => { const { user } = createUser(testDb); const created = await mint(user.id, 'Dawarich', ['stats', 'trips']); - expect(rowFor(created.id)).toEqual({ + expect(await rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'limited', // Canonical order, not the caller's: two keys with the same access must @@ -485,7 +490,7 @@ describe('TokenService — storing and resolving a grant', () => { it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', async () => { const { user } = createUser(testDb); const created = await mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']); - expect(rowFor(created.id).api_scopes).toBe(JSON.stringify(['trips'])); + expect((await rowFor(created.id)).api_scopes).toBe(JSON.stringify(['trips'])); }); it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', async () => { @@ -493,12 +498,12 @@ describe('TokenService — storing and resolving a grant', () => { // Not an empty limited grant: that would mint a key that can read nothing, // which nobody asked for and which fails hours later at the integration. const created = await mint(user.id, 'Dawarich', ['everything']); - expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); + expect(await rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); }); it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', async () => { const { user } = createUser(testDb); - expect(rowFor((await mint(user.id, 'Dawarich', [])).id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); + expect(await rowFor((await mint(user.id, 'Dawarich', [])).id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); }); it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', async () => { @@ -510,7 +515,7 @@ describe('TokenService — storing and resolving a grant', () => { const { user } = createUser(testDb); const created = await mint(user.id, 'Dawarich', ['trips']); for (const broken of ['{"not":"a list"', 'null', '"trips"', '{}', '[]', '[42, null]']) { - testDb.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?').run(broken, created.id); + await setApiScopes(created.id, broken); expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({ mode: 'limited', scopes: [], @@ -522,9 +527,7 @@ describe('TokenService — storing and resolving a grant', () => { const { user } = createUser(testDb); const created = await mint(user.id, 'Dawarich', ['trips']); // What a future version dropping or renaming a scope constant leaves behind. - testDb - .prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?') - .run(JSON.stringify(['itineraries']), created.id); + await setApiScopes(created.id, JSON.stringify(['itineraries'])); expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({ mode: 'limited', scopes: [], @@ -534,9 +537,7 @@ describe('TokenService — storing and resolving a grant', () => { it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', async () => { const { user } = createUser(testDb); const created = await mint(user.id, 'Dawarich', ['trips']); - testDb - .prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?') - .run(JSON.stringify(['trips', 42, 'nope', 'stats']), created.id); + await setApiScopes(created.id, JSON.stringify(['trips', 42, 'nope', 'stats'])); expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({ mode: 'limited', scopes: ['trips', 'stats'], @@ -564,7 +565,7 @@ describe('TokenService — storing and resolving a grant', () => { const mcp = (await tokens.createMcpToken(user.id, 'Assistant')).token as { id: number }; // Stored as the column default, because an MCP token carries no read scopes // and must not look as if it did. - expect(rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null }); + expect(await rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null }); const listed = await tokens.listMcpTokens(user.id) as Array>; expect(listed).toHaveLength(1); // The MCP panel cannot act on scopes, so it is not shown two columns that @@ -586,17 +587,9 @@ describe('TokenService — storing and resolving a grant', () => { it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', async () => { const { user } = createUser(testDb); const created = await mint(user.id, 'Dawarich', ['trips']); - expect( - (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as { - last_used_at: string | null; - }).last_used_at, - ).toBeNull(); + expect(await lastUsedAt(created.id)).toBeNull(); await tokens.verifyApiTokenWithGrant(created.raw_token); - expect( - (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as { - last_used_at: string | null; - }).last_used_at, - ).not.toBeNull(); + expect(await lastUsedAt(created.id)).not.toBeNull(); }); // ------------------------------------------------------------------------- @@ -624,7 +617,7 @@ describe('TokenService — storing and resolving a grant', () => { httpReq, )).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string }; - expect(rowFor(created.id)).toMatchObject({ + expect(await rowFor(created.id)).toMatchObject({ scope_mode: 'limited', api_scopes: JSON.stringify(['trips', 'days']), }); @@ -648,7 +641,7 @@ describe('TokenService — storing and resolving a grant', () => { const created = (await ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq)).token as { id: number; }; - expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); + expect(await rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); expect(await ctl.listApiTokens({ id: user.id } as User)).toEqual({ tokens: [expect.objectContaining({ scope_mode: 'all', scopes: [...PUBLIC_API_SCOPES] })], }); @@ -665,7 +658,7 @@ describe('TokenService — storing and resolving a grant', () => { httpReq, )).token as Record; - expect(rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null }); + expect(await rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null }); expect(created).not.toHaveProperty('scope_mode'); expect(created).not.toHaveProperty('scopes'); expect((await ctl.listMcpTokens({ id: user.id } as User)).tokens[0]).not.toHaveProperty('scope_mode'); diff --git a/server/tests/unit/nest/route-usage.service.test.ts b/server/tests/unit/nest/route-usage.service.test.ts index e7e6cfaa1..cd47303e9 100644 --- a/server/tests/unit/nest/route-usage.service.test.ts +++ b/server/tests/unit/nest/route-usage.service.test.ts @@ -5,6 +5,7 @@ import type { RouteUsageEntry } from '@trek/shared'; import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow'; import { RouteUsageDaily } from '../../../src/db/entities/RouteUsageDaily.entity'; import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { countRows, insertRow } from '../../helpers/factories/rows'; /** * SRV-ROUTEUSAGE-001..010 — the counters behind "could TREK host a router". @@ -40,6 +41,31 @@ async function serviceOver(db: Database.Database): Promise { return new RouteUsageService(t.repo(RouteUsageDaily), t.repo(AppSettings), await createTestUnitOfWork(db)); } +/** One counter row, written as the table holds it: (day, profile, surface, self_hosted, requests, waypoints, km, failed). */ +async function insertDay( + db: Database.Database, + day: string, + selfHosted: number, + requests: number, + waypoints: number, + km: number, + failed: number, +): Promise { + await insertRow(await sharedTestOrm(db), RouteUsageDaily, { + day, + profile: 'driving', + surface: 'legs', + self_hosted: selfHosted, + requests, + waypoints, + km, + failed, + }); +} + +/** The UTC calendar date `days` days back, as SQLite's `date('now', '-N days')` renders it. */ +const daysAgo = (days: number): string => new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); + const entry = (over: Partial = {}): RouteUsageEntry => ({ profile: 'driving', surface: 'legs', selfHosted: false, requests: 1, waypoints: 3, km: 100, failed: 0, ...over, @@ -56,14 +82,14 @@ describe('RouteUsageService', () => { it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', async () => { expect(await svc.enabled()).toBe(true); - db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run(); + await insertRow(await sharedTestOrm(db), AppSettings, { key: 'route_usage_enabled', value: 'false' }); expect(await svc.enabled()).toBe(false); }); it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', async () => { - db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run(); + await insertRow(await sharedTestOrm(db), AppSettings, { key: 'route_usage_enabled', value: 'false' }); expect(await svc.record({ entries: [entry()] })).toBe(false); - expect(db.prepare('SELECT COUNT(*) c FROM route_usage_daily').get()).toEqual({ c: 0 }); + expect(await countRows(await sharedTestOrm(db), RouteUsageDaily)).toBe(0); }); it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', async () => { @@ -96,8 +122,8 @@ describe('RouteUsageService', () => { it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', async () => { // Two days by hand: the service always writes "today", so the spread is set here. - db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,10,30,900,0)`).run(); - db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-02','driving','legs',0,30,60,1800,2)`).run(); + await insertDay(db, '2026-09-01', 0, 10, 30, 900, 0); + await insertDay(db, '2026-09-02', 0, 30, 60, 1800, 2); const s = await svc.summary(); expect(s.totalRequests).toBe(40); @@ -111,21 +137,21 @@ describe('RouteUsageService', () => { }); it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', async () => { - db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,4,20,400,0)`).run(); + await insertDay(db, '2026-09-01', 0, 4, 20, 400, 0); const s = await svc.summary(); expect(s.waypointsPerRequest).toBe(5); expect(s.kmPerRequest).toBe(100); }); it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', async () => { - db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,30,0,0,0)`).run(); - db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',1,10,0,0,0)`).run(); + await insertDay(db, '2026-09-01', 0, 30, 0, 0, 0); + await insertDay(db, '2026-09-01', 1, 10, 0, 0, 0); expect((await svc.summary()).selfHostedShare).toBe(0.25); }); it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', async () => { - db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-1 day'),'driving','legs',0,1,0,0,0)`).run(); - db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-${RETENTION_DAYS + 5} days'),'driving','legs',0,1,0,0,0)`).run(); + await insertDay(db, daysAgo(1), 0, 1, 0, 0, 0); + await insertDay(db, daysAgo(RETENTION_DAYS + 5), 0, 1, 0, 0, 0); expect(await svc.purgeExpired()).toBe(1); expect(await svc.rows()).toHaveLength(1); diff --git a/server/tests/unit/nest/synology.service.test.ts b/server/tests/unit/nest/synology.service.test.ts index e6780d1d4..21c89e2c6 100644 --- a/server/tests/unit/nest/synology.service.test.ts +++ b/server/tests/unit/nest/synology.service.test.ts @@ -48,20 +48,35 @@ import { db as testDb } from '../../../src/db/database'; import { SynologyService } from '../../../src/nest/memories/synology.service'; import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; import { notificationsStub } from '../../helpers/notifications'; -import { createTestUsersRepo } from '../../helpers/test-uow'; +import { createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { deleteRows, upsertRow } from '../../helpers/factories/rows'; +import { readUser } from '../../helpers/factories/users'; +import { Users } from '../../../src/db/entities/Users.entity'; const access = { getAlbumLinkForSync: vi.fn(), updateSyncTimeForAlbumLink: vi.fn() }; let svc: SynologyService; const USER = 1; -function seedUser(id: number, cols: Partial> = {}): void { - const base = { synology_url: 'https://nas.test', synology_username: 'ada', synology_password: 'pw', synology_sid: 'sid-1', synology_did: null, synology_skip_ssl: 1 }; - const row = { ...base, ...cols }; - testDb.prepare( - `INSERT OR REPLACE INTO users (id, username, email, password_hash, synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl) - VALUES (?, ?, ?, 'x', ?, ?, ?, ?, ?, ?)` - ).run(id, `u${id}`, `u${id}@example.test`, row.synology_url, row.synology_username, row.synology_password, row.synology_sid, row.synology_did, row.synology_skip_ssl); +interface SynologyCols { + synology_url: string | null; + synology_username: string | null; + synology_password: string | null; + synology_sid: string | null; + synology_did: string | null; + synology_skip_ssl: number; +} + +async function seedUser(id: number, cols: Partial = {}): Promise { + const base: SynologyCols = { synology_url: 'https://nas.test', synology_username: 'ada', synology_password: 'pw', synology_sid: 'sid-1', synology_did: null, synology_skip_ssl: 1 }; + await upsertRow(await sharedTestOrm(testDb), Users, { + id, + username: `u${id}`, + email: `u${id}@example.test`, + password_hash: 'x', + ...base, + ...cols, + }); } /** A Synology API envelope: { success, data } or { success:false, error:{ code } }. */ @@ -80,13 +95,13 @@ beforeAll(async () => { svc = new SynologyService(access as unknown as MemoriesAccessService, notificationsStub(), users); }); -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); decryptMock.mockImplementation((v: string) => v); maybeEncryptMock.mockImplementation((v: string) => v); checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }); - testDb.prepare('DELETE FROM users').run(); - seedUser(USER); + await deleteRows(await sharedTestOrm(testDb), Users); + await seedUser(USER); }); afterAll(() => testDb.close()); @@ -98,7 +113,7 @@ describe('credentials', () => { }); it('SYNO-U002: a half-filled row is "Synology not configured"', async () => { - seedUser(2, { synology_password: null }); + await seedUser(2, { synology_password: null }); const result = await svc.getSynologySettings(2); expect(result).toEqual({ success: false, error: { message: 'Synology not configured', status: 400 } }); }); @@ -110,7 +125,7 @@ describe('credentials', () => { }); it('SYNO-U004: skip_ssl is read as a boolean, 0 meaning verify', async () => { - seedUser(3, { synology_skip_ssl: 0 }); + await seedUser(3, { synology_skip_ssl: 0 }); const result = await svc.getSynologySettings(3); expect(result.success && result.data.synology_skip_ssl).toBe(false); }); @@ -140,14 +155,14 @@ describe('the session', () => { }); it('SYNO-U012: a login that returns no sid is a 500', async () => { - seedUser(4, { synology_sid: null }); + await seedUser(4, { synology_sid: null }); safeFetch.mockResolvedValue(api({})); const result = await svc.searchSynologyPhotos(4); expect(result).toEqual({ success: false, error: { message: 'Failed to get session ID from Synology', status: 500 } }); }); it('SYNO-U013: a stored device id rides along so a trusted device skips OTP', async () => { - seedUser(5, { synology_sid: null, synology_did: 'device-1' }); + await seedUser(5, { synology_sid: null, synology_did: 'device-1' }); safeFetch.mockResolvedValueOnce(api({ sid: 's' })).mockResolvedValueOnce(api({ list: [] })); await svc.searchSynologyPhotos(5); @@ -219,7 +234,7 @@ describe('updateSynologySettings', () => { it('SYNO-U031: keeps the stored password when none is supplied', async () => { safeFetch.mockResolvedValue(api({ sid: 's' })); await svc.updateSynologySettings(USER, 'https://nas2.test', 'ada'); - const row = testDb.prepare('SELECT synology_password, synology_url FROM users WHERE id = ?').get(USER) as { synology_password: string; synology_url: string }; + const row = await readUser(await sharedTestOrm(testDb), USER); expect(row.synology_password).toBe('pw'); expect(row.synology_url).toBe('https://nas2.test'); }); @@ -240,9 +255,9 @@ describe('updateSynologySettings', () => { const plaintext = 'synthetic-test-synology-pw-001'; await svc.updateSynologySettings(USER, 'https://nas3.test', 'ada', plaintext); - const row = testDb.prepare('SELECT synology_password FROM users WHERE id = ?').get(USER) as { synology_password: string }; + const row = await readUser(await sharedTestOrm(testDb), USER); expect(row.synology_password).not.toBe(plaintext); - expect(row.synology_password.startsWith('enc:v1:')).toBe(true); + expect(row.synology_password?.startsWith('enc:v1:')).toBe(true); }); }); @@ -453,14 +468,14 @@ describe('the search window', () => { describe('fetchSynologyThumbnailBytes', () => { it('SYNO-U080: fails without credentials rather than fetching', async () => { - seedUser(6, { synology_url: null }); + await seedUser(6, { synology_url: null }); const result = await svc.fetchSynologyThumbnailBytes(6, 6, 'a1'); expect(result).toHaveProperty('error'); expect(safeFetch).not.toHaveBeenCalled(); }); it('SYNO-U081: bounds the thumbnail read in time and size, on a host the user configured', async () => { - seedUser(7); + await seedUser(7); safeFetch.mockResolvedValueOnce({ ok: true, status: 200, diff --git a/server/tests/unit/nest/todo.service.test.ts b/server/tests/unit/nest/todo.service.test.ts index ff8a56ff8..35421007c 100644 --- a/server/tests/unit/nest/todo.service.test.ts +++ b/server/tests/unit/nest/todo.service.test.ts @@ -13,21 +13,8 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); - const mock = { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: () => null, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), - }; - return mock; + // Trip access reads through TripsRepository now; the module only hands out the handle. + return { db, closeDb: () => {}, reinitialize: () => {} }; }); @@ -44,7 +31,11 @@ import { createUser, createTrip, addTripMember } from '../../helpers/factories'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { TodoService } from '../../../src/nest/todo/todo.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo } from '../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { countRows, findRows, updateRows } from '../../helpers/factories/rows'; +import { TodoItems } from '../../../src/db/entities/TodoItems.entity'; +import { TodoCategoryAssignees } from '../../../src/db/entities/TodoCategoryAssignees.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos'; let svc: TodoService; @@ -250,7 +241,7 @@ describe('reorderItems', () => { await svc.reorderItems(trip.id, [c.id, a.id, b.id]); - const rows = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[]; + const rows = await findRows(await sharedTestOrm(testDb), TodoItems, { trip: trip.id }, { sort_order: 'asc' }); expect(rows[0].id).toBe(c.id); expect(rows[1].id).toBe(a.id); expect(rows[2].id).toBe(b.id); @@ -321,13 +312,13 @@ describe('getCategoryAssignees / updateCategoryAssignees', () => { expect(rows.map(r => r.user_id).sort()).toEqual([owner.id, member.id].sort()); expect(JSON.stringify(rows)).not.toContain(stranger.username); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM todo_category_assignees WHERE user_id = ?').get(stranger.id)).toEqual({ n: 0 }); + expect(await countRows(await sharedTestOrm(testDb), TodoCategoryAssignees, { user: stranger.id })).toBe(0); }); it('TODO-SVC-017b: keeps a guest, who is a trip member like any other', async () => { const { user: owner } = createUser(testDb); const { user: guest } = createUser(testDb); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + await updateRows(await sharedTestOrm(testDb), Users, { id: guest.id }, { is_guest: 1 }); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, guest.id); @@ -368,6 +359,7 @@ describe('TodoItemsRepository / TodoCategoryAssigneesRepository — parity', () await svc.createItem(trip.id, { name: 'Bare item' }); const converted = await svc.listItems(trip.id); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC').all(trip.id); expect(converted).toEqual(legacy); expect(converted).toHaveLength(2); @@ -382,6 +374,7 @@ describe('TodoItemsRepository / TodoCategoryAssigneesRepository — parity', () const converted = await svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, stranger.id, member.id]); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(` SELECT tca.user_id, u.username, u.avatar FROM todo_category_assignees tca diff --git a/server/tests/unit/nest/trek-photo-registration.service.test.ts b/server/tests/unit/nest/trek-photo-registration.service.test.ts index cf5a49707..7a99758e7 100644 --- a/server/tests/unit/nest/trek-photo-registration.service.test.ts +++ b/server/tests/unit/nest/trek-photo-registration.service.test.ts @@ -20,6 +20,8 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity'; import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity'; import { TripAlbumLinks } from '../../../src/db/entities/TripAlbumLinks.entity'; +import { Journeys } from '../../../src/db/entities/Journeys.entity'; +import { countRows, findRow, insertRow } from '../../helpers/factories/rows'; import { decrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto'; const testDb = createSnapshotTestDb(); @@ -41,14 +43,13 @@ async function makePhoto(): Promise { return await repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null); } -function rawRow(id: number): unknown { - return testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id); +function rawRow(id: number) { + return findRow(t, TrekPhotos, { id }); } -function read(id: number) { - return testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE id = ?').get(id) as { - taken_at: string | null; lat: number | null; lng: number | null; - }; +async function read(id: number) { + const row = await rawRow(id); + return { taken_at: row?.taken_at, lat: row?.lat, lng: row?.lng }; } describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => { @@ -56,7 +57,7 @@ describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => { const id = await makePhoto(); await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); - expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); + expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); }); it('TREKPHOTO-002: a later, emptier answer does not erase what is known', async () => { @@ -65,7 +66,7 @@ describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => { // The album listing knows the date but not the place. await repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null }); - expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); + expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); }); it('TREKPHOTO-003: fills only the half that was still missing', async () => { @@ -73,14 +74,14 @@ describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => { await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' }); await repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 }); - expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); + expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); }); it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', async () => { const id = await makePhoto(); await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null }); - expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null }); + expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null }); }); it('TREKPHOTO-005: an answer with nothing in it touches no row', async () => { @@ -88,7 +89,7 @@ describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => { await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); await repo.recordCaptureMetadata(id, {}); - expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); + expect(await read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); }); it('TREKPHOTO-006: says whether the row learned anything, so a refresh is only sent for news', async () => { @@ -109,7 +110,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => { it('TREKPHOTO-010: registers a new remote asset', async () => { const { user } = createUser(testDb); const id = await repo.getOrCreate('immich', 'asset-1', user.id, undefined, 'video'); - expect(rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'asset-1', owner_id: user.id, media_type: 'video' }); + expect(await rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'asset-1', owner_id: user.id, media_type: 'video' }); }); it('TREKPHOTO-011: a repeat lookup returns the same id rather than inserting a duplicate row', async () => { @@ -117,7 +118,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => { const first = await repo.getOrCreate('immich', 'asset-2', user.id); const second = await repo.getOrCreate('immich', 'asset-2', user.id); expect(second).toBe(first); - expect(testDb.prepare('SELECT COUNT(*) as c FROM trek_photos WHERE asset_id = ?').get('asset-2')).toEqual({ c: 1 }); + expect(await countRows(t, TrekPhotos, { asset_id: 'asset-2' })).toBe(1); }); it('TREKPHOTO-012 (PH2): a passphrase on an already-registered asset re-encrypts and replaces it', async () => { @@ -125,7 +126,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => { const id = await repo.getOrCreate('synologyphotos', 'asset-3', user.id, 'first-pass'); await repo.getOrCreate('synologyphotos', 'asset-3', user.id, 'second-pass'); - const row = rawRow(id) as { passphrase: string }; + const row = (await rawRow(id)) as { passphrase: string }; expect(decrypt_api_key(row.passphrase)).toBe('second-pass'); }); @@ -134,7 +135,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => { const id = await repo.getOrCreate('synologyphotos', 'asset-4', user.id, 'keep-me'); await repo.getOrCreate('synologyphotos', 'asset-4', user.id); - const row = rawRow(id) as { passphrase: string }; + const row = (await rawRow(id)) as { passphrase: string }; expect(decrypt_api_key(row.passphrase)).toBe('keep-me'); }); }); @@ -142,7 +143,7 @@ describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => { describe('TrekPhotoRegistrationService.getOrCreateLocal (PH4-5)', () => { it('TREKPHOTO-020: registers a new local photo', async () => { const id = await repo.getOrCreateLocal('journey/a.jpg', 'journey/thumbs/a.jpg', 800, 600, 'image', null); - expect(rawRow(id)).toMatchObject({ + expect(await rawRow(id)).toMatchObject({ provider: 'local', file_path: 'journey/a.jpg', thumbnail_path: 'journey/thumbs/a.jpg', width: 800, height: 600, }); }); @@ -152,7 +153,7 @@ describe('TrekPhotoRegistrationService.getOrCreateLocal (PH4-5)', () => { const second = await repo.getOrCreateLocal('journey/b.jpg', 'ignored-thumb.jpg', 1, 1); expect(second).toBe(first); // The second call's fields never reach the row — getOrCreateLocal only inserts once. - expect(rawRow(first)).toMatchObject({ thumbnail_path: null, width: null, height: null }); + expect(await rawRow(first)).toMatchObject({ thumbnail_path: null, width: null, height: null }); }); }); @@ -162,6 +163,7 @@ describe('TrekPhotoRegistrationService.resolve (PH6) — parity with the legacy const id = await repo.getOrCreate('immich', 'asset-parity', user.id, 'a-passphrase', 'video'); await repo.recordCaptureMetadata(id, { takenAt: '2026-05-01T00:00:00Z', lat: null, lng: null }); + // test-sql-allow: the row as SELECT * returns it is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id); const resolved = await repo.resolve(id); @@ -172,6 +174,7 @@ describe('TrekPhotoRegistrationService.resolve (PH6) — parity with the legacy const id = await repo.getOrCreateLocal('journey/parity.jpg', 'journey/thumbs/parity.jpg', 800, 600, 'image', null); await repo.recordCaptureMetadata(id, { takenAt: '2026-05-02T00:00:00Z', lat: 48.1, lng: 11.6 }); + // test-sql-allow: the row as SELECT * returns it is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id); const resolved = await repo.resolve(id); @@ -189,7 +192,7 @@ describe('TrekPhotoRegistrationService.setProvider (PH7)', () => { const id = await repo.getOrCreateLocal('journey/retarget.jpg'); await repo.setProvider(id, 'immich', 'new-asset', user.id); - expect(rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'new-asset', owner_id: user.id }); + expect(await rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'new-asset', owner_id: user.id }); }); }); @@ -198,7 +201,7 @@ describe('TrekPhotoRegistrationService.recordLocalThumbnail (PH8)', () => { const id = await repo.getOrCreateLocal('journey/thumb-src.jpg'); await repo.recordLocalThumbnail(id, 'journey/thumbs/thumb-src.jpg', 800, 600); - expect(rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-src.jpg', width: 800, height: 600 }); + expect(await rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-src.jpg', width: 800, height: 600 }); }); it('TREKPHOTO-051: a re-generated thumbnail does not blank dimensions already known', async () => { @@ -206,7 +209,7 @@ describe('TrekPhotoRegistrationService.recordLocalThumbnail (PH8)', () => { await repo.recordLocalThumbnail(id, 'journey/thumbs/thumb-keep.jpg', 400, 300); // COALESCE(width, ?) keeps the already-known 1200x900, not the new 400x300. - expect(rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-keep.jpg', width: 1200, height: 900 }); + expect(await rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-keep.jpg', width: 1200, height: 900 }); }); }); @@ -215,34 +218,33 @@ describe('TrekPhotoRegistrationService.deleteIfOrphan (PH10-11)', () => { const { user } = createUser(testDb); const id = await repo.getOrCreate('immich', 'orphan-1', user.id); await repo.deleteIfOrphan(id); - expect(rawRow(id)).toBeUndefined(); + expect(await rawRow(id)).toBeNull(); }); it('TREKPHOTO-061: a photo still referenced by trip_photos (3e-owned) is kept', async () => { const { user } = createUser(testDb); const id = await repo.getOrCreate('immich', 'referenced-1', user.id); const trip = createTrip(testDb, user.id); - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, id); + await insertRow(t, TripPhotos, { trip: trip.id, user: user.id, photo: id, shared: 1 }); await repo.deleteIfOrphan(id); - expect(rawRow(id)).toBeDefined(); + expect(await rawRow(id)).not.toBeNull(); }); it('TREKPHOTO-062: a photo still referenced by journey_photos (Plan 3g, converted onto JourneyPhotosRepository.existsForPhoto) is kept', async () => { const { user } = createUser(testDb); const id = await repo.getOrCreate('immich', 'referenced-2', user.id); - testDb.prepare("INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'J', 'draft', 0, 0)").run(user.id); - const journeyId = testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(user.id) as { id: number }; - testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId.id, id); + const journeyId = await insertRow(t, Journeys, { user: user.id, title: 'J', status: 'draft', created_at: 0, updated_at: 0 }); + await insertRow(t, JourneyPhotos, { journey: journeyId, photo: id, created_at: 0 }); await repo.deleteIfOrphan(id); - expect(rawRow(id)).toBeDefined(); + expect(await rawRow(id)).not.toBeNull(); }); it('TREKPHOTO-063: an unreferenced LOCAL photo is never reclaimed here — its bytes are ours', async () => { const id = await repo.getOrCreateLocal('journey/never-reclaimed.jpg'); await repo.deleteIfOrphan(id); - expect(rawRow(id)).toBeDefined(); + expect(await rawRow(id)).not.toBeNull(); }); }); diff --git a/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts b/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts index ce85ecb7c..704fa8207 100644 --- a/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts +++ b/server/tests/unit/nest/trip-membership/trip-membership.service.test.ts @@ -19,6 +19,7 @@ import { Trips } from '../../../../src/db/entities/Trips.entity'; import type { TripsRepository } from '../../../../src/db/repositories/Trips.repository'; import { TripMembers } from '../../../../src/db/entities/TripMembers.entity'; import type { TripMembersRepository } from '../../../../src/db/repositories/TripMembers.repository'; +import { countRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows'; import { TripMembershipService } from '../../../../src/nest/trip-membership/trip-membership.service'; const testDb = createSnapshotTestDb(); @@ -37,7 +38,7 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); function memberRow(tripId: number, userId: number) { - return testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, userId); + return findRow(t, TripMembers, { trip: tripId, user: userId }); } describe('joinTripAsMember', () => { @@ -48,7 +49,7 @@ describe('joinTripAsMember', () => { const r = await svc.joinTripAsMember(trip.id, joiner.id, null); expect(r).toEqual({ joined: true, tripId: trip.id }); - expect(memberRow(trip.id, joiner.id)).toBeTruthy(); + expect(await memberRow(trip.id, joiner.id)).toBeTruthy(); }); it('TRIP-JOIN-002: never adds the trip owner as a member', async () => { @@ -57,7 +58,7 @@ describe('joinTripAsMember', () => { const r = await svc.joinTripAsMember(trip.id, owner.id, null); expect(r.joined).toBe(false); - expect(memberRow(trip.id, owner.id)).toBeUndefined(); + expect(await memberRow(trip.id, owner.id)).toBeNull(); }); it('TRIP-JOIN-003: is idempotent for an existing member (no duplicate row)', async () => { @@ -67,8 +68,7 @@ describe('joinTripAsMember', () => { expect((await svc.joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(true); expect((await svc.joinTripAsMember(trip.id, joiner.id, owner.id)).joined).toBe(false); - const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id) as { n: number }; - expect(count.n).toBe(1); + expect(await countRows(t, TripMembers, { trip: trip.id, user: joiner.id })).toBe(1); }); it('TRIP-JOIN-004: no-ops for a missing trip', async () => { @@ -113,8 +113,7 @@ describe('joinTripAsMember', () => { // Exactly one membership row exists afterwards either way — the UNIQUE // constraint on (trip_id, user_id) is the actual safety net today, not // application-level locking. - const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id) as { n: number }; - expect(count.n).toBe(1); + expect(await countRows(t, TripMembers, { trip: trip.id, user: joiner.id })).toBe(1); }); }); @@ -133,8 +132,8 @@ describe('leaf membership reads', () => { const { user: m1 } = createUser(testDb); const { user: m2 } = createUser(testDb); const trip = createTrip(testDb, owner.id); - testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-02')").run(trip.id, m2.id); - testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-01')").run(trip.id, m1.id); + await insertRow(t, TripMembers, { trip: trip.id, user: m2.id, added_at: '2026-01-02' }); + await insertRow(t, TripMembers, { trip: trip.id, user: m1.id, added_at: '2026-01-01' }); expect(await svc.listMemberUserIds(trip.id)).toEqual([m1.id, m2.id]); expect(await svc.listMemberUserIds(999999)).toEqual([]); }); @@ -145,10 +144,10 @@ describe('leaf membership reads', () => { const owned = createTrip(testDb, user.id); const memberOf = createTrip(testDb, other.id); const foreign = createTrip(testDb, other.id); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(memberOf.id, user.id); + await insertRow(t, TripMembers, { trip: memberOf.id, user: user.id }); // Distinct created_at so the ORDER BY is actually asserted, not assumed. - testDb.prepare("UPDATE trips SET created_at = '2026-01-01' WHERE id = ?").run(owned.id); - testDb.prepare("UPDATE trips SET created_at = '2026-01-02' WHERE id = ?").run(memberOf.id); + await updateRows(t, Trips, { id: owned.id }, { created_at: '2026-01-01' }); + await updateRows(t, Trips, { id: memberOf.id }, { created_at: '2026-01-02' }); const ids = await svc.listAccessibleTripIds(user.id); expect(ids).toEqual([memberOf.id, owned.id]); expect(ids).not.toContain(foreign.id); diff --git a/server/tests/unit/services/airtrailImport.test.ts b/server/tests/unit/services/airtrailImport.test.ts index aedf5556e..e29bc08c7 100644 --- a/server/tests/unit/services/airtrailImport.test.ts +++ b/server/tests/unit/services/airtrailImport.test.ts @@ -22,9 +22,13 @@ import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.cli import type { AirtrailService } from '../../../src/nest/integrations/airtrail.service'; import { notificationsStub } from '../../helpers/notifications'; import { accommodationsOver } from '../../helpers/accommodations-service'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestReservationsRepo, createTestReservationEndpointsRepo, createTestReservationTravelersRepo, createTestReservationDayPositionsRepo, createTestDayAccommodationsRepo, createTestDaysRepo, createTestPlacesRepo, createTestDayAssignmentsRepo, createTestTripMembersRepo, createTestUsersRepo, createTestTripsRepo } from '../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestReservationsRepo, createTestReservationEndpointsRepo, createTestReservationTravelersRepo, createTestReservationDayPositionsRepo, createTestDayAccommodationsRepo, createTestDaysRepo, createTestPlacesRepo, createTestDayAssignmentsRepo, createTestTripMembersRepo, createTestUsersRepo, createTestTripsRepo, sharedTestOrm } from '../../helpers/test-uow'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { budgetRepoArgs } from '../../helpers/budget-repos'; +import { findRow, findRows } from '../../helpers/factories/rows'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; // The client and the per-user credentials are the only stubs; the reservation // writes go through the real service against the real test DB, as before. They @@ -113,14 +117,19 @@ const legLhrJfk = () => flightNumber: 'BA117', }); -function tripReservations(tripId: number) { - return db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY id').all(tripId) as any[]; +async function tripReservations(tripId: number) { + return findRows(await sharedTestOrm(db), Reservations, { trip: tripId }, { id: 'asc' }); } -function endpointsOf(reservationId: number) { - return db - .prepare('SELECT role, code, sequence FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence') - .all(reservationId) as { role: string; code: string | null; sequence: number }[]; +async function endpointsOf(reservationId: number) { + return findRows(await sharedTestOrm(db), ReservationEndpoints, { reservation: reservationId }, { sequence: 'asc' }); +} + +/** The id of the trip's day on `date`. */ +async function dayIdOn(tripId: number, date: string): Promise { + const day = await findRow(await sharedTestOrm(db), Days, { trip: tripId, date }); + if (!day) throw new Error(`no day on ${date} for trip ${tripId}`); + return day.id; } let tripId: number; @@ -142,7 +151,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => { expect([...result.imported].sort()).toEqual(['101', '102']); expect(result.skipped).toEqual([]); - const rows = tripReservations(tripId); + const rows = await tripReservations(tripId); expect(rows).toHaveLength(1); const r = rows[0]; expect(r.type).toBe('flight'); @@ -150,10 +159,10 @@ describe('importAirtrailFlights connection joining (#1535)', () => { expect(r.external_id).toBe('101'); expect(r.sync_enabled).toBe(0); // AirTrail has no multi-leg entity to round-trip to - const meta = JSON.parse(r.metadata); + const meta = JSON.parse(String(r.metadata)); expect(meta.airtrail_ids).toEqual(['101', '102']); expect(meta.legs).toHaveLength(2); - expect(endpointsOf(r.id).map(e => [e.role, e.code])).toEqual([ + expect((await endpointsOf(r.id)).map(e => [e.role, e.code])).toEqual([ ['from', 'BRU'], ['stop', 'HEL'], ['to', 'JFK'], @@ -161,9 +170,9 @@ describe('importAirtrailFlights connection joining (#1535)', () => { // Each leg is filed on its own trip day so the day planner renders the // legs where they belong (both flights are on Aug 1 here). - const day1 = db.prepare("SELECT id FROM days WHERE trip_id = ? AND date = '2026-08-01'").get(tripId) as { id: number }; - expect(meta.legs[0]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id }); - expect(meta.legs[1]).toMatchObject({ dep_day_id: day1.id, arr_day_id: day1.id }); + const day1 = await dayIdOn(tripId, '2026-08-01'); + expect(meta.legs[0]).toMatchObject({ dep_day_id: day1, arr_day_id: day1 }); + expect(meta.legs[1]).toMatchObject({ dep_day_id: day1, arr_day_id: day1 }); }); it('resolves overnight-connection legs to their own days', async () => { @@ -176,12 +185,11 @@ describe('importAirtrailFlights connection joining (#1535)', () => { listFlights.mockResolvedValue([legBruHel(), overnightLeg2]); await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]); - const [r] = tripReservations(tripId); - const dayId = (d: string) => (db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, d) as { id: number }).id; - const legs = JSON.parse(r.metadata).legs; - expect(legs[0].dep_day_id).toBe(dayId('2026-08-01')); - expect(legs[1].dep_day_id).toBe(dayId('2026-08-02')); - expect(legs[1].arr_day_id).toBe(dayId('2026-08-02')); + const [r] = await tripReservations(tripId); + const legs = JSON.parse(String(r.metadata)).legs; + expect(legs[0].dep_day_id).toBe(await dayIdOn(tripId, '2026-08-01')); + expect(legs[1].dep_day_id).toBe(await dayIdOn(tripId, '2026-08-02')); + expect(legs[1].arr_day_id).toBe(await dayIdOn(tripId, '2026-08-02')); }); it('refuses to join an out-and-back return as a connection', async () => { @@ -198,7 +206,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => { const result = await importAirtrailFlights(tripId, userId, ['101', '104'], undefined, [['101', '104']]); expect([...result.imported].sort()).toEqual(['101', '104']); - expect(tripReservations(tripId)).toHaveLength(2); // two singles, no bogus BRU→HEL→BRU booking + expect(await tripReservations(tripId)).toHaveLength(2); // two singles, no bogus BRU→HEL→BRU booking }); it('skips every member of a joined booking on a later import attempt', async () => { @@ -210,7 +218,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => { const again = await importAirtrailFlights(tripId, userId, ['102'], undefined); expect(again.imported).toEqual([]); expect(again.skipped).toEqual([{ flightId: '102', reason: 'already-imported' }]); - expect(tripReservations(tripId)).toHaveLength(1); + expect(await tripReservations(tripId)).toHaveLength(1); }); it('recognizes a joined leg imported by another member via its per-leg signature', async () => { @@ -224,7 +232,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => { const result = await importAirtrailFlights(tripId, other.id, ['999'], undefined); expect(result.imported).toEqual([]); expect(result.skipped).toEqual([{ flightId: '999', reason: 'already-in-trip', detail: expect.any(String) }]); - expect(tripReservations(tripId)).toHaveLength(1); + expect(await tripReservations(tripId)).toHaveLength(1); }); it('falls back to individual imports when the requested join does not chain', async () => { @@ -233,11 +241,11 @@ describe('importAirtrailFlights connection joining (#1535)', () => { const result = await importAirtrailFlights(tripId, userId, ['101', '103'], undefined, [['101', '103']]); expect([...result.imported].sort()).toEqual(['101', '103']); - const rows = tripReservations(tripId); + const rows = await tripReservations(tripId); expect(rows).toHaveLength(2); for (const r of rows) { expect(r.sync_enabled).toBe(1); // plain imports keep live sync - expect(endpointsOf(r.id)).toHaveLength(2); + expect(await endpointsOf(r.id)).toHaveLength(2); } }); @@ -247,7 +255,7 @@ describe('importAirtrailFlights connection joining (#1535)', () => { const result = await importAirtrailFlights(tripId, userId, ['101', '102'], undefined, [['101', '102']]); expect([...result.imported].sort()).toEqual(['101', '102']); - expect(tripReservations(tripId)).toHaveLength(2); + expect(await tripReservations(tripId)).toHaveLength(2); }); it('imports singles exactly as before when no join is requested', async () => { @@ -256,11 +264,11 @@ describe('importAirtrailFlights connection joining (#1535)', () => { const result = await importAirtrailFlights(tripId, userId, ['101'], undefined); expect(result.imported).toEqual(['101']); - const [r] = tripReservations(tripId); + const [r] = await tripReservations(tripId); expect(r.external_id).toBe('101'); expect(r.sync_enabled).toBe(1); expect(r.external_hash).toBeTruthy(); - expect(JSON.parse(r.metadata).airtrail_ids).toBeUndefined(); + expect(JSON.parse(String(r.metadata)).airtrail_ids).toBeUndefined(); }); });