diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index b5472d326..4141c02b1 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -110,6 +110,12 @@ import { PushSubscriptions } from '../../../src/db/entities/PushSubscriptions.en import type { McpTokensRepository } from '../../../src/db/repositories/McpTokens.repository'; import { budgetRepoArgs } from '../../helpers/budget-repos'; import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; +import type { TestOrm } from '../../helpers/test-orm'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { readAppSetting } from '../../helpers/factories/settings'; +import { readUser } from '../../helpers/factories/users'; +import { McpTokens } from '../../../src/db/entities/McpTokens.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -155,6 +161,7 @@ beforeAll(async () => { await createTestSessionsService(testDb), ); const t = await sharedTestOrm(testDb); + orm = t; mcpTokensRepo = await createTestMcpTokensRepo(testDb); auditLogRepo = t.repo(AuditLog); svc = new AdminService( @@ -209,6 +216,10 @@ afterAll(() => { testDb.close(); }); +let orm: TestOrm; + +const setJourney = (enabled: boolean) => updateRows(orm, Addons, { id: 'journey' }, { enabled }); + // ── listUsers ───────────────────────────────────────────────────────────────── describe('listUsers', () => { @@ -389,9 +400,7 @@ describe('getAuditLog', () => { describe('getAuditLog — JSON details', () => { it('ADMIN-SVC-045 — parses JSON details when present', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run( - user.id, 'test_action', JSON.stringify({ key: 'val' }) - ); + await insertRow(orm, AuditLog, { user: user.id, action: 'test_action', details: JSON.stringify({ key: 'val' }) }); const result = (await getAuditLog({})) as any; expect(result.entries.length).toBeGreaterThanOrEqual(1); const entry = result.entries.find((e: any) => e.action === 'test_action'); @@ -401,9 +410,7 @@ describe('getAuditLog — JSON details', () => { it('ADMIN-SVC-046 — falls back to the raw string when details are not valid JSON', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run( - user.id, 'bad_json_action', 'not-valid-json{' - ); + await insertRow(orm, AuditLog, { user: user.id, action: 'bad_json_action', details: 'not-valid-json{' }); const result = (await getAuditLog({})) as any; const entry = result.entries.find((e: any) => e.action === 'bad_json_action'); expect(entry).toBeDefined(); @@ -642,32 +649,32 @@ describe('updateAddon', () => { }); it('ADMIN-SVC-087 — refuses to enable a photo provider while journey is off', async () => { - testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run(); - testDb.prepare("UPDATE photo_providers SET enabled = 0 WHERE id = 'immich'").run(); + await setJourney(false); + await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 0 }); const result = await updateAddon('immich', { enabled: true }) as any; expect(result).toEqual({ error: 'Enable the Journey addon first', status: 409 }); - expect(testDb.prepare("SELECT enabled FROM photo_providers WHERE id = 'immich'").get()).toEqual({ enabled: 0 }); + expect((await findRow(orm, PhotoProviders, { id: 'immich' }))?.enabled).toBe(0); }); it('ADMIN-SVC-088 — enables a provider under an enabled journey; disabling never needs journey', async () => { - testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run(); + await setJourney(true); const enabled = await updateAddon('immich', { enabled: true }) as any; expect(enabled.addon).toMatchObject({ id: 'immich', type: 'photo_provider', enabled: true }); // Switching a provider OFF stays possible with journey off — cleanup must not dead-end. - testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run(); + await setJourney(false); const disabled = await updateAddon('immich', { enabled: false }) as any; expect(disabled.addon.enabled).toBe(false); }); it('ADMIN-SVC-089 — disabling journey cascades every photo provider off', async () => { - testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run(); - testDb.prepare('UPDATE photo_providers SET enabled = 1').run(); + await setJourney(true); + await updateRows(orm, PhotoProviders, {}, { enabled: 1 }); const result = await updateAddon('journey', { enabled: false }) as any; expect(result.addon.enabled).toBe(false); - const rows = testDb.prepare('SELECT enabled FROM photo_providers').all() as Array<{ enabled: number }>; + const rows = await findRows(orm, PhotoProviders); expect(rows.length).toBeGreaterThan(0); expect(rows.every((r) => r.enabled === 0)).toBe(true); }); @@ -691,10 +698,7 @@ describe('version-check job', () => { await new VersionCheckJob(svc, registrarStub, envStub).tick(); - const notified = testDb - .prepare('SELECT value FROM app_settings WHERE key = ?') - .get('last_notified_version') as { value: string } | undefined; - expect(notified?.value).toBe('99.9.9'); + expect(await readAppSetting(orm, 'last_notified_version')).toBe('99.9.9'); // The version cache is module-scoped in admin.helpers, so the cron and // GET /api/admin/version-check hit GitHub once between them. @@ -714,7 +718,7 @@ describe('admin quirk fixes (post-fold)', () => { expect((await updateUser(String(user.id), { username: '' })) as any).toMatchObject({ status: 400, error: 'Username cannot be empty' }); expect((await updateUser(String(user.id), { email: ' ' })) as any).toMatchObject({ status: 400, error: 'Email cannot be empty' }); // The row is untouched. - const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string }; + const row = await readUser(orm, user.id); expect(row.username).toBe(user.username); }); @@ -722,29 +726,20 @@ describe('admin quirk fixes (post-fold)', () => { // ── What an admin password reset ends, and what an ordinary edit must not ───── -const pv = (id: number): number => - (testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number | null }) - ?.password_version ?? 0; +const pv = async (id: number): Promise => (await findRow(orm, Users, { id }))?.password_version ?? 0; -const mcpTokenCount = (id: number): number => - (testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n; +const mcpTokenCount = (id: number): Promise => countRows(orm, McpTokens, { user: id }); -const addPushDevice = (userId: number, endpoint: string): void => { - testDb - .prepare( - "INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')", - ) - .run(userId, endpoint); +const addPushDevice = async (userId: number, endpoint: string): Promise => { + await insertRow(orm, PushSubscriptions, { user: userId, endpoint, p256dh: 'p', auth: 'a', vapid_public_key: 'k' }); }; const liveSessions = (id: number): number => (testDb.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND revoked_at IS NULL').get(id) as { n: number }).n; -const pushDeviceCount = (id: number): number => - (testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?').get(id) as { n: number }).n; +const pushDeviceCount = (id: number): Promise => countRows(orm, PushSubscriptions, { user: id }); -const passwordHash = (id: number): string => - (testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(id) as { password_hash: string }).password_hash; +const passwordHash = async (id: number): Promise => (await readUser(orm, id)).password_hash; describe('admin password reset revokes what an intruder already holds', () => { it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', async () => { @@ -753,41 +748,41 @@ describe('admin password reset revokes what an intruder already holds', () => { // accepting every cookie the intruder holds, and the one action taken to // lock them out is the one action that did not. const { user } = createUser(testDb); - const before = pv(user.id); + const before = await pv(user.id); await updateUser(String(user.id), { password: 'ANewStrongPass123!' }); - expect(pv(user.id)).toBe(before + 1); + expect(await pv(user.id)).toBe(before + 1); }); it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); + await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' }); await updateUser(String(user.id), { password: 'ANewStrongPass123!' }); - expect(mcpTokenCount(user.id)).toBe(0); + expect(await mcpTokenCount(user.id)).toBe(0); }); it('ADMIN-SVC-081b: and forgets the push devices, which outlive every session, of that user only', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder'); - addPushDevice(user.id, 'https://web.push.apple.com/owner'); - addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/bystander'); + await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder'); + await addPushDevice(user.id, 'https://web.push.apple.com/owner'); + await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/bystander'); await updateUser(String(user.id), { password: 'ANewStrongPass123!' }); - expect(pushDeviceCount(user.id)).toBe(0); - expect(pushDeviceCount(other.id)).toBe(1); + expect(await pushDeviceCount(user.id)).toBe(0); + expect(await pushDeviceCount(other.id)).toBe(1); }); it('ADMIN-SVC-081c: drops them in the same transaction as the password, so a failure leaves the account as it was', async () => { const { user } = createUser(testDb); - const before = pv(user.id); - const hashBefore = passwordHash(user.id); - testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); - addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder'); + const before = await pv(user.id); + const hashBefore = await passwordHash(user.id); + await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' }); + await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder'); testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END"); try { await expect(updateUser(String(user.id), { password: 'ANewStrongPass123!' })).rejects.toThrow('boom'); @@ -795,10 +790,10 @@ describe('admin password reset revokes what an intruder already holds', () => { testDb.exec('DROP TRIGGER boom'); } - expect(pv(user.id)).toBe(before); - expect(passwordHash(user.id)).toBe(hashBefore); - expect(mcpTokenCount(user.id)).toBe(1); - expect(pushDeviceCount(user.id)).toBe(1); + expect(await pv(user.id)).toBe(before); + expect(await passwordHash(user.id)).toBe(hashBefore); + expect(await mcpTokenCount(user.id)).toBe(1); + expect(await pushDeviceCount(user.id)).toBe(1); }); it('ADMIN-SVC-081d: and ends every session of that user, and only of that user', async () => { @@ -817,15 +812,15 @@ describe('admin password reset revokes what an intruder already holds', () => { it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', async () => { const { user } = createUser(testDb); - const before = pv(user.id); - testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); - addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/renamed'); + const before = await pv(user.id); + await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' }); + await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/renamed'); await updateUser(String(user.id), { username: 'renamed' }); - expect(pv(user.id)).toBe(before); - expect(mcpTokenCount(user.id)).toBe(1); - expect(pushDeviceCount(user.id)).toBe(1); + expect(await pv(user.id)).toBe(before); + expect(await mcpTokenCount(user.id)).toBe(1); + expect(await pushDeviceCount(user.id)).toBe(1); }); }); @@ -852,9 +847,7 @@ describe('resetUserMfa', () => { expect(result.success).toBe(true); expect(result.email).toBe(user.email); - const row = testDb - .prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes FROM users WHERE id = ?') - .get(user.id) as { mfa_enabled: number; mfa_secret: string | null; mfa_backup_codes: string | null }; + const row = await readUser(orm, user.id); expect(row.mfa_enabled).toBe(0); expect(row.mfa_secret).toBeNull(); expect(row.mfa_backup_codes).toBeNull(); @@ -912,7 +905,7 @@ describe('updateUser — last-admin guard (AD9/AD10, R4)', () => { const result = (await updateUser(String(soleAdmin.id), { role: 'user' })) as { error?: string; status?: number }; expect(result).toEqual({ error: 'Cannot remove the last admin', status: 400 }); - const row = testDb.prepare('SELECT role FROM users WHERE id = ?').get(soleAdmin.id) as { role: string }; + const row = await readUser(orm, soleAdmin.id); expect(row.role).toBe('admin'); }); @@ -923,7 +916,7 @@ describe('updateUser — last-admin guard (AD9/AD10, R4)', () => { const result = (await updateUser(String(secondAdmin.id), { role: 'user' })) as { user?: { role: string }; error?: string }; expect(result.error).toBeUndefined(); - const row = testDb.prepare('SELECT role FROM users WHERE id = ?').get(secondAdmin.id) as { role: string }; + const row = await readUser(orm, secondAdmin.id); expect(row.role).toBe('user'); }); }); @@ -933,18 +926,17 @@ describe('getAuditLog — AD22 parity through the service (LEFT JOIN survives a const { user: liveUser } = createUser(testDb); const { user: doomedUser } = createUser(testDb); - testDb.prepare('INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)') - .run(liveUser.id, 'live_user_action', 'trip', null, '127.0.0.1'); - testDb.prepare('INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)') - .run(doomedUser.id, 'about_to_be_deleted_action', 'trip', null, '127.0.0.1'); + await insertRow(orm, AuditLog, { user: liveUser.id, action: 'live_user_action', resource: 'trip', details: null, ip: '127.0.0.1' }); + await insertRow(orm, AuditLog, { user: doomedUser.id, action: 'about_to_be_deleted_action', resource: 'trip', details: null, ip: '127.0.0.1' }); // audit_log.user_id is ON DELETE SET NULL (Task 0's report) — deleting the // user directly (not through the service) makes the row's user_id // genuinely NULL, the exact LEFT JOIN shape rule 16 warns about. - testDb.prepare('DELETE FROM users WHERE id = ?').run(doomedUser.id); + await deleteRows(orm, Users, { id: doomedUser.id }); // The legacy statement, run raw on the SAME seeded rows — the parity anchor. const legacyRows = testDb + // test-sql-allow: the legacy statement is this parity test's oracle and has to run as written. .prepare( `SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip FROM audit_log a @@ -986,9 +978,7 @@ describe('getAuditLog — AD22 parity through the service (LEFT JOIN survives a describe('updateUser — password-reset transaction boundary (AD11/12/13)', () => { it('ADMIN-SVC-093 — a failure on the mcp_tokens delete (AD12, not try/caught) rolls back the already-run users UPDATE (AD11) too', async () => { const { user } = createUser(testDb); - const before = testDb - .prepare('SELECT username, password_version FROM users WHERE id = ?') - .get(user.id) as { username: string; password_version: number }; + const before = await readUser(orm, user.id); const spy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('simulated mcp_tokens failure')); @@ -996,9 +986,7 @@ describe('updateUser — password-reset transaction boundary (AD11/12/13)', () = updateUser(String(user.id), { username: 'should-roll-back', password: 'ANewStrongPass123!' }), ).rejects.toThrow('simulated mcp_tokens failure'); - const after = testDb - .prepare('SELECT username, password_version FROM users WHERE id = ?') - .get(user.id) as { username: string; password_version: number }; + const after = await readUser(orm, user.id); // The users UPDATE (AD11) ran FIRST, inside the SAME uow.transactional // boundary as the failing mcp_tokens delete — proves the TX boundary @@ -1069,6 +1057,6 @@ describe('a create or edit that loses the race for an email or username', () => const { user } = createUser(testDb, { username: 'carl', email: 'carl@example.com' }); vi.spyOn(UsersRepository.prototype, 'findIdByEmailCI').mockResolvedValue(null); expect(await updateUser(String(user.id), { email: 'Anna@Example.com' })).toEqual({ error: 'Email already taken', status: 409 }); - expect(testDb.prepare('SELECT email FROM users WHERE id = ?').get(user.id)).toEqual({ email: 'carl@example.com' }); + expect((await readUser(orm, user.id)).email).toBe('carl@example.com'); }); }); diff --git a/server/tests/unit/nest/unified-memories.service.test.ts b/server/tests/unit/nest/unified-memories.service.test.ts index 34c0c8486..ecab2bad7 100644 --- a/server/tests/unit/nest/unified-memories.service.test.ts +++ b/server/tests/unit/nest/unified-memories.service.test.ts @@ -16,14 +16,6 @@ vi.mock('../../../src/db/database', async () => { closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(` - SELECT t.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; }); @@ -60,6 +52,8 @@ import type { TripAlbumLinksRepository } from '../../../src/db/repositories/Trip import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; import { notificationsStub } from '../../helpers/notifications'; import { createTestUnitOfWork, sharedTestOrm, createTestUsersRepo } from '../../helpers/test-uow'; +import type { TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, findRows, insertRow, insertRowIgnoringConflict, updateRows } from '../../helpers/factories/rows'; // The album-sync paths are the providers' half and have their own suites; these // cases never reach them, so stubs keep the graph small. @@ -104,6 +98,7 @@ beforeAll(async () => { // never read it, so the `dbs.canAccessTrip` spy this block used to route // to a real `DatabaseService` was already dead; removed with it. const t = await sharedTestOrm(testDb); + orm = t; tripPhotosRepo = t.repo(TripPhotos); tripAlbumLinksRepo = t.repo(TripAlbumLinks); usersRepo = await createTestUsersRepo(testDb); @@ -124,10 +119,10 @@ beforeAll(async () => { ); }); -beforeEach(() => { +beforeEach(async () => { resetTestDb(testDb); // Ensure default providers are enabled (resetTestDb seeds them but doesn't reset enabled flag) - testDb.prepare('UPDATE photo_providers SET enabled = 1').run(); + await updateRows(orm, PhotoProviders, {}, { enabled: 1 }); // Providers only count as enabled under an enabled journey addon (migration 84 seeds it off). setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true); realtimeMock.broadcast.mockClear(); @@ -137,6 +132,13 @@ afterAll(() => { testDb.close(); }); +let orm: TestOrm; + +/** The ids of the providers switched on, the list the legacy statements bind into their IN (...). */ +async function enabledProviderIds(): Promise { + return (await findRows(orm, PhotoProviders, { enabled: 1 })).map((r) => r.id as string); +} + // ── listTripPhotos ──────────────────────────────────────────────────────────── describe('listTripPhotos', () => { @@ -151,7 +153,7 @@ describe('listTripPhotos', () => { const trip = createTrip(testDb, user.id); // Disable all providers - testDb.prepare('UPDATE photo_providers SET enabled = 0').run(); + await updateRows(orm, PhotoProviders, {}, { enabled: 0 }); const result = await listTripPhotos(String(trip.id), user.id); expect(result.success).toBe(false); @@ -185,7 +187,7 @@ describe('listTripAlbumLinks', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare('UPDATE photo_providers SET enabled = 0').run(); + await updateRows(orm, PhotoProviders, {}, { enabled: 0 }); const result = await listTripAlbumLinks(String(trip.id), user.id); expect(result.success).toBe(false); @@ -207,9 +209,9 @@ describe('addTripPhotos', () => { const trip = createTrip(testDb, user.id); // Insert a disabled provider - testDb.prepare( - 'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)' - ).run('disabled-prov', 'Disabled', 'Disabled provider', 'Image', 0, 99); + await insertRowIgnoringConflict(orm, PhotoProviders, { + id: 'disabled-prov', name: 'Disabled', description: 'Disabled provider', icon: 'Image', enabled: 0, sort_order: 99, + }); const result = await addTripPhotos( String(trip.id), @@ -292,9 +294,9 @@ describe('createTripAlbumLink', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare( - 'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)' - ).run('disabled-prov2', 'Disabled2', 'desc', 'Image', 0, 100); + await insertRowIgnoringConflict(orm, PhotoProviders, { + id: 'disabled-prov2', name: 'Disabled2', description: 'desc', icon: 'Image', enabled: 0, sort_order: 100, + }); const result = await createTripAlbumLink(String(trip.id), user.id, 'disabled-prov2', 'album-1', 'My Album'); expect(result.success).toBe(false); @@ -311,8 +313,7 @@ describe('createTripAlbumLink', () => { const second = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-dup-1', 'Second Name'); expect(expectFailure(second).status).toBe(409); - const rows = testDb.prepare('SELECT COUNT(*) as c FROM trip_album_links WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as { c: number }; - expect(rows.c).toBe(1); + expect(await countRows(orm, TripAlbumLinks, { trip: trip.id, user: user.id })).toBe(1); }); }); @@ -331,22 +332,23 @@ describe('removeAlbumLink', () => { const linked = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-rm-1', 'To Remove'); expect(linked.success).toBe(true); - const linkRow = testDb.prepare('SELECT id FROM trip_album_links WHERE trip_id = ? AND user_id = ? AND album_id = ?').get(trip.id, user.id, 'album-rm-1') as { id: number }; + const linkRow = await findRow(orm, TripAlbumLinks, { trip: trip.id, user: user.id, album_id: 'album-rm-1' }); + if (!linkRow) throw new Error('album link album-rm-1 was not created'); await addTripPhotos(String(trip.id), user.id, true, [{ provider: 'immich', asset_ids: ['asset-rm-1'] }], 'sid-6', String(linkRow.id)); - const photoRow = testDb.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').get(trip.id, linkRow.id) as { photo_id: number }; - expect(photoRow).toBeDefined(); + const photoRow = await findRow(orm, TripPhotos, { trip: trip.id, albumLink: linkRow.id }); + expect(photoRow).not.toBeNull(); const result = await removeAlbumLink(String(trip.id), String(linkRow.id), user.id); expect(result.success).toBe(true); - const remainingLink = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkRow.id); - expect(remainingLink).toBeUndefined(); - const remainingPhoto = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').get(trip.id, linkRow.id); - expect(remainingPhoto).toBeUndefined(); + const remainingLink = await findRow(orm, TripAlbumLinks, { id: linkRow.id }); + expect(remainingLink).toBeNull(); + const remainingPhoto = await findRow(orm, TripPhotos, { trip: trip.id, albumLink: linkRow.id }); + expect(remainingPhoto).toBeNull(); // The orphaned trek_photos row is reclaimed too (TrekPhotoRegistrationService.deleteIfOrphan, PH10/PH11). - const orphan = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoRow.photo_id); - expect(orphan).toBeUndefined(); + const orphan = await findRow(orm, TrekPhotos, { id: photoRow?.photo_id }); + expect(orphan).toBeNull(); }); }); @@ -359,17 +361,18 @@ describe('listTripPhotos / listTripAlbumLinks — parity', () => { // A photo under a disabled provider must not appear — the legacy `tkp.provider IN (...)` // only ever names the enabled set. - testDb.prepare( - 'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, 50)' - ).run('disabled-x', 'Disabled X', 'desc', 'Image'); + await insertRowIgnoringConflict(orm, PhotoProviders, { + id: 'disabled-x', name: 'Disabled X', description: 'desc', icon: 'Image', enabled: 0, sort_order: 50, + }); - const enabledPhoto = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-enabled', user.id).lastInsertRowid as number; - const disabledPhoto = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('disabled-x', 'asset-disabled', user.id).lastInsertRowid as number; + const enabledPhoto = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-enabled', owner: user.id }); + const disabledPhoto = await insertRow(orm, TrekPhotos, { provider: 'disabled-x', asset_id: 'asset-disabled', owner: user.id }); - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, enabledPhoto); - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, disabledPhoto); + await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: enabledPhoto, shared: 1 }); + await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: disabledPhoto, shared: 1 }); - const enabledProviders = testDb.prepare('SELECT id FROM photo_providers WHERE enabled = 1').all().map((r: { id: string }) => r.id); + const enabledProviders = await enabledProviderIds(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const oracle = testDb.prepare(` SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, u.username, u.avatar @@ -394,7 +397,8 @@ describe('listTripPhotos / listTripAlbumLinks — parity', () => { await createTripAlbumLink(String(trip.id), user.id, 'synologyphotos', 'album-p1', 'Passphrase Album', 'secret-pass'); - const enabledProviders = testDb.prepare('SELECT id FROM photo_providers WHERE enabled = 1').all().map((r: { id: string }) => r.id); + const enabledProviders = await enabledProviderIds(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const oracle = testDb.prepare(` SELECT tal.id, tal.trip_id, @@ -452,16 +456,16 @@ describe('R8 — injected RealtimeService, not the legacy module broadcast', () it('MEM-UNIFIED-016: setTripPhotoSharing calls this.realtime.broadcast', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const photoId = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-r8-2', user.id).lastInsertRowid as number; - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 0)').run(trip.id, user.id, photoId); + const photoId = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-r8-2', owner: user.id }); + await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: photoId, shared: 0 }); const result = await setTripPhotoSharing(String(trip.id), user.id, photoId, true, 'sid-2'); expect(result.success).toBe(true); expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1); expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-2'); - const row = testDb.prepare('SELECT shared FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?').get(trip.id, user.id, photoId) as { shared: number }; - expect(row.shared).toBe(1); + const row = await findRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: photoId }); + expect(row?.shared).toBe(1); }); it('MEM-UNIFIED-018: UM4 insertIgnore reports 0 added on a re-add of the same photo (duplicate-ignore, not a crash)', async () => { @@ -475,22 +479,21 @@ describe('R8 — injected RealtimeService, not the legacy module broadcast', () const second = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'immich', asset_ids: ['asset-dup-1'] }], 'sid-5'); expect(expectSuccess(second).added).toBe(0); - const rows = testDb.prepare('SELECT COUNT(*) as c FROM trip_photos WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as { c: number }; - expect(rows.c).toBe(1); + expect(await countRows(orm, TripPhotos, { trip: trip.id, user: user.id })).toBe(1); }); it('MEM-UNIFIED-017: removeTripPhoto calls this.realtime.broadcast and deletes the row', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const photoId = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-r8-3', user.id).lastInsertRowid as number; - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 0)').run(trip.id, user.id, photoId); + const photoId = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-r8-3', owner: user.id }); + await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: photoId, shared: 0 }); const result = await removeTripPhoto(String(trip.id), user.id, photoId, 'sid-3'); expect(result.success).toBe(true); expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1); expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-3'); - const row = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?').get(trip.id, user.id, photoId); - expect(row).toBeUndefined(); + const row = await findRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: photoId }); + expect(row).toBeNull(); }); });