diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index 33c3703b0..52f51d7ad 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -22,8 +22,6 @@ vi.mock('../../src/db/database', async () => { closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, - canAccessTrip: (tripId: number | string, 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: () => false, }; }); @@ -39,7 +37,13 @@ import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { countRows, findRow } from '../helpers/factories/rows'; +import { CollectionPlaces } from '../../src/db/entities/CollectionPlaces.entity'; +import { Collections } from '../../src/db/entities/Collections.entity'; +import { Places } from '../../src/db/entities/Places.entity'; + +let orm: TestOrm; describe('Collections e2e (real auth guard + real service + temp SQLite)', () => { let server: Server; @@ -64,6 +68,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => } beforeAll(async () => { + orm = await createTestOrm(db); ownerId = createUser(db as never, { username: 'owner', email: 'owner@test.example' }).user.id; otherId = createUser(db as never, { username: 'other', email: 'other@test.example' }).user.id; createCategory(db as never); @@ -78,6 +83,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => afterAll(async () => { await app.close(); + await orm.close(); }); // ── Addon gate ─────────────────────────────────────────────────────────── @@ -128,8 +134,8 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => .set('Cookie', sessionCookie(ownerId)).send({ trip_id: tripId, place_ids: [saved.body.place.id] }); expect(copy.status).toBe(200); expect(copy.body.copied).toBe(1); - const placed = db.prepare("SELECT reservation_status FROM places WHERE trip_id = ? AND name = 'Trevi Fountain'").get(tripId) as { reservation_status: string }; - expect(placed.reservation_status).toBe('none'); // itinerary defaults + const placed = await findRow(orm, Places, { trip: tripId, name: 'Trevi Fountain' }); + expect(placed?.reservation_status).toBe('none'); // itinerary defaults }); // #2483: a place from the TREK index can carry its website without a scheme, @@ -147,9 +153,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => }); expect(saved.status).toBe(200); expect(saved.body.place.website).toBe('https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët'); - expect(db.prepare('SELECT website FROM collection_places WHERE id = ?').get(saved.body.place.id)).toEqual({ - website: 'https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët', - }); + expect((await findRow(orm, CollectionPlaces, { id: saved.body.place.id }))?.website).toBe('https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët'); }); it('COLLECTIONS-E2E-090: a script link on save is still a 400 and stores nothing', async () => { @@ -160,7 +164,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => expect(res.status).toBe(400); expect(res.body.error).toMatch(/^website: /); } - expect(db.prepare('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + expect(await countRows(orm, CollectionPlaces, { collection: col.id })).toBe(0); }); // Regression for #1437: editing a place (PATCH without a status field) must NOT @@ -171,12 +175,12 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Colosseum', status: 'want' }); expect(saved.status).toBe(200); const placeId = saved.body.place.id; - expect(db.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId)).toEqual({ status: 'want' }); + expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.status).toBe('want'); const patched = await request(server).patch(`/api/addons/collections/places/${placeId}`) .set('Cookie', sessionCookie(ownerId)).send({ name: 'Colosseo' }); expect(patched.status).toBe(200); - expect(db.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId)).toEqual({ status: 'want' }); + expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.status).toBe('want'); }); // #1870: the address was missing from the update contract, so the pipe stripped @@ -192,19 +196,19 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => .set('Cookie', sessionCookie(ownerId)).send({ address: 'Via Nuova 1' }); expect(patched.status).toBe(200); expect(patched.body.address).toBe('Via Nuova 1'); - expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: 'Via Nuova 1' }); + expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBe('Via Nuova 1'); // A rename must not wipe the address that was just corrected. const renamed = await request(server).patch(`/api/addons/collections/places/${placeId}`) .set('Cookie', sessionCookie(ownerId)).send({ name: 'Trattoria da Enzo' }); expect(renamed.status).toBe(200); - expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: 'Via Nuova 1' }); + expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBe('Via Nuova 1'); // null clears it again. const cleared = await request(server).patch(`/api/addons/collections/places/${placeId}`) .set('Cookie', sessionCookie(ownerId)).send({ address: null }); expect(cleared.status).toBe(200); - expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: null }); + expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBeNull(); }); // ── Cross-user isolation ───────────────────────────────────────────────── @@ -464,6 +468,6 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => expect((await request(server).delete(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(otherId))).status).toBe(403); expect((await request(server).delete(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(ownerId))).status).toBe(200); - expect(db.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(col.id)).toEqual({ n: 0 }); + expect(await countRows(orm, Collections, { id: col.id })).toBe(0); }); }); diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts index ae37b00ba..18abd6255 100644 --- a/server/tests/integration/memories-immich.test.ts +++ b/server/tests/integration/memories-immich.test.ts @@ -198,20 +198,41 @@ vi.mock('../../src/utils/ssrfGuard', async () => { }); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { safeFetch } from '../../src/utils/ssrfGuard'; +import { findRow, findRows, insertRow, updateRows } from '../helpers/factories/rows'; +import { readUser } from '../helpers/factories/users'; +import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity'; +import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity'; +import { TripPhotos } from '../../src/db/entities/TripPhotos.entity'; +import { Users } from '../../src/db/entities/Users.entity'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; const IMMICH = '/api/integrations/memories/immich'; +/** The trip's photo rows, each with the provider and asset id of the photo it points at. */ +async function tripPhotoAssets(tripId: number, userId?: number) { + const links = await findRows(orm, TripPhotos, userId === undefined ? { trip: tripId } : { trip: tripId, user: userId }); + const rows = []; + for (const link of links) { + const photo = await findRow(orm, TrekPhotos, { id: link.photo_id }); + if (photo) rows.push({ ...link, provider: photo.provider, asset_id: photo.asset_id }); + } + return rows; +} + beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -223,7 +244,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 }); immichState.albumAssets = DEFAULT_ALBUM_ASSETS.map((a) => ({ ...a })); immichState.albumAssetPages = null; immichState.searchCalls = []; @@ -462,11 +483,9 @@ describe('Immich asset proxy', () => { const { user: member } = createUser(testDb); // Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily) testDb.exec('PRAGMA foreign_keys = OFF'); - testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-notrip', owner.id); - const tkpNotrip = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-notrip', owner.id) as any; - testDb.prepare( - 'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)' - ).run(9999, owner.id, tkpNotrip.id, 1); + const known = await findRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-notrip', owner: owner.id }); + const photoId = known?.id ?? (await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-notrip', owner: owner.id })); + await insertRow(orm, TripPhotos, { trip: 9999, user: owner.id, photo: photoId, shared: 1 }); testDb.exec('PRAGMA foreign_keys = ON'); const res = await request(app) @@ -768,11 +787,7 @@ describe('Immich syncAlbumAssets', () => { expect(res.body.added).toBe(1); // Verify photos were inserted into the DB - const photos = testDb.prepare(` - SELECT tp.*, tkp.provider FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.trip_id = ? AND tp.user_id = ? - `).all(trip.id, user.id) as any[]; + const photos = await tripPhotoAssets(trip.id, user.id); expect(photos.length).toBeGreaterThan(0); expect(photos[0].provider).toBe('immich'); }); @@ -799,11 +814,7 @@ describe('Immich syncAlbumAssets', () => { expect(res.status).toBe(200); expect(res.body.total).toBe(1); - 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[]; + const rows = await tripPhotoAssets(trip.id); expect(rows.map((r) => r.asset_id)).toEqual(['visible-still']); }); @@ -1091,8 +1102,7 @@ describe('Immich saveImmichSettings clearing URL', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any; - expect(row.immich_url).toBeNull(); + expect((await readUser(orm, user.id)).immich_url).toBeNull(); }); it('IMMICH-096 — PUT /settings with empty string URL clears immich_url', async () => { @@ -1107,8 +1117,7 @@ describe('Immich saveImmichSettings clearing URL', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any; - expect(row.immich_url).toBeNull(); + expect((await readUser(orm, user.id)).immich_url).toBeNull(); }); }); @@ -1164,8 +1173,8 @@ describe('Immich self-signed certificate switch (#2475)', () => { return { ok: true, status: 200, url: '', headers: { get: () => null }, json: async () => json, body: null } as unknown as Response; } - function allowInsecureTls(userId: number): number { - return (testDb.prepare('SELECT immich_allow_insecure_tls AS v FROM users WHERE id = ?').get(userId) as { v: number }).v; + async function allowInsecureTls(userId: number): Promise { + return (await readUser(orm, userId)).immich_allow_insecure_tls; } beforeEach(() => { @@ -1196,20 +1205,20 @@ describe('Immich self-signed certificate switch (#2475)', () => { request(app).put(`${IMMICH}/settings`).set('Cookie', authCookie(user.id)).send(body); await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls: true }); - expect(allowInsecureTls(user.id)).toBe(1); + expect(await allowInsecureTls(user.id)).toBe(1); // What a client that predates the switch sends. await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k' }); - expect(allowInsecureTls(user.id)).toBe(1); + expect(await allowInsecureTls(user.id)).toBe(1); // A different server is a different trust decision: without the switch it starts off. await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k' }); - expect(allowInsecureTls(user.id)).toBe(0); + expect(await allowInsecureTls(user.id)).toBe(0); await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k', allow_insecure_tls: true }); - expect(allowInsecureTls(user.id)).toBe(1); + expect(await allowInsecureTls(user.id)).toBe(1); await put({ immich_url: '', immich_api_key: 'k', allow_insecure_tls: true }); - expect(allowInsecureTls(user.id)).toBe(0); + expect(await allowInsecureTls(user.id)).toBe(0); }); it('IMMICH-104: browse, search and the thumbnail proxy follow the stored switch', async () => { @@ -1225,13 +1234,13 @@ describe('Immich self-signed certificate switch (#2475)', () => { expect((await request(app).get(`${IMMICH}/assets/${trip.id}/asset-tls/${user.id}/thumbnail`).set('Cookie', cookie)).status).toBe(200); }; - testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 1 WHERE id = ?').run(user.id); + await updateRows(orm, Users, { id: user.id }, { immich_allow_insecure_tls: 1 }); await hitEveryPath(); expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3); for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toMatchObject(LAX); vi.mocked(safeFetch).mockClear(); - testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 0 WHERE id = ?').run(user.id); + await updateRows(orm, Users, { id: user.id }, { immich_allow_insecure_tls: 0 }); await hitEveryPath(); expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3); for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toMatchObject(STRICT); diff --git a/server/tests/unit/db/repositories/McpTokens.repository.test.ts b/server/tests/unit/db/repositories/McpTokens.repository.test.ts index 826d946b9..8369e36d6 100644 --- a/server/tests/unit/db/repositories/McpTokens.repository.test.ts +++ b/server/tests/unit/db/repositories/McpTokens.repository.test.ts @@ -7,6 +7,7 @@ import { createMcpToken, createUser } from '../../../helpers/factories'; import { McpTokens } from '../../../../src/db/entities/McpTokens.entity'; import type { McpTokensRepository } from '../../../../src/db/repositories/McpTokens.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; +import { countRows, findRow, updateRows } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -25,8 +26,9 @@ afterAll(async () => { testDb.close(); }); -function rawToken(id: number): unknown { - return testDb.prepare('SELECT * FROM mcp_tokens WHERE id = ?').get(id); +/** The stored row, read past the shared identity map; undefined when it is gone. */ +async function storedToken(id: number) { + return (await findRow(t, McpTokens, { id })) ?? undefined; } describe('McpTokensRepository', () => { @@ -35,9 +37,9 @@ describe('McpTokensRepository', () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const older = createMcpToken(testDb, user.id, { name: 'older', kind: 'mcp' }); - testDb.prepare("UPDATE mcp_tokens SET created_at = '2020-01-01 00:00:00' WHERE id = ?").run(older.id); + await updateRows(t, McpTokens, { id: older.id }, { created_at: '2020-01-01 00:00:00' }); const newer = createMcpToken(testDb, user.id, { name: 'newer', kind: 'mcp' }); - testDb.prepare("UPDATE mcp_tokens SET created_at = '2020-01-02 00:00:00' WHERE id = ?").run(newer.id); + await updateRows(t, McpTokens, { id: newer.id }, { created_at: '2020-01-02 00:00:00' }); createMcpToken(testDb, user.id, { name: 'an api key', kind: 'api' }); createMcpToken(testDb, other.id, { name: 'not mine', kind: 'mcp' }); @@ -56,7 +58,7 @@ describe('McpTokensRepository', () => { it('MCPTOKREPO-002b: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => { const { user } = createUser(testDb); const token = createMcpToken(testDb, user.id, { name: 'null-created', kind: 'mcp' }); - testDb.prepare('UPDATE mcp_tokens SET created_at = NULL WHERE id = ?').run(token.id); + await updateRows(t, McpTokens, { id: token.id }, { created_at: null }); const [row] = await tokens.listByUserAndKind(user.id, 'mcp'); expect(row.created_at).toBeNull(); }); @@ -90,7 +92,7 @@ describe('McpTokensRepository', () => { // else) to a caller that only asked to mint a row. `findBasic` (TK4) // stays the one place a caller reads the row back. expect(Object.keys(inserted)).toEqual(['id']); - const row = rawToken(inserted.id) as Record; + const row = (await storedToken(inserted.id)) as Record; expect(row.user_id).toBe(user.id); expect(row.name).toBe('My Token'); expect(row.token_hash).toBe('hash-value'); @@ -112,7 +114,7 @@ describe('McpTokensRepository', () => { scope_mode: 'limited', api_scopes: '["stats","trips"]', }); - const row = rawToken(inserted.id) as { scope_mode: string; api_scopes: string }; + const row = (await storedToken(inserted.id)) as { scope_mode: string; api_scopes: string }; expect(row.scope_mode).toBe('limited'); expect(row.api_scopes).toBe('["stats","trips"]'); }); @@ -176,7 +178,7 @@ describe('McpTokensRepository', () => { it('MCPTOKREPO-007c: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => { const { user } = createUser(testDb); const created = createMcpToken(testDb, user.id, { name: 'null-created-basic' }); - testDb.prepare('UPDATE mcp_tokens SET created_at = NULL WHERE id = ?').run(created.id); + await updateRows(t, McpTokens, { id: created.id }, { created_at: null }); const row = await tokens.findBasic(created.id); expect(row?.created_at).toBeNull(); }); @@ -185,7 +187,7 @@ describe('McpTokensRepository', () => { const { user } = createUser(testDb); const created = createMcpToken(testDb, user.id, { name: 'before' }); await tokens.findBasic(created.id); // populate identity map - testDb.prepare("UPDATE mcp_tokens SET name = 'after' WHERE id = ?").run(created.id); + await updateRows(t, McpTokens, { id: created.id }, { name: 'after' }); const row = await tokens.findBasic(created.id); expect(row?.name).toBe('after'); }); @@ -217,7 +219,7 @@ describe('McpTokensRepository', () => { const { user } = createUser(testDb); const created = createMcpToken(testDb, user.id); await tokens.deleteById(created.id); - expect(rawToken(created.id)).toBeUndefined(); + expect((await storedToken(created.id))).toBeUndefined(); }); }); @@ -226,9 +228,9 @@ describe('McpTokensRepository', () => { const { user: ada } = createUser(testDb, { username: 'ada' }); const { user: bob } = createUser(testDb, { username: 'bob' }); const first = createMcpToken(testDb, ada.id, { name: 'first' }); - testDb.prepare("UPDATE mcp_tokens SET created_at = '2020-01-01 00:00:00' WHERE id = ?").run(first.id); + await updateRows(t, McpTokens, { id: first.id }, { created_at: '2020-01-01 00:00:00' }); const second = createMcpToken(testDb, bob.id, { name: 'second' }); - testDb.prepare("UPDATE mcp_tokens SET created_at = '2020-06-01 00:00:00' WHERE id = ?").run(second.id); + await updateRows(t, McpTokens, { id: second.id }, { created_at: '2020-06-01 00:00:00' }); const rows = await tokens.listAllWithUsername(); expect(rows.map((r) => r.name)).toEqual(['second', 'first']); @@ -254,7 +256,7 @@ describe('McpTokensRepository', () => { it('MCPTOKREPO-012c: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => { const { user } = createUser(testDb, { username: 'null-created-user' }); const created = createMcpToken(testDb, user.id, { name: 'null-created-list' }); - testDb.prepare('UPDATE mcp_tokens SET created_at = NULL WHERE id = ?').run(created.id); + await updateRows(t, McpTokens, { id: created.id }, { created_at: null }); const rows = await tokens.listAllWithUsername(); expect(rows.find((r) => r.id === created.id)?.created_at).toBeNull(); }); @@ -349,15 +351,15 @@ describe('McpTokensRepository', () => { const { user } = createUser(testDb); const target = createMcpToken(testDb, user.id, { rawToken: 'trek_touch_me' }); const other = createMcpToken(testDb, user.id, { rawToken: 'trek_leave_me' }); - const before = rawToken(target.id) as { name: string; token_hash: string }; + const before = (await storedToken(target.id)) as { name: string; token_hash: string }; await tokens.touchLastUsedByHash(target.tokenHash); - const after = rawToken(target.id) as Record; + const after = (await storedToken(target.id)) as Record; expect(after.last_used_at).not.toBeNull(); expect(after.name).toBe(before.name); expect(after.token_hash).toBe(before.token_hash); - expect((rawToken(other.id) as { last_used_at: string | null }).last_used_at).toBeNull(); + expect(((await storedToken(other.id)) as { last_used_at: string | null }).last_used_at).toBeNull(); }); it('MCPTOKREPO-019b: an unknown hash touches nothing (no error, no row)', async () => { @@ -375,8 +377,8 @@ describe('McpTokensRepository', () => { await tokens.deleteAllForUser(user.id); - expect(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(user.id)).toEqual({ c: 0 }); - expect(rawToken(untouched.id)).toBeDefined(); + expect(await countRows(t, McpTokens, { user: user.id })).toBe(0); + expect((await storedToken(untouched.id))).toBeDefined(); }); }); @@ -455,7 +457,7 @@ describe('McpTokensRepository', () => { await tokens.touchLastUsedByHash(created.tokenHash); }); - const row = rawToken(created.id) as { last_used_at: string | null; name: string }; + const row = (await storedToken(created.id)) as { last_used_at: string | null; name: string }; expect(row.last_used_at).not.toBeNull(); expect(row.name).toBe('stays-original'); }); @@ -485,8 +487,8 @@ describe('McpTokensRepository', () => { api_scopes: null, }); - expect((rawToken(untouched.id) as { name: string }).name).toBe('do-not-flush-me'); - expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE token_hash = ?').get('insert-no-side-effect-hash')).toBeDefined(); + expect(((await storedToken(untouched.id)) as { name: string }).name).toBe('do-not-flush-me'); + expect(await findRow(t, McpTokens, { token_hash: 'insert-no-side-effect-hash' })).not.toBeNull(); }); /** @@ -524,7 +526,7 @@ describe('McpTokensRepository', () => { await t.em.nativeUpdate(McpTokens, { id: created.id }, { scope_mode: 'limited' }); }); - const row = rawToken(created.id) as { scope_mode: string; name: string }; + const row = (await storedToken(created.id)) as { scope_mode: string; name: string }; expect(row.scope_mode).toBe('limited'); expect(row.name).toBe('stays-original'); }); diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts index ded99314f..afaf2fcee 100644 --- a/server/tests/unit/mcp/tools-transit.test.ts +++ b/server/tests/unit/mcp/tools-transit.test.ts @@ -27,6 +27,10 @@ import { ReservationsService } from '../../../src/nest/reservations/reservations import type { TransitPlace } from '../../../src/nest/transit/transit.helpers'; import { TransitService } from '../../../src/nest/transit/transit.service'; import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow } from '../../helpers/factories/rows'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; // savePermissions is no longer bridged; write through a service instance — the // permissions cache is module-scoped, so the MCP _shared checkPermission path @@ -117,7 +121,25 @@ beforeEach(async () => { await invalidatePermissionsCache(); }); -afterAll(() => testDb.close()); +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); + testDb.close(); +}); + +/** The trip's day, on the given date or the first one; fails the test when there is none. */ +async function dayOf(tripId: number, date?: string) { + const day = await findRow(orm, Days, date === undefined ? { trip: tripId } : { trip: tripId, date }); + if (!day) throw new Error(`no day for trip ${tripId}`); + return day; +} + +const transitCount = () => countRows(orm, Reservations, { type: 'transit' }); async function withHarness(userId: number, scopes: string[] | null, fn: (harness: McpHarness) => Promise) { const harness = await createMcpHarness({ userId, scopes, withResources: false }); @@ -197,7 +219,7 @@ describe('MCP transit tools', () => { it('persists a selected itinerary with local dates, endpoints, and transit metadata', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' }); - const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2026-12-03') as any; + const day = await dayOf(trip.id, '2026-12-03'); await withHarness(user.id, ['reservations:write'], async (harness) => { const result = parseToolResult( await harness.client.callTool({ @@ -232,9 +254,7 @@ describe('MCP transit tools', () => { const datelessTrip = createTrip(testDb, user.id); const datelessDay = createDay(testDb, datelessTrip.id); const datedTrip = createTrip(testDb, user.id, { start_date: '2026-12-02', end_date: '2026-12-03' }); - const datedDay = testDb - .prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?') - .get(datedTrip.id, '2026-12-02') as any; + const datedDay = await dayOf(datedTrip.id, '2026-12-02'); await withHarness(user.id, ['reservations:write'], async (harness) => { const dateless = await harness.client.callTool({ name: 'create_transit_journey', @@ -259,9 +279,7 @@ describe('MCP transit tools', () => { : leg, ), }; - const startDay = testDb - .prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?') - .get(datedTrip.id, '2026-12-03') as any; + const startDay = await dayOf(datedTrip.id, '2026-12-03'); const outside = await harness.client.callTool({ name: 'create_transit_journey', arguments: { tripId: datedTrip.id, dayId: startDay.id, from, to, itinerary: nextDayItinerary }, @@ -274,7 +292,7 @@ describe('MCP transit tools', () => { it('rejects malformed provider data before persistence', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' }); - const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any; + const day = await dayOf(trip.id); const allWalk = { ...itinerary, legs: [itinerary.legs[0]] }; await withHarness(user.id, ['reservations:write'], async (harness) => { const result = await harness.client.callTool({ @@ -282,9 +300,7 @@ describe('MCP transit tools', () => { arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: allWalk }, }); expect(result.isError).toBe(true); - expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({ - count: 0, - }); + expect(await transitCount()).toBe(0); const wrongDestination = { ...itinerary, @@ -298,9 +314,7 @@ describe('MCP transit tools', () => { }); expect(mismatch.isError).toBe(true); expect((mismatch.content[0] as any).text).toContain('does not match'); - expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({ - count: 0, - }); + expect(await transitCount()).toBe(0); const invalidTime = { ...itinerary, @@ -390,9 +404,7 @@ describe('MCP transit tools', () => { arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: disconnected }, }); expect(disconnectedResult.isError).toBe(true); - expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({ - count: 0, - }); + expect(await transitCount()).toBe(0); }); }); @@ -402,7 +414,7 @@ describe('MCP transit tools', () => { const { user: stranger } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2026-12-03', end_date: '2026-12-03' }); - const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any; + const day = await dayOf(trip.id); addTripMember(testDb, trip.id, demo.id); addTripMember(testDb, trip.id, member.id); @@ -436,9 +448,7 @@ describe('MCP transit tools', () => { expect((result.content[0] as any).text).toContain('permission'); }); - expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({ - count: 0, - }); + expect(await transitCount()).toBe(0); expect(broadcastMock).not.toHaveBeenCalled(); expect(notifyBookingChangeMock).not.toHaveBeenCalled(); }); @@ -446,7 +456,7 @@ describe('MCP transit tools', () => { it('uses scheduled times when realtime stop times are unavailable', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' }); - const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any; + const day = await dayOf(trip.id); const scheduledOnly = { ...itinerary, legs: itinerary.legs.map((leg) => ({ @@ -476,7 +486,7 @@ describe('MCP transit tools', () => { it('accepts provider leg modes outside the requestable mode whitelist', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' }); - const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any; + const day = await dayOf(trip.id); const flying = { ...itinerary, legs: itinerary.legs.map((leg) => (leg.mode === 'WALK' ? leg : { ...leg, mode: 'AIRPLANE' })), diff --git a/server/tests/unit/nest/storage/storage-registry.service.test.ts b/server/tests/unit/nest/storage/storage-registry.service.test.ts index 55f11df9a..26331d34b 100644 --- a/server/tests/unit/nest/storage/storage-registry.service.test.ts +++ b/server/tests/unit/nest/storage/storage-registry.service.test.ts @@ -35,6 +35,9 @@ import { } from '../../../../src/nest/storage/storage-paths'; import { STORAGE_CATEGORIES } from '../../../../src/nest/storage/storage.types'; import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow'; +import { deleteRows, findRow } from '../../../helpers/factories/rows'; +import { setAppSetting } from '../../../helpers/factories/settings'; +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -53,8 +56,13 @@ function makeEnvStub(initial: EnvPaths): { env: { placePhotoDir: string | undefi return { env: { placePhotoDir: initial.placePhotoDir } }; } -function setSetting(key: string, value: string): void { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value); +async function setSetting(key: string, value: string): Promise { + await setAppSetting(await sharedTestOrm(testDb), key, value); +} + +/** The stored value of an app setting, undefined when there is no row. */ +async function storedSetting(key: string): Promise { + return (await findRow(await sharedTestOrm(testDb), AppSettings, { key }))?.value ?? undefined; } function uploadsOverride(root: string): unknown { @@ -78,8 +86,8 @@ interface RegistryOpts { */ async function makeRegistry(opts: RegistryOpts = {}) { const uploadsRoot = opts.uploadsRoot ?? makeTmpDir(); - setSetting('storage.backends', JSON.stringify([uploadsOverride(uploadsRoot), ...(opts.backends ?? [])])); - if (opts.categories) setSetting('storage.categories', JSON.stringify(opts.categories)); + await setSetting('storage.backends', JSON.stringify([uploadsOverride(uploadsRoot), ...(opts.backends ?? [])])); + if (opts.categories) await setSetting('storage.categories', JSON.stringify(opts.categories)); const stub = makeEnvStub({ placePhotoDir: opts.placePhotoDir }); const registry = new StorageRegistryService( await createTestAppSettingsRepo(testDb), @@ -93,17 +101,15 @@ async function makeRegistry(opts: RegistryOpts = {}) { } /** For reload tests: swap the override row's root in place. */ -function rewriteUploadsOverride(root: string): void { - const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.backends'").get() as - | { value: string } - | undefined; - const entries = row?.value ? (JSON.parse(row.value) as Array<{ name: string; options: { root: string } }>) : []; +async function rewriteUploadsOverride(root: string): Promise { + const value = await storedSetting('storage.backends'); + const entries = value ? (JSON.parse(value) as Array<{ name: string; options: { root: string } }>) : []; const next = entries.map((e) => (e.name === 'uploads-local' ? { ...e, options: { root } } : e)); - setSetting('storage.backends', JSON.stringify(next)); + await setSetting('storage.backends', JSON.stringify(next)); } -beforeEach(() => { - testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run(); +beforeEach(async () => { + await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } }); }); afterEach(() => { @@ -165,7 +171,7 @@ describe('StorageRegistryService defaults', () => { it('roots uploads-local at the computed default when no override row exists', async () => { const { registry } = await makeRegistry({ boot: false }); - testDb.prepare("DELETE FROM app_settings WHERE key = 'storage.backends'").run(); + await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: 'storage.backends' }); await registry.onModuleInit(); const files = registry.resolve('files'); expect(files.backendName).toBe('uploads-local'); @@ -316,8 +322,8 @@ describe('StorageRegistryService settings', () => { ['malformed JSON', 'not json at all', undefined], ])('falls back to built-in defaults at boot on invalid settings: %s', async (_label, backendsRow, categoriesRow) => { const { registry } = await makeRegistry({ boot: false }); - if (backendsRow !== undefined) setSetting('storage.backends', backendsRow); - if (categoriesRow !== undefined) setSetting('storage.categories', categoriesRow); + if (backendsRow !== undefined) await setSetting('storage.backends', backendsRow); + if (categoriesRow !== undefined) await setSetting('storage.categories', categoriesRow); await registry.onModuleInit(); expect(registry.resolve('files').backendName).toBe('uploads-local'); @@ -337,8 +343,8 @@ describe('StorageRegistryService settings', () => { async (_label, backendsRow, categoriesRow) => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const { registry } = await makeRegistry({ boot: false }); - if (backendsRow !== undefined) setSetting('storage.backends', backendsRow); - if (categoriesRow !== undefined) setSetting('storage.categories', categoriesRow); + if (backendsRow !== undefined) await setSetting('storage.backends', backendsRow); + if (categoriesRow !== undefined) await setSetting('storage.categories', categoriesRow); await registry.onModuleInit(); expect(registry.lastLoadError()).not.toBeNull(); @@ -357,7 +363,7 @@ describe('StorageRegistryService settings', () => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const { registry } = await makeRegistry({ boot: false }); const secretToken = 'AKIA_SUPER_SECRET_TOKEN_1234567890'; - setSetting('storage.backends', `{"secretAccessKey": ${secretToken}_undefined_broken}`); + await setSetting('storage.backends', `{"secretAccessKey": ${secretToken}_undefined_broken}`); await registry.onModuleInit(); const err = registry.lastLoadError(); @@ -370,11 +376,11 @@ describe('StorageRegistryService settings', () => { it('lastLoadError() clears on the next successful load/reload', async () => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const { registry } = await makeRegistry({ boot: false }); - setSetting('storage.categories', 'garbage {'); + await setSetting('storage.categories', 'garbage {'); await registry.onModuleInit(); expect(registry.lastLoadError()).not.toBeNull(); - setSetting('storage.categories', '{}'); + await setSetting('storage.categories', '{}'); await registry.reload(); expect(registry.lastLoadError()).toBeNull(); }); @@ -390,7 +396,7 @@ describe('StorageRegistryService settings', () => { }); expect(registry.resolve('backups').backendName).toBe('backup-mirror'); - setSetting('storage.categories', 'garbage {'); + await setSetting('storage.categories', 'garbage {'); await registry.reload(); // last-good, i.e. the mirror config — NOT the built-in defaults @@ -408,7 +414,7 @@ describe('StorageRegistryService reload', () => { const before = registry.resolve('files'); await before.driver.put('files/pre-reload.bin', Readable.from('old root')); - setUploadsRoot(makeTmpDir()); + await setUploadsRoot(makeTmpDir()); await registry.reload(); const after = registry.resolve('files'); @@ -446,17 +452,13 @@ describe('StorageRegistryService reload', () => { describe('StorageRegistryService assignCategory', () => { it('REG-ASSIGN-001 throws on an unknown backend and persists nothing (belt-and-braces alongside the migration job\'s own guard)', async () => { const { registry } = await makeRegistry(); - const before = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as - | { value: string } - | undefined; + const before = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.categories' })); await expect(registry.assignCategory('files', 'ghost-backend')).rejects.toThrow( "cannot assign 'files' to unknown backend 'ghost-backend'", ); - const after = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as - | { value: string } - | undefined; + const after = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.categories' })); expect(after).toEqual(before); // no write happened expect(registry.snapshot().categories.files.backend).toBe('uploads-local'); // unchanged }); @@ -467,10 +469,8 @@ describe('StorageRegistryService assignCategory', () => { await registry.assignCategory('files', 'dest-local'); expect(registry.snapshot().categories.files.backend).toBe('dest-local'); - const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as { - value: string; - }; - expect((JSON.parse(row.value) as Record).files).toBe('dest-local'); + const row = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.categories' })); + expect((JSON.parse(String(row?.value)) as Record).files).toBe('dest-local'); }); it('REG-ASSIGN-003 bumps the shared optimistic-concurrency version counter by exactly one, in the same write (audit #7)', async () => { @@ -483,9 +483,7 @@ describe('StorageRegistryService assignCategory', () => { await registry.assignCategory('journey', 'dest-local'); expect(await registry.currentConfigVersion()).toBe(2); - const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.config_version'").get() as - | { value: string } - | undefined; + const row = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.config_version' })); expect(row?.value).toBe('2'); }); @@ -565,10 +563,8 @@ describe('StorageRegistryService assignCategory', () => { // not by this call) is byte-unchanged — no 'files' key was added. expect(registry.snapshot().categories.files.backend).toBe('uploads-local'); expect(await registry.currentConfigVersion()).toBe(0); - const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as { - value: string; - }; - expect(JSON.parse(row.value)).toEqual({ backups: 'm-backups' }); + const row = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.categories' })); + expect(JSON.parse(String(row?.value))).toEqual({ backups: 'm-backups' }); }); }); @@ -824,11 +820,8 @@ describe('seed-once storage-config.json import', () => { ); } - function readRow(key: string): string | undefined { - const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as - | { value: string } - | undefined; - return row?.value; + function readRow(key: string): Promise { + return storedSetting(key); } afterEach(() => { @@ -865,13 +858,13 @@ describe('seed-once storage-config.json import', () => { await registry.onModuleInit(); expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver); - const storedBackends = JSON.parse(readRow('storage.backends')!) as Array<{ + const storedBackends = JSON.parse((await readRow('storage.backends'))!) as Array<{ name: string; options: Record; }>; const offBox = storedBackends.find((b) => b.name === 'off-box')!; expect(String(offBox.options.secretAccessKey).startsWith('enc:v1:')).toBe(true); // never plaintext at rest - expect(readRow('storage.categories')).toBe(JSON.stringify({ backups: 'off-box' })); + expect((await readRow('storage.categories'))).toBe(JSON.stringify({ backups: 'off-box' })); const logged = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); expect(logged).toContain('storage config seeded from'); expect(logged).toContain('the file is now ignored; manage storage in the admin UI'); @@ -890,7 +883,7 @@ describe('seed-once storage-config.json import', () => { const registry = await makeUnseededRegistry(); await registry.onModuleInit(); expect(registry.resolve('backups').backendName).toBe('backups-local'); - expect(readRow('storage.backends')).toBeUndefined(); + expect((await readRow('storage.backends'))).toBeUndefined(); }); it('SEED-004 unparseable JSON aborts boot with the exact error', async () => { @@ -933,7 +926,7 @@ describe('seed-once storage-config.json import', () => { await registry.onModuleInit(); // No key-presence gate: the implicit key covers encryption when // ENCRYPTION_KEY is unset, and the plaintext never persists. - const row = readRow('storage.backends')!; + const row = (await readRow('storage.backends'))!; expect(row).not.toContain('sk-seed'); expect(row).toContain('enc:v1:'); }); @@ -963,12 +956,12 @@ describe('seed-once storage-config.json import', () => { const root = nasRoot(); writeSeed(JSON.stringify({ backends: [{ name: 'nas', type: 'local', options: { root } }], categories: { backups: 'nas' } })); await (await makeUnseededRegistry()).onModuleInit(); - const firstRow = readRow('storage.backends'); + const firstRow = (await readRow('storage.backends')); writeSeed(JSON.stringify({ backends: [], categories: { backups: 'other' } })); // would fail preview if read const second = await makeUnseededRegistry(); await second.onModuleInit(); // must not throw — rows exist, file ignored - expect(readRow('storage.backends')).toBe(firstRow); + expect((await readRow('storage.backends'))).toBe(firstRow); expect(second.resolve('backups').backendName).toBe('nas'); }); @@ -996,7 +989,7 @@ describe('seed-once storage-config.json import', () => { // enc:v1: values pass through the idempotent encrypt — the ciphertext // must persist byte-for-byte. expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver); - expect(readRow('storage.backends')).toContain(cipher); + expect((await readRow('storage.backends'))).toContain(cipher); }); }); @@ -1028,7 +1021,7 @@ describe('snapshot()', () => { it('reports pure built-in defaults when no settings rows exist', async () => { const { registry } = await makeRegistry({ boot: false }); - testDb.prepare("DELETE FROM app_settings WHERE key = 'storage.backends'").run(); + await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: 'storage.backends' }); await registry.onModuleInit(); const snap = registry.snapshot(); expect(snap.backends.map((b) => [b.name, b.source])).toEqual([ @@ -1063,7 +1056,7 @@ describe('snapshot()', () => { vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); const { registry } = await makeRegistry(); const before = registry.snapshot(); - setSetting('storage.categories', 'garbage {'); + await setSetting('storage.categories', 'garbage {'); await registry.reload(); expect(registry.snapshot()).toEqual(before); });