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(); }); });