diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index e3e69a6b6..62ca2f0d6 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -20,13 +20,15 @@ vi.mock('../../../src/nest/plugins/install/safe-fetch', async (orig) => ({ // `discoverPlugins`/`setUpdateBlock`/`clearUpdateBlock` calls are all // repository-backed now — no more raw `DatabaseService`/`db/database` mock. // A real MikroORM over the full migrated schema (`createSnapshotTestDb` + -// `createTestOrm`) replaces the old hand-rolled `:memory:` table set; every -// `testDb.prepare(...)` fixture/assertion below still works unchanged since -// the real schema is a strict superset of the old one's columns. +// `createTestOrm`) replaces the old hand-rolled `:memory:` table set; the +// fixtures and read-backs below go through the same ORM (the factories in +// tests/helpers/factories), since the real schema is a strict superset of +// the old one's columns. import { createSnapshotTestDb } from '../../helpers/db-mock'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; +import { findRow, insertRow, updateRows } from '../../helpers/factories/rows'; import { PluginActions } from '../../../src/db/entities/PluginActions.entity'; import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity'; import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity'; @@ -80,6 +82,13 @@ const REGISTRY = { const testDb = createSnapshotTestDb(); let t: TestOrm; +/** The plugin's stored row; fails the case when there is none. */ +async function pluginRow(id: string) { + const row = await findRow(t, Plugins, { id }); + if (!row) throw new Error(`no plugins row for ${id}`); + return row; +} + let dataRoot: string; let codeRoot: string; let svc: PluginRegistryService; @@ -287,9 +296,8 @@ describe('PluginRegistryService', () => { ], }; const seedRow = (hold = 0) => - testDb.prepare("INSERT INTO plugins (id, name, status, enabled, update_hold) VALUES ('flight-tracker','Flight','inactive',0,?)").run(hold); - const holdInDb = () => - (testDb.prepare("SELECT update_hold FROM plugins WHERE id='flight-tracker'").get() as { update_hold: number }).update_hold; + insertRow(t, Plugins, { id: 'flight-tracker', name: 'Flight', status: 'inactive', enabled: 0, update_hold: hold }); + const holdInDb = async () => (await pluginRow('flight-tracker')).update_hold; beforeEach(() => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => twoVersions }) as unknown as Response)); @@ -297,15 +305,15 @@ describe('PluginRegistryService', () => { }); it('holds when the admin explicitly picked an older version', async () => { - seedRow(); + await seedRow(); await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(true); - expect(holdInDb()).toBe(1); + expect(await holdInDb()).toBe(1); }); it('does not hold when the explicit pick IS the newest compatible version', async () => { - seedRow(1); + await seedRow(1); await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false); - expect(holdInDb()).toBe(0); + expect(await holdInDb()).toBe(0); }); // The comparison is against the newest version THIS TREK can run, not the newest @@ -330,26 +338,26 @@ describe('PluginRegistryService', () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => withIncompatibleNewest }) as unknown as Response)); __clearRegistryCacheForTests(); try { - seedRow(1); + await seedRow(1); await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false); - expect(holdInDb()).toBe(0); + expect(await holdInDb()).toBe(0); } finally { delete process.env.APP_VERSION; } }); it('a non-deliberate update clears a stale hold', async () => { - seedRow(1); + await seedRow(1); await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', false)).resolves.toBe(false); - expect(holdInDb()).toBe(0); + expect(await holdInDb()).toBe(0); }); it('an unresolvable registry never sets a hold', async () => { - seedRow(); + await seedRow(); vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); __clearRegistryCacheForTests(); await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(false); - expect(holdInDb()).toBe(0); + expect(await holdInDb()).toBe(0); }); }); @@ -403,7 +411,7 @@ describe('PluginRegistryService', () => { // moved into place + registered inactive with provenance expect(fs.existsSync(path.join(codeRoot, 'flight-tracker', 'trek-plugin.json'))).toBe(true); - const row = testDb.prepare("SELECT status, source_repo, source_commit FROM plugins WHERE id='flight-tracker'").get() as { status: string; source_repo: string; source_commit: string }; + const row = await pluginRow('flight-tracker'); expect(row).toMatchObject({ status: 'inactive', source_repo: 'acme/trek-flight', source_commit: 'a'.repeat(40) }); // no staging left behind const staging = path.join(dataRoot, '.staging'); @@ -451,8 +459,7 @@ describe('PluginRegistryService', () => { await svc.commitUpload(staged); - const row = testDb.prepare('SELECT status, source_repo, reviewed_at, version FROM plugins WHERE id = ?').get('my-upload') as - { status: string; source_repo: string | null; reviewed_at: string | null; version: string } | undefined; + const row = await findRow(t, Plugins, { id: 'my-upload' }); expect(row?.status).toBe('inactive'); // never auto-activates expect(row?.source_repo).toBe('local:upload'); expect(row?.reviewed_at).toBeNull(); // unsigned + unreviewed → flagged in the UI @@ -473,9 +480,9 @@ describe('PluginRegistryService', () => { it('sideload: forces INACTIVE even when replacing a plugin that was active', async () => { const zip = () => makeArtifact({ id: 'my-upload', name: 'Uploaded', version: '2.0.0', type: 'widget', permissions: ['db:own'] }); await svc.commitUpload(svc.stageUpload(zip())); // first install - testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'my-upload'").run(); // admin activated it + await updateRows(t, Plugins, { id: 'my-upload' }, { status: 'active', enabled: 1 }); // admin activated it await svc.commitUpload(svc.stageUpload(zip())); // re-upload replaces the code - const row = testDb.prepare('SELECT status, enabled FROM plugins WHERE id = ?').get('my-upload') as { status: string; enabled: number }; + const row = await pluginRow('my-upload'); expect(row.status).toBe('inactive'); // discoverPlugins keeps the old status; commitUpload floors it back to inactive expect(row.enabled).toBe(0); }); @@ -509,7 +516,7 @@ describe('PluginRegistryService', () => { const k = signingKey(); stageSignedArtifact(k.pubB64, k.sign); await expect(svc.install('flight-tracker')).resolves.toEqual({ id: 'flight-tracker', version: '1.0.0', trekRangeBypassed: null }); - const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string }; + const row = await pluginRow('flight-tracker'); expect(row.author_pubkey).toBe(k.pubB64); }); @@ -637,7 +644,7 @@ describe('signature failure codes', () => { stageSignedArtifact(b.pubB64, b.sign); await expect(svc.install('flight-tracker')).rejects.toThrow(); - const blocked = testDb.prepare("SELECT * FROM plugins WHERE id='flight-tracker'").get() as Record; + const blocked = await pluginRow('flight-tracker'); expect(blocked.update_block_code).toBe('SIGNATURE_KEY_CHANGED'); expect(blocked.update_block_version).toBe('1.0.0'); // The plugin still RUNS fine on its old code — a blocked update is not a broken @@ -649,7 +656,7 @@ describe('signature failure codes', () => { // The author reverts to the original key: the install succeeds and the block goes. stageSignedArtifact(a.pubB64, a.sign); await svc.install('flight-tracker'); - const after = testDb.prepare("SELECT * FROM plugins WHERE id='flight-tracker'").get() as Record; + const after = await pluginRow('flight-tracker'); expect(after.update_block_code).toBeNull(); expect(after.update_block_version).toBeNull(); }); @@ -688,7 +695,7 @@ describe('re-trust (assertRetrustable)', () => { async function rotateTo(b: { pubB64: string; sign: (x: Buffer) => string }, a = signingKey()) { stageSignedArtifact(a.pubB64, a.sign); await svc.install('flight-tracker'); - testDb.prepare("UPDATE plugins SET source_repo='acme/trek-flight' WHERE id='flight-tracker'").run(); + await updateRows(t, Plugins, { id: 'flight-tracker' }, { source_repo: 'acme/trek-flight' }); stageSignedArtifact(b.pubB64, b.sign); return a; } @@ -716,7 +723,7 @@ describe('re-trust (assertRetrustable)', () => { const a = signingKey(); stageSignedArtifact(a.pubB64, a.sign); await svc.install('flight-tracker'); - testDb.prepare("UPDATE plugins SET source_repo='acme/trek-flight' WHERE id='flight-tracker'").run(); + await updateRows(t, Plugins, { id: 'flight-tracker' }, { source_repo: 'acme/trek-flight' }); await expect(svc.assertRetrustable('flight-tracker', a.pubB64)).rejects.toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' }); }); @@ -724,7 +731,7 @@ describe('re-trust (assertRetrustable)', () => { it('refuses a sideloaded plugin (it sits outside the registry trust model entirely)', async () => { const b = signingKey(); await rotateTo(b); - testDb.prepare("UPDATE plugins SET source_repo='local:upload' WHERE id='flight-tracker'").run(); + await updateRows(t, Plugins, { id: 'flight-tracker' }, { source_repo: 'local:upload' }); await expect(svc.assertRetrustable('flight-tracker', b.pubB64)).rejects.toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' }); }); @@ -742,7 +749,7 @@ describe('re-trust (assertRetrustable)', () => { code: 'SIGNATURE_INVALID', }); // The old key is STILL pinned — a refused re-trust changes nothing. - const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string }; + const row = await pluginRow('flight-tracker'); expect(row.author_pubkey).toBe(a.pubB64); }); @@ -752,10 +759,7 @@ describe('re-trust (assertRetrustable)', () => { __clearRegistryCacheForTests(); await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: b.pubB64 })).resolves.toMatchObject({ version: '1.0.0' }); - const row = testDb.prepare("SELECT author_pubkey, update_block_code FROM plugins WHERE id='flight-tracker'").get() as { - author_pubkey: string | null; - update_block_code: string | null; - }; + const row = await pluginRow('flight-tracker'); expect(row.author_pubkey).toBe(b.pubB64); expect(row.author_pubkey).not.toBeNull(); expect(row.update_block_code).toBeNull(); @@ -778,7 +782,7 @@ describe('re-trust (assertRetrustable)', () => { await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: 'anything' })).rejects.toMatchObject({ code: 'SIGNATURE_MISSING', }); - const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string | null }; + const row = await pluginRow('flight-tracker'); expect(row.author_pubkey).toBe(a.pubB64); // still pinned, never NULL }); }); @@ -1081,16 +1085,13 @@ describe('an update block does not outlive the registry relationship', () => { const b = signingKey(); stageSignedArtifact(b.pubB64, b.sign); await expect(svc.install('flight-tracker')).rejects.toThrow(); // rotated key → blocked - expect( - (testDb.prepare("SELECT update_block_code FROM plugins WHERE id='flight-tracker'").get() as { update_block_code: string }) - .update_block_code, - ).toBe('SIGNATURE_KEY_CHANGED'); + expect((await pluginRow('flight-tracker')).update_block_code).toBe('SIGNATURE_KEY_CHANGED'); // The admin now uploads the plugin by hand. const upload = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '9.9.9', type: 'widget', permissions: ['db:own'] }); await svc.commitUpload(svc.stageUpload(upload)); - const row = testDb.prepare("SELECT source_repo, author_pubkey, update_block_code, update_block_version FROM plugins WHERE id='flight-tracker'").get() as Record; + const row = await pluginRow('flight-tracker'); expect(row.source_repo).toBe('local:upload'); expect(row.author_pubkey).toBeNull(); // out of the trust model — deliberate, and badged expect(row.update_block_code).toBeNull(); diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index 22f7e6243..c1ab8930a 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -42,6 +42,13 @@ import type { JourneyDomainService } from '../../../src/nest/journey/journey-dom import type { DawarichVisitSuggestionsRepository } from '../../../src/db/repositories/DawarichVisitSuggestions.repository'; import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; import { BucketList } from '../../../src/db/entities/BucketList.entity'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { DawarichVisitSuggestions } from '../../../src/db/entities/DawarichVisitSuggestions.entity'; +import { Journeys } from '../../../src/db/entities/Journeys.entity'; +import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity'; +import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; import type { PlacesRepository } from '../../../src/db/repositories/Places.repository'; import type { BucketListRepository } from '../../../src/db/repositories/BucketList.repository'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; @@ -137,8 +144,10 @@ const CREATED_ENTRY_ID = 7702; * the acceptance fail on the constraint instead of on anything a case is about. */ function insertPlaceRow(tripId: string, body: { name?: string; lat?: number; lng?: number }): { id: number } { + // test-sql-allow: runs inside the service's open transaction, on the connection that transaction holds. const category = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined; const result = testDb + // test-sql-allow: runs inside the service's open transaction, on the connection that transaction holds. .prepare('INSERT INTO places (trip_id, name, lat, lng, category_id) VALUES (?, ?, ?, ?, ?)') .run(Number(tripId), body.name ?? 'Imported stay', body.lat ?? null, body.lng ?? null, category?.id ?? null); return { id: Number(result.lastInsertRowid) }; @@ -167,8 +176,8 @@ function armStubs(): void { assignmentsStub.dayExists .mockReset() .mockImplementation( - (dayId: unknown, tripId: unknown) => - !!testDb.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId), + async (dayId: unknown, tripId: unknown) => + !!(await findRow(t, Days, { id: Number(dayId), trip: Number(tripId) })), ); placesStub.broadcast.mockReset(); placesStub.onCreated.mockReset(); @@ -198,56 +207,33 @@ interface SuggestionSeed { let _visitSeq = 0; -function seedSuggestion(seed: SuggestionSeed): number { +async function seedSuggestion(seed: SuggestionSeed): Promise { _visitSeq++; - const result = testDb - .prepare( - `INSERT INTO dawarich_visit_suggestions - (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at, - duration_minutes, local_date, state, matched_bucket_list_item_id, source_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - seed.userId, - `visit-${_visitSeq}`, - seed.tripId ?? null, - seed.name ?? 'Cafe Central', - seed.lat === undefined ? 48.2092 : seed.lat, - seed.lng === undefined ? 16.3658 : seed.lng, - seed.startedAt ?? '2026-09-01T09:30:00Z', - seed.endedAt ?? '2026-09-01T11:45:00Z', - seed.durationMinutes ?? 135, - seed.localDate ?? '2026-09-01', - seed.state ?? 'new', - seed.matchedBucketListItemId ?? null, - seed.sourceHash ?? `hash-${_visitSeq}`, - ); - return Number(result.lastInsertRowid); + return insertRow(t, DawarichVisitSuggestions, { + user: seed.userId, + source_visit_id: `visit-${_visitSeq}`, + trip: seed.tripId ?? null, + name: seed.name ?? 'Cafe Central', + lat: seed.lat === undefined ? 48.2092 : seed.lat, + lng: seed.lng === undefined ? 16.3658 : seed.lng, + started_at: seed.startedAt ?? '2026-09-01T09:30:00Z', + ended_at: seed.endedAt ?? '2026-09-01T11:45:00Z', + duration_minutes: seed.durationMinutes ?? 135, + local_date: seed.localDate ?? '2026-09-01', + state: seed.state ?? 'new', + matchedBucketListItem: seed.matchedBucketListItemId ?? null, + source_hash: seed.sourceHash ?? `hash-${_visitSeq}`, + }); } -interface SuggestionRow { - id: number; - state: string; - target: string | null; - accepted_place_id: number | null; - accepted_journal_entry_id: number | null; - accepted_bucket_list_item_id: number | null; - source_hash: string; - accepted_hash: string | null; +/** The stored suggestion; fails the case when it is gone. */ +async function rowOf(id: number) { + const row = await findRow(t, DawarichVisitSuggestions, { id }); + if (!row) throw new Error(`no suggestion ${id}`); + return row; } -function rowOf(id: number): SuggestionRow { - return testDb.prepare('SELECT * FROM dawarich_visit_suggestions WHERE id = ?').get(id) as SuggestionRow; -} - -interface BucketRow { - id: number; - name: string; - visited_at: string | null; - visited_source: string | null; -} - -function seedBucketItem( +async function seedBucketItem( userId: number, overrides: Partial<{ name: string; @@ -256,24 +242,22 @@ function seedBucketItem( visitedAt: string | null; visitedSource: string | null; }> = {}, -): number { - const result = testDb - .prepare( - 'INSERT INTO bucket_list (user_id, name, lat, lng, visited_at, visited_source) VALUES (?, ?, ?, ?, ?, ?)', - ) - .run( - userId, - overrides.name ?? 'Zell am See', - overrides.lat === undefined ? 47.3232 : overrides.lat, - overrides.lng === undefined ? 12.7981 : overrides.lng, - overrides.visitedAt ?? null, - overrides.visitedSource ?? null, - ); - return Number(result.lastInsertRowid); +): Promise { + return insertRow(t, BucketList, { + user: userId, + name: overrides.name ?? 'Zell am See', + lat: overrides.lat === undefined ? 47.3232 : overrides.lat, + lng: overrides.lng === undefined ? 12.7981 : overrides.lng, + visited_at: overrides.visitedAt ?? null, + visited_source: overrides.visitedSource ?? null, + }); } -function bucketOf(id: number): BucketRow { - return testDb.prepare('SELECT * FROM bucket_list WHERE id = ?').get(id) as BucketRow; +/** The stored wish; fails the case when it is gone. */ +async function bucketOf(id: number) { + const row = await findRow(t, BucketList, { id }); + if (!row) throw new Error(`no bucket list item ${id}`); + return row; } /** @@ -311,8 +295,8 @@ describe('DawarichSuggestionsService — the review list', () => { it("DAWARICH-SUG-001: list() is scoped in SQL — a stranger's suggestion never appears", async () => { const { user: mine } = createUser(testDb); const { user: stranger } = createUser(testDb); - const mineId = seedSuggestion({ userId: mine.id, name: 'Volksgarten' }); - const strangerId = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' }); + const mineId = await seedSuggestion({ userId: mine.id, name: 'Volksgarten' }); + const strangerId = await seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' }); const list = await svc.list(mine.id, {}); @@ -329,10 +313,10 @@ describe('DawarichSuggestionsService — the review list', () => { const otherTrip = createTrip(testDb, user.id); const strangerTrip = createTrip(testDb, stranger.id); - const onTrip = seedSuggestion({ userId: user.id, tripId: trip.id, startedAt: '2026-09-03T08:00:00Z' }); - const dismissed = seedSuggestion({ userId: user.id, tripId: trip.id, state: 'dismissed' }); - seedSuggestion({ userId: user.id, tripId: otherTrip.id }); - seedSuggestion({ userId: stranger.id, tripId: strangerTrip.id }); + const onTrip = await seedSuggestion({ userId: user.id, tripId: trip.id, startedAt: '2026-09-03T08:00:00Z' }); + const dismissed = await seedSuggestion({ userId: user.id, tripId: trip.id, state: 'dismissed' }); + await seedSuggestion({ userId: user.id, tripId: otherTrip.id }); + await seedSuggestion({ userId: stranger.id, tripId: strangerTrip.id }); const byTrip = (await svc.list(user.id, { tripId: trip.id })) .suggestions.map((s) => s.id) @@ -348,7 +332,7 @@ describe('DawarichSuggestionsService — the review list', () => { it('DAWARICH-SUG-003: list() carries the connection state so a panel needs no second request', async () => { const { user } = createUser(testDb); - seedSuggestion({ userId: user.id }); + await seedSuggestion({ userId: user.id }); const list = await svc.list(user.id, {}); @@ -362,7 +346,7 @@ describe('DawarichSuggestionsService — the review list', () => { it('DAWARICH-SUG-004: getOne() answers null for a suggestion that belongs to somebody else', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const theirs = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' }); + const theirs = await seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' }); expect(await svc.getOne(user.id, theirs)).toBeNull(); expect((await svc.getOne(stranger.id, theirs))?.name).toBe('Their Kitchen'); @@ -370,22 +354,19 @@ describe('DawarichSuggestionsService — the review list', () => { it('DAWARICH-SUG-059: the flags the panel warns with are read off the row, never acted on', async () => { const { user } = createUser(testDb); - const wish = seedBucketItem(user.id); - const id = seedSuggestion({ userId: user.id }); + const wish = await seedBucketItem(user.id); + const id = await seedSuggestion({ userId: user.id }); await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); // Everything a later sync is allowed to do to a row somebody already // accepted: the detector re-ran and the hash moved, it upgraded the stay // from suggested to confirmed, and then the visit disappeared from // Dawarich altogether. The tick the user made stands through all of it. - testDb - .prepare( - `UPDATE dawarich_visit_suggestions - SET source_hash = 'hash-after-redetection', source_status = 'confirmed', - source_missing_at = '2026-09-07T00:00:00Z' - WHERE id = ?`, - ) - .run(id); + await updateRows(t, DawarichVisitSuggestions, { id }, { + source_hash: 'hash-after-redetection', + source_status: 'confirmed', + source_missing_at: '2026-09-07T00:00:00Z', + }); const wire = (await svc.getOne(user.id, id))!; expect(wire.sourceStatus).toBe('confirmed'); @@ -404,14 +385,14 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const strangerTrip = createTrip(testDb, stranger.id); - const id = seedSuggestion({ userId: user.id }); + const id = await seedSuggestion({ userId: user.id }); const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: strangerTrip.id })); expect(err.status).toBe(404); expect(err.code).toBe('not_found'); expect(placesStub.create).not.toHaveBeenCalled(); - expect(rowOf(id).state).toBe('new'); + expect((await rowOf(id)).state).toBe('new'); }); it('DAWARICH-SUG-006: a day from another trip is a 400 — trip access is not day access', async () => { @@ -419,7 +400,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { const trip = createTrip(testDb, user.id); const otherTrip = createTrip(testDb, user.id); const foreignDay = createDay(testDb, otherTrip.id, { date: '2026-09-01' }); - const id = seedSuggestion({ userId: user.id }); + const id = await seedSuggestion({ userId: user.id }); const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: foreignDay.id }), @@ -431,7 +412,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { expect(assignmentsStub.dayExists).toHaveBeenCalledWith(foreignDay.id, trip.id); expect(placesStub.create).not.toHaveBeenCalled(); expect(assignmentsStub.createAssignment).not.toHaveBeenCalled(); - expect(rowOf(id).state).toBe('new'); + expect((await rowOf(id)).state).toBe('new'); }); it('DAWARICH-SUG-007: a stay on a trip the caller is a member of becomes a place pinned to the day', async () => { @@ -440,7 +421,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); - const id = seedSuggestion({ userId: member.id, tripId: trip.id, name: 'Cafe Central' }); + const id = await seedSuggestion({ userId: member.id, tripId: trip.id, name: 'Cafe Central' }); const result = await svc.accept(member.id, id, { target: 'place', @@ -467,7 +448,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { expect(result.createdJournalEntryId).toBeNull(); expect(result.suggestion.state).toBe('accepted'); - const row = rowOf(id); + const row = await rowOf(id); expect(row.target).toBe('place'); expect(row.accepted_place_id).toBe(placeId); // The hash is frozen at acceptance, which is what makes "changed since" answerable. @@ -488,16 +469,17 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { // the write", not merely "changed before either ran". const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id, sourceHash: 'hash-at-accept-start' }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id, sourceHash: 'hash-at-accept-start' }); placesStub.create.mockImplementationOnce((tripId: string, body: { name?: string; lat?: number; lng?: number }) => { + // test-sql-allow: runs inside the service's open transaction, on the connection that transaction holds. testDb.prepare('UPDATE dawarich_visit_suggestions SET source_hash = ? WHERE id = ?').run('hash-changed-concurrently', id); return insertPlaceRow(tripId, body); }); await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); - const row = rowOf(id); + const row = await rowOf(id); expect(row.source_hash).toBe('hash-changed-concurrently'); // A bound-at-read-time literal would have frozen 'hash-at-accept-start' // here instead — the whole point of the column-ref form. @@ -507,7 +489,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { it('DAWARICH-SUG-008: a stay with no trip anywhere is a 400 rather than a place nobody can see', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id, tripId: null }); + const id = await seedSuggestion({ userId: user.id, tripId: null }); const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place' })); @@ -522,7 +504,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { // the whole form decorative. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Unnamed stay' }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Unnamed stay' }); await svc.accept(user.id, id, { target: 'place', @@ -556,7 +538,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { // coast of Africa with a duration of "0 min" is worse than one with none. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id, lat: null, lng: null, durationMinutes: 0 }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id, lat: null, lng: null, durationMinutes: 0 }); await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); @@ -569,7 +551,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { it('DAWARICH-SUG-045: with no trip in the body, the one the sync filed the stay under is used', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id }); const result = await svc.accept(user.id, id, { target: 'place' }); @@ -584,7 +566,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { // time field. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ + const id = await seedSuggestion({ userId: user.id, tripId: trip.id, startedAt: '2026-09-01', @@ -603,7 +585,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, user.id); - const theirs = seedSuggestion({ userId: stranger.id }); + const theirs = await seedSuggestion({ userId: stranger.id }); const err = await asyncRefusalFrom(() => svc.accept(user.id, theirs, { target: 'place', tripId: trip.id })); @@ -618,7 +600,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { describe('DawarichSuggestionsService — accepting into a journey', () => { it('DAWARICH-SUG-010: a journey the caller may not edit refuses before createEntry is reached', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id }); + const id = await seedSuggestion({ userId: user.id }); journeyStub.canEdit.mockReturnValue(false); const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 4242 })); @@ -627,12 +609,12 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { expect(err.status).toBe(404); expect(err.code).toBe('journal_forbidden'); expect(journeyStub.createEntry).not.toHaveBeenCalled(); - expect(rowOf(id).state).toBe('new'); + expect((await rowOf(id)).state).toBe('new'); }); it('DAWARICH-SUG-011: a permitted journey gets a dated entry, with the socket id passed through', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' }); + const id = await seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' }); const result = await svc.accept(user.id, id, { target: 'journal', journalId: 88 }, 'socket-7'); @@ -653,7 +635,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { expect(result.createdJournalEntryId).toBe(CREATED_ENTRY_ID); expect(result.createdPlaceId).toBeNull(); - const row = rowOf(id); + const row = await rowOf(id); expect(row.state).toBe('accepted'); expect(row.target).toBe('journal'); expect(row.accepted_journal_entry_id).toBe(CREATED_ENTRY_ID); @@ -661,7 +643,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { it('DAWARICH-SUG-012: a journey id missing from the body is a 400, not a guess', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id }); + const id = await seedSuggestion({ userId: user.id }); const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal' })); @@ -672,7 +654,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { it('DAWARICH-SUG-047: a corrected entry carries the corrections, not the recording', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' }); + const id = await seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' }); await svc.accept(user.id, id, { target: 'journal', @@ -706,7 +688,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { // (a location_lat of null would put a pin on the map at the equator), so // everything unknown has to arrive as undefined rather than as null. const { user } = createUser(testDb); - const id = seedSuggestion({ + const id = await seedSuggestion({ userId: user.id, lat: null, lng: null, @@ -726,13 +708,13 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { it('DAWARICH-SUG-013: a journey domain that returns nothing leaves the suggestion in review', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id }); + const id = await seedSuggestion({ userId: user.id }); journeyStub.createEntry.mockReturnValue(null); const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 12 })); expect(err.status).toBe(404); - expect(rowOf(id).state).toBe('new'); + expect((await rowOf(id)).state).toBe('new'); }); }); @@ -742,8 +724,8 @@ describe('DawarichSuggestionsService — ticking off a wish', () => { it('DAWARICH-SUG-014: a wish owned by somebody else is a 404 and stays untouched', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const theirWish = seedBucketItem(stranger.id, { name: 'Their Hallstatt' }); - const id = seedSuggestion({ userId: user.id }); + const theirWish = await seedBucketItem(stranger.id, { name: 'Their Hallstatt' }); + const id = await seedSuggestion({ userId: user.id }); const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: theirWish }), @@ -751,39 +733,39 @@ describe('DawarichSuggestionsService — ticking off a wish', () => { expect(err.status).toBe(404); expect(err.code).toBe('not_found'); - expect(bucketOf(theirWish).visited_at).toBeNull(); - expect(rowOf(id).state).toBe('new'); + expect((await bucketOf(theirWish)).visited_at).toBeNull(); + expect((await rowOf(id)).state).toBe('new'); }); it("DAWARICH-SUG-015: the caller's own wish is ticked with the stay's arrival and marked as imported", async () => { const { user } = createUser(testDb); - const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); - const id = seedSuggestion({ userId: user.id, startedAt: '2026-09-01T09:30:00Z' }); + const wish = await seedBucketItem(user.id, { name: 'Hallstatt' }); + const id = await seedSuggestion({ userId: user.id, startedAt: '2026-09-01T09:30:00Z' }); const result = await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); - const item = bucketOf(wish); + const item = await bucketOf(wish); expect(item.visited_at).toBe('2026-09-01T09:30:00Z'); expect(item.visited_source).toBe('dawarich'); expect(result.bucketListItemId).toBe(wish); expect(result.createdPlaceId).toBeNull(); - expect(rowOf(id).accepted_bucket_list_item_id).toBe(wish); + expect((await rowOf(id)).accepted_bucket_list_item_id).toBe(wish); }); it('DAWARICH-SUG-016: with no wish named in the body, the matched one is used', async () => { const { user } = createUser(testDb); - const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); - const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: wish }); + const wish = await seedBucketItem(user.id, { name: 'Hallstatt' }); + const id = await seedSuggestion({ userId: user.id, matchedBucketListItemId: wish }); const result = await svc.accept(user.id, id, { target: 'bucket_list' }); expect(result.bucketListItemId).toBe(wish); - expect(bucketOf(wish).visited_source).toBe('dawarich'); + expect((await bucketOf(wish)).visited_source).toBe('dawarich'); }); it('DAWARICH-SUG-017: with nothing named and nothing matched it is a 400', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: null }); + const id = await seedSuggestion({ userId: user.id, matchedBucketListItemId: null }); const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'bucket_list' })); @@ -798,7 +780,7 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => { it('DAWARICH-SUG-018: accepting an already accepted suggestion is a 409, not a second place', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id }); const first = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); expect(placesStub.create).toHaveBeenCalledTimes(1); @@ -809,28 +791,28 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => { expect(err.code).toBe('already_accepted'); expect(placesStub.create).toHaveBeenCalledTimes(1); // Still pointing at the first place, so the refusal cost nothing and created nothing. - expect(rowOf(id).accepted_place_id).toBe(first.createdPlaceId); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 }); + expect((await rowOf(id)).accepted_place_id).toBe(first.createdPlaceId); + expect(await countRows(t, Places, { trip: trip.id })).toBe(1); }); it('DAWARICH-SUG-019: setState cannot pull an accepted suggestion back into review', async () => { const { user } = createUser(testDb); - const wish = seedBucketItem(user.id); - const id = seedSuggestion({ userId: user.id }); + const wish = await seedBucketItem(user.id); + const id = await seedSuggestion({ userId: user.id }); await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); const back = await svc.setState(user.id, id, 'new'); expect(back?.state).toBe('accepted'); - expect(rowOf(id).state).toBe('accepted'); + expect((await rowOf(id)).state).toBe('accepted'); // Dismissing it is refused the same way — the tick it produced is its own thing now. expect((await svc.setState(user.id, id, 'dismissed'))?.state).toBe('accepted'); - expect(rowOf(id).target).toBe('bucket_list'); + expect((await rowOf(id)).target).toBe('bucket_list'); }); it('DAWARICH-SUG-020: a suggestion still in review dismisses and restores', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id }); + const id = await seedSuggestion({ userId: user.id }); expect((await svc.setState(user.id, id, 'dismissed'))?.state).toBe('dismissed'); expect((await svc.setState(user.id, id, 'new'))?.state).toBe('new'); @@ -839,10 +821,10 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => { it("DAWARICH-SUG-021: setState on a stranger's suggestion answers null and writes nothing", async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const theirs = seedSuggestion({ userId: stranger.id }); + const theirs = await seedSuggestion({ userId: stranger.id }); expect(await svc.setState(user.id, theirs, 'dismissed')).toBeNull(); - expect(rowOf(theirs).state).toBe('new'); + expect((await rowOf(theirs)).state).toBe('new'); }); }); @@ -851,8 +833,8 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => { describe('DawarichSuggestionsService — confirming a scan', () => { it('DAWARICH-SUG-022: confirmBucketVisits only touches wishes that were not ticked yet', async () => { const { user } = createUser(testDb); - const open = seedBucketItem(user.id, { name: 'Hallstatt' }); - const alreadyTicked = seedBucketItem(user.id, { + const open = await seedBucketItem(user.id, { name: 'Hallstatt' }); + const alreadyTicked = await seedBucketItem(user.id, { name: 'Kyoto', visitedAt: '2026-01-04T12:00:00Z', visitedSource: 'manual', @@ -861,24 +843,24 @@ describe('DawarichSuggestionsService — confirming a scan', () => { const updated = await svc.confirmBucketVisits(user.id, [open, alreadyTicked], '2026-09-05T10:00:00Z'); expect(updated).toBe(1); - expect(bucketOf(open).visited_at).toBe('2026-09-05T10:00:00Z'); - expect(bucketOf(open).visited_source).toBe('dawarich'); + expect((await bucketOf(open)).visited_at).toBe('2026-09-05T10:00:00Z'); + expect((await bucketOf(open)).visited_source).toBe('dawarich'); // The hand-ticked one keeps both its date and who decided it. - expect(bucketOf(alreadyTicked).visited_at).toBe('2026-01-04T12:00:00Z'); - expect(bucketOf(alreadyTicked).visited_source).toBe('manual'); + expect((await bucketOf(alreadyTicked)).visited_at).toBe('2026-01-04T12:00:00Z'); + expect((await bucketOf(alreadyTicked)).visited_source).toBe('manual'); }); it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const mine = seedBucketItem(user.id); - const theirs = seedBucketItem(stranger.id); + const mine = await seedBucketItem(user.id); + const theirs = await seedBucketItem(stranger.id); const updated = await svc.confirmBucketVisits(user.id, [mine, theirs]); expect(updated).toBe(1); - expect(bucketOf(theirs).visited_at).toBeNull(); - expect(bucketOf(mine).visited_at).not.toBeNull(); + expect((await bucketOf(theirs)).visited_at).toBeNull(); + expect((await bucketOf(mine)).visited_at).not.toBeNull(); }); it('DAWARICH-SUG-056: a confirmed wish is dated from the stay that matched it, not from today', async () => { @@ -886,28 +868,28 @@ describe('DawarichSuggestionsService — confirming a scan', () => { // entry in a list people keep for years. The clock is only the answer when // nothing else knows better. const { user } = createUser(testDb); - const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); - seedSuggestion({ userId: user.id, matchedBucketListItemId: wish, startedAt: '2023-07-14T08:20:00Z' }); + const wish = await seedBucketItem(user.id, { name: 'Hallstatt' }); + await seedSuggestion({ userId: user.id, matchedBucketListItemId: wish, startedAt: '2023-07-14T08:20:00Z' }); const updated = await svc.confirmBucketVisits(user.id, [wish]); expect(updated).toBe(1); - expect(bucketOf(wish).visited_at).toBe('2023-07-14T08:20:00Z'); - expect(bucketOf(wish).visited_source).toBe('dawarich'); + expect((await bucketOf(wish)).visited_at).toBe('2023-07-14T08:20:00Z'); + expect((await bucketOf(wish)).visited_source).toBe('dawarich'); }); it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const mine = seedBucketItem(user.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' }); - const theirs = seedBucketItem(stranger.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' }); + const mine = await seedBucketItem(user.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' }); + const theirs = await seedBucketItem(stranger.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' }); expect(await svc.clearBucketVisit(user.id, mine)).toBe(true); - expect(bucketOf(mine).visited_at).toBeNull(); - expect(bucketOf(mine).visited_source).toBeNull(); + expect((await bucketOf(mine)).visited_at).toBeNull(); + expect((await bucketOf(mine)).visited_source).toBeNull(); expect(await svc.clearBucketVisit(user.id, theirs)).toBe(false); - expect(bucketOf(theirs).visited_at).toBe('2026-09-05T10:00:00Z'); + expect((await bucketOf(theirs)).visited_at).toBe('2026-09-05T10:00:00Z'); }); }); @@ -916,7 +898,7 @@ describe('DawarichSuggestionsService — confirming a scan', () => { describe('DawarichSuggestionsService — scanning for stays', () => { it('DAWARICH-SUG-025: a scan without a connection refuses before a single request', async () => { const { user } = createUser(testDb); - seedBucketItem(user.id); + await seedBucketItem(user.id); dawarichStub.getCredentials.mockReturnValue(null); const err = await asyncRefusalFrom(() => svc.scanBucketList(user.id)); @@ -928,8 +910,8 @@ describe('DawarichSuggestionsService — scanning for stays', () => { it('DAWARICH-SUG-026: the longest stay wins over the nearest, and a wish without coordinates reports as skipped', async () => { const { user } = createUser(testDb); - const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); - seedBucketItem(user.id, { name: 'Somewhere vague', lat: null, lng: null }); + const wish = await seedBucketItem(user.id, { name: 'Hallstatt' }); + await seedBucketItem(user.id, { name: 'Somewhere vague', lat: null, lng: null }); clientStub.findVisitsNear.mockResolvedValue([ { timestamp: 1757000000, @@ -965,7 +947,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => { // mode this flag exists to prevent. const { user } = createUser(testDb); for (let i = 0; i < DAWARICH_BUCKET_SCAN_LIMIT + 1; i++) { - seedBucketItem(user.id, { name: `Wish ${i}` }); + await seedBucketItem(user.id, { name: `Wish ${i}` }); } clientStub.findVisitsNear.mockResolvedValue([]); @@ -983,8 +965,8 @@ describe('DawarichSuggestionsService — scanning for stays', () => { // tick did, but flagged, so the list reads as "these are new" rather than // as a wall of things the user has known for years. const { user } = createUser(testDb); - const open = seedBucketItem(user.id, { name: 'Hallstatt' }); - const ticked = seedBucketItem(user.id, { name: 'Kyoto', visitedAt: '2023-04-02T09:00:00Z' }); + const open = await seedBucketItem(user.id, { name: 'Hallstatt' }); + const ticked = await seedBucketItem(user.id, { name: 'Kyoto', visitedAt: '2023-04-02T09:00:00Z' }); clientStub.findVisitsNear.mockResolvedValue([]); const scan = await svc.scanBucketList(user.id); @@ -999,7 +981,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => { // it answers with stays from its own default 500 m, and a four-hour stay // 600 m away is somewhere else entirely. const { user } = createUser(testDb); - seedBucketItem(user.id, { name: 'Hallstatt' }); + await seedBucketItem(user.id, { name: 'Hallstatt' }); clientStub.findVisitsNear.mockResolvedValue([ { timestamp: 1757000000, @@ -1020,7 +1002,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => { it('DAWARICH-SUG-052: with no duration reported the two timestamps decide, and a stay with no details is skipped', async () => { const { user } = createUser(testDb); - seedBucketItem(user.id, { name: 'Hallstatt' }); + await seedBucketItem(user.id, { name: 'Hallstatt' }); clientStub.findVisitsNear.mockResolvedValue([ // Nothing to measure at all: no duration, no times. It cannot clear the // dwell threshold, so it is not a visit. @@ -1048,7 +1030,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => { // for a 250 m radius: treating the answer as "at most the radius" keeps the // stay rather than dropping a real match over a missing field. const { user } = createUser(testDb); - seedBucketItem(user.id, { name: 'Hallstatt' }); + await seedBucketItem(user.id, { name: 'Hallstatt' }); clientStub.findVisitsNear.mockResolvedValue([ { timestamp: 1_757_000_000, @@ -1069,7 +1051,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => { it('DAWARICH-SUG-054: a stay that cannot be dated at all is skipped, not ticked off on 1970-01-01', async () => { const { user } = createUser(testDb); - seedBucketItem(user.id, { name: 'Hallstatt' }); + await seedBucketItem(user.id, { name: 'Hallstatt' }); clientStub.findVisitsNear.mockResolvedValue([ { timestamp: null, distance_meters: 10, points_count: 8, visit_details: { duration_minutes: 60 } }, ]); @@ -1081,7 +1063,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => { it('DAWARICH-SUG-055: the longest qualifying stay wins, and a later shorter one does not displace it', async () => { const { user } = createUser(testDb); - seedBucketItem(user.id, { name: 'Hallstatt' }); + await seedBucketItem(user.id, { name: 'Hallstatt' }); clientStub.findVisitsNear.mockResolvedValue([ { timestamp: 1757000000, @@ -1112,7 +1094,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => { it('DAWARICH-SUG-027: a wish whose lookup fails reads as "no match", not as a failed scan', async () => { const { user } = createUser(testDb); - seedBucketItem(user.id, { name: 'Hallstatt' }); + await seedBucketItem(user.id, { name: 'Hallstatt' }); clientStub.findVisitsNear.mockRejectedValue(new Error('upstream is down')); const scan = await svc.scanBucketList(user.id); @@ -1204,7 +1186,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { ['FR', false], ['AT', false], ]); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM visited_countries WHERE user_id = ?').get(user.id)).toEqual({ n: 0 }); + expect(await countRows(t, VisitedCountries, { user: user.id })).toBe(0); }); it('DAWARICH-SUG-064: the shape the Atlas answers for a user without trips carries no status and reads as visited', async () => { @@ -1278,7 +1260,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { it('DAWARICH-SUG-031: accepting as a place broadcasts it like any other place, socket id and all', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' }); const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-7'); @@ -1299,7 +1281,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { // next full load, which is the state this re-read exists to avoid. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' }); const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); @@ -1314,7 +1296,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { // the trip never receives is a place that needs a page reload to appear. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id }); // Plan 3h Task 3: the re-read is `PlacesRepository.findWithTagsAndRatings` // now, injected directly (no more `DatabaseService.getPlaceWithTags` // indirection) — spied directly on the real repository instance. @@ -1336,13 +1318,13 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - const id = seedSuggestion({ userId: member.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: member.id, tripId: trip.id }); permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'place_edit'); await expect(svc.accept(member.id, id, { target: 'place', tripId: trip.id })).rejects.toThrow(AcceptError); // Nothing written, and the stay is still waiting rather than marked handled. expect(placesStub.create).not.toHaveBeenCalled(); - expect(rowOf(id).state).toBe('new'); + expect((await rowOf(id)).state).toBe('new'); }); it('DAWARICH-SUG-040: pinning to a day asks day_edit as well, and writes nothing without it', async () => { @@ -1351,12 +1333,12 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); - const id = seedSuggestion({ userId: member.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: member.id, tripId: trip.id }); permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'day_edit'); await expect(svc.accept(member.id, id, { target: 'place', tripId: trip.id, dayId: day.id })).rejects.toThrow(AcceptError); expect(placesStub.create).not.toHaveBeenCalled(); - expect(rowOf(id).state).toBe('new'); + expect((await rowOf(id)).state).toBe('new'); }); it('DAWARICH-SUG-041: the permission is asked about the trip owner, not about the caller', async () => { @@ -1364,7 +1346,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - const id = seedSuggestion({ userId: member.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: member.id, tripId: trip.id }); await svc.accept(member.id, id, { target: 'place', tripId: trip.id }); @@ -1374,21 +1356,19 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { it('DAWARICH-SUG-038: a place made from a recording says where it came from', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id }); const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); - const place = testDb - .prepare('SELECT source FROM places WHERE id = ?') - .get(result.createdPlaceId) as { source: string | null }; - expect(place.source).toBe('dawarich'); + const place = await findRow(t, Places, { id: result.createdPlaceId }); + expect(place?.source).toBe('dawarich'); }); it('DAWARICH-SUG-032: a stay accepted onto a day broadcasts the assignment too', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); - const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id }); assignmentsStub.createAssignment.mockReturnValue({ id: 4242, day_id: day.id }); await svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }, 'socket-9'); @@ -1405,7 +1385,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); - const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id }); assignmentsStub.createAssignment.mockReturnValue(undefined); await svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }); @@ -1424,11 +1404,11 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { it('DAWARICH-SUG-034: deleting the place an acceptance created puts the stay back into review', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id }); const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); - expect(rowOf(id).state).toBe('accepted'); + expect((await rowOf(id)).state).toBe('accepted'); - testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId); + await deleteRows(t, Places, { id: result.createdPlaceId }); const listed = (await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!; expect(listed.state).toBe('new'); @@ -1441,7 +1421,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { it('DAWARICH-SUG-035: an acceptance whose place still exists is left alone', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: user.id, tripId: trip.id }); await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); const listed = (await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!; @@ -1451,27 +1431,23 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { it('DAWARICH-SUG-036: a journal acceptance reopens once its entry is gone', async () => { const { user } = createUser(testDb); - const id = seedSuggestion({ userId: user.id, tripId: null }); - const journeyId = Number( - testDb - .prepare( - "INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'Trip diary', 'active', 0, 0)", - ) - .run(user.id).lastInsertRowid, - ); - const entryId = Number( - testDb - .prepare( - "INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, 'entry', 'Cafe', '2026-09-01', 0, 0)", - ) - .run(journeyId, user.id).lastInsertRowid, - ); + const id = await seedSuggestion({ userId: user.id, tripId: null }); + const journeyId = await insertRow(t, Journeys, { user: user.id, title: 'Trip diary', status: 'active', created_at: 0, updated_at: 0 }); + const entryId = await insertRow(t, JourneyEntries, { + journey: journeyId, + author: user.id, + type: 'entry', + title: 'Cafe', + entry_date: '2026-09-01', + created_at: 0, + updated_at: 0, + }); journeyStub.createEntry.mockReturnValue({ id: entryId }); await svc.accept(user.id, id, { target: 'journal', journalId: journeyId }); expect((await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!.state).toBe('accepted'); - testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + await deleteRows(t, JourneyEntries, { id: entryId }); expect((await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!.state).toBe('new'); }); @@ -1480,12 +1456,12 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const { user: mine } = createUser(testDb); const { user: theirs } = createUser(testDb); const trip = createTrip(testDb, theirs.id); - const id = seedSuggestion({ userId: theirs.id, tripId: trip.id }); + const id = await seedSuggestion({ userId: theirs.id, tripId: trip.id }); const result = await svc.accept(theirs.id, id, { target: 'place', tripId: trip.id }); - testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId); + await deleteRows(t, Places, { id: result.createdPlaceId }); await svc.list(mine.id, {}); - expect(rowOf(id).state).toBe('accepted'); + expect((await rowOf(id)).state).toBe('accepted'); }); }); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts index 394536784..ff30bd5c0 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts @@ -73,6 +73,14 @@ import type { import type { User } from '../../../../src/types'; import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { deleteRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { addTripMember } from '../../../helpers/factories/trips'; +import { DocumentConnections } from '../../../../src/db/entities/DocumentConnections.entity'; +import { DocumentProviders } from '../../../../src/db/entities/DocumentProviders.entity'; +import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity'; +import { TripDocumentLinks } from '../../../../src/db/entities/TripDocumentLinks.entity'; +import { TripFiles } from '../../../../src/db/entities/TripFiles.entity'; +import { TripMembers } from '../../../../src/db/entities/TripMembers.entity'; import { createTestDocumentConnectionsRepo, createTestDocumentProviderFieldsRepo, @@ -126,23 +134,11 @@ const sync = { // real read against this file's own testDb, not a stub: "the document // list" describe block below is testing the join/trip-scoping SHAPE this // method now owns, and a canned return would only restate the assertion. - itemsForTrip: vi.fn((tripId: number, state?: string) => - state - ? testDb - .prepare( - `SELECT i.*, f.original_name AS file_name FROM document_sync_items i - LEFT JOIN trip_files f ON f.id = i.file_id - WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`, - ) - .all(tripId, state) - : testDb - .prepare( - `SELECT i.*, f.original_name AS file_name FROM document_sync_items i - LEFT JOIN trip_files f ON f.id = i.file_id - WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`, - ) - .all(tripId), - ), + // It answers through the same two repository reads DocSyncService makes. + itemsForTrip: vi.fn(async (tripId: number, state?: string) => { + const items = await createTestDocumentSyncItemsRepo(testDb); + return state ? items.listForTripByState(tripId, state) : items.listForTrip(tripId); + }), }; const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]); @@ -228,7 +224,7 @@ beforeAll(async () => { admin = { id: a.id, role: 'admin' } as User; tripId = createTrip(testDb, o.id, { title: 'Japan' }).id; otherTripId = createTrip(testDb, m.id, { title: 'Norway' }).id; - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, m.id); + await addTripMember(t, tripId, m.id); }); afterAll(async () => { @@ -236,11 +232,14 @@ afterAll(async () => { testDb.close(); }); -beforeEach(() => { - testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections; DELETE FROM trip_files'); +beforeEach(async () => { + await deleteRows(t, DocumentSyncItems); + await deleteRows(t, TripDocumentLinks); + await deleteRows(t, DocumentConnections); + await deleteRows(t, TripFiles); // Providers ship switched off; these three are what the cases below need. - testDb.prepare("UPDATE document_providers SET enabled = 0").run(); - testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('paperless', 'nextcloud', 'papra')").run(); + await updateRows(t, DocumentProviders, {}, { enabled: 0 }); + await updateRows(t, DocumentProviders, { id: { $in: ['paperless', 'nextcloud', 'papra'] } }, { enabled: 1 }); vi.clearAllMocks(); }); @@ -330,7 +329,7 @@ describe('storing a connection', () => { }); it('refuses a provider the instance admin has not switched on, and names it', async () => { - testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'papra'").run(); + await updateRows(t, DocumentProviders, { id: 'papra' }, { enabled: 0 }); const err = await thrown(() => controller.upsertConnection(String(tripId), owner, connBody({ providerId: 'papra', credentials: { api_key: 'k', organization_id: 'org_1' } })), ); @@ -428,7 +427,7 @@ describe('a secret the adapter earned itself', () => { const EARNED = 'a1b2c3:DEVICE-SECRET'; it('is in neither connection payload the form is rendered from', async () => { - testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id = 'synologydrive'").run(); + await updateRows(t, DocumentProviders, { id: 'synologydrive' }, { enabled: 1 }); const nas = connBody({ providerId: 'synologydrive', baseUrl: 'https://nas.example.com:5001', @@ -480,14 +479,11 @@ describe('reading the trip state', () => { describe('removing a connection', () => { it('unbinds without touching the documents that came through it', async () => { const conn = await storedPaperless(); - const fileId = Number( - testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)') - .run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid, - ); + const fileId = await insertRow(t, TripFiles, { trip: tripId, filename: 'stored.pdf', original_name: 'boarding.pdf' }); await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true }); expect(await config.getConnection(conn.id)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy(); + expect(await findRow(t, TripFiles, { id: fileId })).toBeTruthy(); }); it('takes down every subscription TREK registered for its bindings, before the credential goes', async () => { @@ -577,10 +573,12 @@ describe('the scope picker', () => { }); it('refuses a connection whose provider has no adapter on this instance', async () => { - const id = Number( - testDb.prepare('INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url) VALUES (?, ?, ?, ?)') - .run(tripId, 'papra', Number(owner.id), 'https://papra.example.com').lastInsertRowid, - ); + const id = await insertRow(t, DocumentConnections, { + trip: tripId, + provider: 'papra', + ownerUser: Number(owner.id), + base_url: 'https://papra.example.com', + }); const err = await thrown(() => controller.listScopes(String(tripId), String(id), owner)); expect(err.getStatus()).toBe(400); expect(err.message).toBe('Provider not available'); @@ -780,7 +778,7 @@ describe('a manual run', () => { if ('error' in res) throw new Error('fixture failed'); const created = (await controller.createLink(String(tripId), owner, linkBody(res.data.id, { syncEnabled: false }), makeReq())) as { id: number }; sync.syncLink.mockClear(); - testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, member.id); + await deleteRows(t, TripMembers, { trip: tripId, user: member.id }); try { const err = await thrown(() => controller.syncNow(String(tripId), String(created.id), { full: false })); @@ -788,7 +786,7 @@ describe('a manual run', () => { expect(sync.retryShelvedItems).not.toHaveBeenCalled(); expect(sync.syncLink).not.toHaveBeenCalled(); } finally { - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id); + await addTripMember(t, tripId, member.id); } }); }); @@ -796,21 +794,19 @@ describe('a manual run', () => { describe('the document list', () => { async function seedItems() { const conn = await storedPaperless(); - const fileId = Number( - testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)') - .run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid, - ); - const linkId = Number( - testDb.prepare( - `INSERT INTO trip_document_links (trip_id, connection_id, provider_id, remote_scope_key) - VALUES (?, ?, ?, ?)`, - ).run(tripId, conn.id, 'paperless', 'tag:1').lastInsertRowid, - ); - const insert = testDb.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)'); - insert.run(linkId, tripId, fileId, 'uid-1', 'synced'); - insert.run(linkId, tripId, null, 'uid-2', 'conflict'); + const fileId = await insertRow(t, TripFiles, { trip: tripId, filename: 'stored.pdf', original_name: 'boarding.pdf' }); + const linkId = await insertRow(t, TripDocumentLinks, { + trip: tripId, + connection: conn.id, + provider_id: 'paperless', + remote_scope_key: 'tag:1', + }); + const insert = (trip: number, file: number | null, trekDocUid: string, state: string) => + insertRow(t, DocumentSyncItems, { link: linkId, trip, file, trek_doc_uid: trekDocUid, state }); + await insert(tripId, fileId, 'uid-1', 'synced'); + await insert(tripId, null, 'uid-2', 'conflict'); // Another trip's row, which must never appear in this trip's list. - insert.run(linkId, otherTripId, null, 'uid-3', 'conflict'); + await insert(otherTripId, null, 'uid-3', 'conflict'); } it('carries the local file name, so the list reads as documents rather than as ids', async () => { @@ -845,20 +841,15 @@ describe('removing a binding', () => { linkBody(conn.id), makeReq({ host: 'trek.example' }), )) as { id: number }; - const fileId = Number( - testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)') - .run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid, - ); - testDb - .prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)') - .run(created.id, tripId, fileId, 'uid-1', 'synced'); + const fileId = await insertRow(t, TripFiles, { trip: tripId, filename: 'stored.pdf', original_name: 'boarding.pdf' }); + await insertRow(t, DocumentSyncItems, { link: created.id, trip: tripId, file: fileId, trek_doc_uid: 'uid-1', state: 'synced' }); const res = await controller.deleteLink(String(tripId), String(created.id), owner); expect(res).toEqual({ success: true, documentsKept: true }); expect(paperless.unregisterWebhook.mock.calls[0][1]).toBe('sub-7'); expect(await config.getLink(created.id)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy(); + expect(await findRow(t, TripFiles, { id: fileId })).toBeTruthy(); }); it('pings the trip that the binding is gone', async () => { diff --git a/server/tests/unit/nest/reminder-jobs.service.test.ts b/server/tests/unit/nest/reminder-jobs.service.test.ts index c6c8f60dd..943bafabc 100644 --- a/server/tests/unit/nest/reminder-jobs.service.test.ts +++ b/server/tests/unit/nest/reminder-jobs.service.test.ts @@ -37,6 +37,17 @@ import { createTestTodoItemsRepo } from '../../helpers/todo-repos'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; import type { TodoItemsRepository } from '../../../src/db/repositories/TodoItems.repository'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { findRow, updateRows } from '../../helpers/factories/rows'; +import { Trips } from '../../../src/db/entities/Trips.entity'; +import { TodoItems } from '../../../src/db/entities/TodoItems.entity'; + +/** The stored reminded_at of a to-do; fails the case when the item is gone. */ +async function remindedAtOf(id: number): Promise { + const todo = await findRow(await sharedTestOrm(testDb), TodoItems, { id }); + if (!todo) throw new Error(`no to-do ${id}`); + return todo.reminded_at; +} interface Registered { name: string; @@ -75,9 +86,22 @@ function makeJobs(overrides: { notifications?: NotificationsService } = {}) { } /** A trip whose start_date sits exactly `days` ahead, with reminders on. */ -function tripWithReminder(userId: number, days: number, title = 'Lisbon'): number { +/** The UTC calendar date `days` from today, as SQLite's `date('now', '+N days')` renders it. */ +const dayFromToday = (days: number): string => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); + +const orm = () => sharedTestOrm(testDb); + +async function setTrip(tripId: number, data: { reminder_days?: number; start_date?: string }): Promise { + await updateRows(await orm(), Trips, { id: tripId }, data); +} + +async function setTodo(todoId: number, data: { due_date?: string; assignedUser?: number }): Promise { + await updateRows(await orm(), TodoItems, { id: todoId }, data); +} + +async function tripWithReminder(userId: number, days: number, title = 'Lisbon'): Promise { const trip = createTrip(testDb, userId, { title }); - testDb.prepare("UPDATE trips SET reminder_days = ?, start_date = date('now', '+' || ? || ' days') WHERE id = ?").run(days, days, trip.id); + await setTrip(trip.id, { reminder_days: days, start_date: dayFromToday(days) }); return trip.id; } @@ -140,7 +164,7 @@ describe('ReminderJobsService bootstrap', () => { it('RJOB-004 — the trip banner carries the active channels and the reminder-trip count', async () => { const { user } = createUser(testDb); setNotificationChannels(testDb, 'email'); - tripWithReminder(user.id, 5); + await tripWithReminder(user.id, 5); const { svc } = makeJobs(); await svc.onApplicationBootstrap(); expect(logMock.logInfo).toHaveBeenCalledWith('Trip reminders: enabled via [email], 1 trip(s) with active reminders'); @@ -150,11 +174,11 @@ describe('ReminderJobsService bootstrap', () => { describe('trip reminder tick', () => { it('RJOB-005 — sends for a trip starting exactly reminder_days out and logs the summary', async () => { const { user } = createUser(testDb); - const tripId = tripWithReminder(user.id, 3); - tripWithReminder(user.id, 5, 'Also due'); // its own window lines up too (start = now + reminder_days) + const tripId = await tripWithReminder(user.id, 3); + await tripWithReminder(user.id, 5, 'Also due'); // its own window lines up too (start = now + reminder_days) // A trip whose start date does NOT line up with its reminder window: const off = createTrip(testDb, user.id, { title: 'Off-window' }); - testDb.prepare("UPDATE trips SET reminder_days = 2, start_date = date('now', '+9 days') WHERE id = ?").run(off.id); + await setTrip(off.id, { reminder_days: 2, start_date: dayFromToday(9) }); const { svc, send } = makeJobs(); await svc.tripTick(); @@ -170,10 +194,10 @@ describe('trip reminder tick', () => { const { user } = createUser(testDb); // Due yesterday (start in 2 days, reminder 3 days ahead): the job did not run then. const missed = createTrip(testDb, user.id, { title: 'Missed' }); - testDb.prepare("UPDATE trips SET reminder_days = 3, start_date = date('now', '+2 days') WHERE id = ?").run(missed.id); + await setTrip(missed.id, { reminder_days: 3, start_date: dayFromToday(2) }); // Already started: no reminder any more. const started = createTrip(testDb, user.id, { title: 'Started' }); - testDb.prepare("UPDATE trips SET reminder_days = 3, start_date = date('now', '-1 days') WHERE id = ?").run(started.id); + await setTrip(started.id, { reminder_days: 3, start_date: dayFromToday(-1) }); const { svc, send } = makeJobs(); await svc.tripTick(); @@ -183,14 +207,14 @@ describe('trip reminder tick', () => { await svc.tripTick(); expect(send).not.toHaveBeenCalled(); - testDb.prepare("UPDATE trips SET start_date = date('now', '+1 days') WHERE id = ?").run(missed.id); + await setTrip(missed.id, { start_date: dayFromToday(1) }); await svc.tripTick(); expect(send.mock.calls.map(([p]) => p.targetId)).toEqual([missed.id]); }); it('RJOB-006 — the per-tick gate skips everything when notify_trip_reminder is false', async () => { const { user } = createUser(testDb); - tripWithReminder(user.id, 3); + await tripWithReminder(user.id, 3); setAppSetting(testDb, 'notify_trip_reminder', 'false'); const { svc, send } = makeJobs(); await svc.tripTick(); @@ -216,43 +240,46 @@ describe('trip reminder tick', () => { }); describe('trip reminder claims', () => { - const sentFor = (id: number) => - (testDb.prepare('SELECT reminder_sent_for FROM trips WHERE id = ?').get(id) as { reminder_sent_for: string | null }).reminder_sent_for; + const sentFor = async (id: number) => { + const trip = await findRow(await orm(), Trips, { id }); + if (!trip) throw new Error(`no trip ${id}`); + return trip.reminder_sent_for; + }; it('RJOB-013: a send that throws gives the claim back, logs it, and the next run sends again', async () => { const { user } = createUser(testDb); - const tripId = tripWithReminder(user.id, 2); + const tripId = await tripWithReminder(user.id, 2); const { svc, send } = makeJobs(); send.mockRejectedValueOnce(new Error('db gone mid-send')); await svc.tripTick(); - expect(sentFor(tripId)).toBeNull(); + expect(await sentFor(tripId)).toBeNull(); expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('db gone mid-send')); expect(logMock.logWarn).toHaveBeenCalledWith('Trip reminder for "Lisbon" was not delivered; the next run tries again'); expect(logMock.logInfo).not.toHaveBeenCalledWith(expect.stringMatching(/^Trip reminders sent/)); await svc.tripTick(); expect(send).toHaveBeenCalledTimes(2); - expect(sentFor(tripId)).not.toBeNull(); + expect(await sentFor(tripId)).not.toBeNull(); }); it('RJOB-014: every channel failing is retried, a send nobody wanted is not', async () => { const { user } = createUser(testDb); - const failing = tripWithReminder(user.id, 2, 'Failing'); - const unwanted = tripWithReminder(user.id, 3, 'Unwanted'); + const failing = await tripWithReminder(user.id, 2, 'Failing'); + const unwanted = await tripWithReminder(user.id, 3, 'Unwanted'); const { svc, send } = makeJobs(); send.mockImplementation(async (payload: { targetId: number }) => payload.targetId === failing ? { attempted: 2, delivered: 0 } : { attempted: 0, delivered: 0 }, ); await svc.tripTick(); - expect(sentFor(failing)).toBeNull(); - expect(sentFor(unwanted)).not.toBeNull(); + expect(await sentFor(failing)).toBeNull(); + expect(await sentFor(unwanted)).not.toBeNull(); }); it('RJOB-015: a reminder another process claimed first is not sent again', async () => { const { user } = createUser(testDb); - const tripId = tripWithReminder(user.id, 2); + const tripId = await tripWithReminder(user.id, 2); // Both processes read the candidates; the other one claims first. const stale = await tripsRepo.listReminderCandidates(); const start = stale.find((t) => t.id === tripId)!.start_date; @@ -271,7 +298,7 @@ describe('todo reminder tick', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Lisbon' }); const todo = createTodoItem(testDb, trip.id, { name: 'Pack bags' }); - testDb.prepare("UPDATE todo_items SET due_date = date('now', '+1 day') WHERE id = ?").run(todo.id); + await setTodo(todo.id, { due_date: dayFromToday(1) }); const { svc, send } = makeJobs(); await svc.todoTick(); @@ -279,8 +306,7 @@ describe('todo reminder tick', () => { expect(send).toHaveBeenCalledTimes(1); expect(send).toHaveBeenCalledWith(expect.objectContaining({ event: 'todo_due', scope: 'trip', targetId: trip.id, params: expect.objectContaining({ todo: 'Pack bags', trip: 'Lisbon' }) })); expect(logMock.logInfo).toHaveBeenCalledWith('Todo reminders sent for 1 item(s)'); - const { reminded_at } = testDb.prepare('SELECT reminded_at FROM todo_items WHERE id = ?').get(todo.id) as { reminded_at: string | null }; - expect(reminded_at).not.toBeNull(); + expect(await remindedAtOf(todo.id)).not.toBeNull(); // The 20h dedup keeps a second same-day run silent. send.mockClear(); @@ -293,7 +319,7 @@ describe('todo reminder tick', () => { const { user: assignee } = createUser(testDb, { email: 'b@example.test', username: 'assignee' }); const trip = createTrip(testDb, user.id, { title: 'Lisbon' }); const todo = createTodoItem(testDb, trip.id, { name: 'Book train' }); - testDb.prepare("UPDATE todo_items SET due_date = date('now'), assigned_user_id = ? WHERE id = ?").run(assignee.id, todo.id); + await setTodo(todo.id, { due_date: dayFromToday(0), assignedUser: assignee.id }); const { svc, send } = makeJobs(); await svc.todoTick(); @@ -304,11 +330,11 @@ describe('todo reminder tick', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Lisbon' }); const checked = createTodoItem(testDb, trip.id, { name: 'Done', checked: 1 }); - testDb.prepare("UPDATE todo_items SET due_date = date('now') WHERE id = ?").run(checked.id); + await setTodo(checked.id, { due_date: dayFromToday(0) }); const far = createTodoItem(testDb, trip.id, { name: 'Far' }); - testDb.prepare("UPDATE todo_items SET due_date = date('now', '+10 days') WHERE id = ?").run(far.id); + await setTodo(far.id, { due_date: dayFromToday(10) }); const past = createTodoItem(testDb, trip.id, { name: 'Past' }); - testDb.prepare("UPDATE todo_items SET due_date = date('now', '-1 day') WHERE id = ?").run(past.id); + await setTodo(past.id, { due_date: dayFromToday(-1) }); const { svc, send } = makeJobs(); await svc.todoTick(); @@ -320,7 +346,7 @@ describe('todo reminder tick', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Lisbon' }); const todo = createTodoItem(testDb, trip.id, { name: 'Pack bags' }); - testDb.prepare("UPDATE todo_items SET due_date = date('now') WHERE id = ?").run(todo.id); + await setTodo(todo.id, { due_date: dayFromToday(0) }); setAppSetting(testDb, 'notify_todo_due', 'false'); const { svc, send } = makeJobs(); @@ -330,33 +356,32 @@ describe('todo reminder tick', () => { }); describe('todo reminder claims', () => { - function dueTodo(): number { + async function dueTodo(): Promise { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Lisbon' }); const todo = createTodoItem(testDb, trip.id, { name: 'Pack bags' }); - testDb.prepare("UPDATE todo_items SET due_date = date('now', '+1 day') WHERE id = ?").run(todo.id); + await setTodo(todo.id, { due_date: dayFromToday(1) }); return todo.id; } - const remindedAt = (id: number) => - (testDb.prepare('SELECT reminded_at FROM todo_items WHERE id = ?').get(id) as { reminded_at: string | null }).reminded_at; + const remindedAt = remindedAtOf; it('RJOB-016: a failed send gives the claim back, so the next run sends again', async () => { - const todoId = dueTodo(); + const todoId = await dueTodo(); const { svc, send } = makeJobs(); send.mockResolvedValueOnce({ attempted: 1, delivered: 0 }); await svc.todoTick(); - expect(remindedAt(todoId)).toBeNull(); + expect(await remindedAt(todoId)).toBeNull(); expect(logMock.logWarn).toHaveBeenCalledWith('Todo reminder for "Pack bags" was not delivered; the next run tries again'); expect(logMock.logInfo).not.toHaveBeenCalledWith(expect.stringMatching(/^Todo reminders sent/)); await svc.todoTick(); expect(send).toHaveBeenCalledTimes(2); - expect(remindedAt(todoId)).not.toBeNull(); + expect(await remindedAt(todoId)).not.toBeNull(); }); it('RJOB-017: a todo another process claimed first is not sent again', async () => { - const todoId = dueTodo(); + const todoId = await dueTodo(); const stale = await todoItemsRepo.listDueForReminder(new Date().toISOString().slice(0, 10), '2999-12-31'); await expect(todoItemsRepo.claimReminder(todoId)).resolves.toBe(true); vi.spyOn(todoItemsRepo, 'listDueForReminder').mockResolvedValueOnce(stale); diff --git a/server/tests/unit/nest/trip-members.service.test.ts b/server/tests/unit/nest/trip-members.service.test.ts index 38f3faf6d..b1e0fde38 100644 --- a/server/tests/unit/nest/trip-members.service.test.ts +++ b/server/tests/unit/nest/trip-members.service.test.ts @@ -23,20 +23,8 @@ const { broadcast, notifySend } = vi.hoisted(() => ({ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); - return { - 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), - }; + // Trip access reads through TripsRepository now; the module only hands out the handle. + return { db, closeDb: () => {}, reinitialize: () => {} }; }); import { db as testDb } from '../../../src/db/database'; @@ -62,6 +50,23 @@ import type { User } from '../../../src/types'; import { notificationsStub } from '../../helpers/notifications'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTripsRepo, createTestTripMembersRepo, sharedTestOrm } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; +import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { readUser } from '../../helpers/factories/users'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; + +const orm = () => sharedTestOrm(testDb); + +/** The trip_members row for the pair, or null. */ +async function memberRow(tripId: number, userId: number) { + return findRow(await orm(), TripMembers, { trip: tripId, user: userId }); +} + +/** The trip's owner as stored. */ +async function ownerOf(tripId: number): Promise { + return (await findRow(await orm(), Trips, { id: tripId }))?.user_id; +} import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -221,7 +226,7 @@ describe('addMember fallbacks', () => { spy.mockRestore(); } expect(result.tripTitle).toBe('Untitled'); - expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeDefined(); + expect(await memberRow(trip.id, invitee.id)).not.toBeNull(); }); it('MEMBERS-SVC-008: addMember resolves a padded identifier and matches on username as well as email', async () => { @@ -257,7 +262,7 @@ describe('transferOwnership guard rails', () => { // its owner either way. await expect(roster.transferOwnership(trip.id, 999999, owner.id)).rejects.toThrow(NotFoundError); await expect(roster.transferOwnership(trip.id, 999999, owner.id)).rejects.toThrow('User not found'); - expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(owner.id); + expect(await ownerOf(trip.id)).toBe(owner.id); }); // R8: the same rewrite as MEMBERS-SVC-007 above, on `UsersRepository.getEmail` @@ -281,7 +286,7 @@ describe('transferOwnership guard rails', () => { } expect(result.fromEmail).toBe(''); expect(result.toEmail).toBe(member.email); - expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(member.id); + expect(await ownerOf(trip.id)).toBe(member.id); }); }); @@ -298,7 +303,7 @@ describe('guest name validation', () => { // The guards run ahead of the transaction, so a rejected name can never leave // a credential-less users row behind with no trip to belong to. - expect((testDb.prepare('SELECT COUNT(*) AS n FROM users WHERE is_guest = 1').get() as { n: number }).n).toBe(0); + expect(await countRows(await orm(), Users, { is_guest: 1 })).toBe(0); // 50 is the accepted boundary the DTO shares — off by one here and the API // starts refusing names the client believes are valid. @@ -317,11 +322,11 @@ describe('guest name validation', () => { // Order matters for the status code: an unusable name throws (400) even for an // id that is not a guest of this trip, where the scope check returns false (404). await expect(roster.renameGuest(trip.id, owner.id, '')).rejects.toThrow('Guest name is required'); - expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida'); + expect((await readUser(await orm(), guest.id)).display_name).toBe('Ida'); // A padded name is stored trimmed, so the roster does not render the spaces. expect(await roster.renameGuest(trip.id, guest.id, ' Ida M. ')).toBe(true); - expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida M.'); + expect((await readUser(await orm(), guest.id)).display_name).toBe('Ida M.'); }); it("MEMBERS-SVC-014: deleteGuest is trip-scoped — another trip's owner cannot erase this trip's guest", async () => { @@ -335,7 +340,7 @@ describe('guest name validation', () => { // only thing between it and a foreign guest's users row — and the delete // cascades every assignment that guest is on. expect(await roster.deleteGuest(otherTrip.id, guest.id)).toBe(false); - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined(); + expect(await findRow(await orm(), Users, { id: guest.id })).not.toBeNull(); }); }); @@ -348,11 +353,11 @@ describe('listMembers shaping', () => { const { user: sso } = createUser(testDb); const { user: bare } = createUser(testDb); const trip = createTrip(testDb, owner.id); - testDb.prepare('UPDATE users SET avatar = ?, display_name = ? WHERE id = ?').run('me.png', 'Owner Displayed', owner.id); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('a.png', uploaded.id); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('https://idp.example.test/p.jpg', sso.id); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(trip.id, uploaded.id, owner.id); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(trip.id, sso.id, owner.id); + await updateRows(await orm(), Users, { id: owner.id }, { avatar: 'me.png', display_name: 'Owner Displayed' }); + await updateRows(await orm(), Users, { id: uploaded.id }, { avatar: 'a.png' }); + await updateRows(await orm(), Users, { id: sso.id }, { avatar: 'https://idp.example.test/p.jpg' }); + await insertRow(await orm(), TripMembers, { trip: trip.id, user: uploaded.id, invitedByRef: owner.id }); + await insertRow(await orm(), TripMembers, { trip: trip.id, user: sso.id, invitedByRef: owner.id }); addTripMember(testDb, trip.id, bare.id); const { owner: ownerRow, members } = await roster.listMembers(trip.id, owner.id); @@ -407,15 +412,14 @@ describe('Task 6 review items — rollback and concurrency', () => { // setOwner (TM13) ran, then remove (TM14) ran, then addIgnoringConflict (TM15) // rejected — a fix that moved TM15 outside the transaction would leave TM13/TM14 // committed while this assertion still expects them rolled back. - const tripRow = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }; - expect(tripRow.user_id).toBe(owner.id); - expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id)).toBeDefined(); + expect(await ownerOf(trip.id)).toBe(owner.id); + expect(await memberRow(trip.id, member.id)).not.toBeNull(); }); it('MEMBERS-SVC-017 (mutation-proved): createGuest rolls back when the membership INSERT rejects — no orphan guest user row', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }).n; + const before = await countRows(await orm(), Users); const spy = vi.spyOn(tripMembersRepo, 'addMember').mockRejectedValueOnce(new Error('boom')); try { @@ -426,7 +430,7 @@ describe('Task 6 review items — rollback and concurrency', () => { // insertGuest (TM16) ran, then addMember (TM17) rejected — a fix that moved // TM17 outside the transaction would leave the guest's `users` row behind. - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }).n; + const after = await countRows(await orm(), Users); expect(after).toBe(before); }); @@ -490,7 +494,6 @@ describe('Task 6 review items — rollback and concurrency', () => { // Without this the test stayed green against a mutation that serializes // `addMember` (forcing the TM5-catch branch instead) — not load-bearing. expect((rejected[0] as PromiseRejectedResult).reason.message).toMatch(/UNIQUE constraint failed: trip_members/); - const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id) as { n: number }; - expect(count.n).toBe(1); + expect(await countRows(await orm(), TripMembers, { trip: trip.id, user: invitee.id })).toBe(1); }); }); diff --git a/server/tests/unit/nest/user-profile.service.test.ts b/server/tests/unit/nest/user-profile.service.test.ts index 30474912e..b9190eb1f 100644 --- a/server/tests/unit/nest/user-profile.service.test.ts +++ b/server/tests/unit/nest/user-profile.service.test.ts @@ -40,7 +40,19 @@ import path from 'node:path'; import { UserProfileService } from '../../../src/nest/auth/user-profile.service'; import { UsersRepository } from '../../../src/db/repositories/Users.repository'; import { makeStorageFixture } from '../../helpers/storage-fixture'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { updateRows } from '../../helpers/factories/rows'; +import { readUser } from '../../helpers/factories/users'; +import { readAppSetting, setAppSetting } from '../../helpers/factories/settings'; +import { Users } from '../../../src/db/entities/Users.entity'; + +/** Writes columns of the user row directly, the state a case starts from. */ +async function setUserColumns( + userId: number, + data: { maps_api_key?: string | null; openweather_api_key?: string | null; avatar?: string | null }, +): Promise { + await updateRows(await sharedTestOrm(testDb), Users, { id: userId }, data); +} import { SEARCH_TEXT_FIELD_MASK } from '../../../src/nest/maps/maps.helpers'; const avatarsFx = makeStorageFixture('avatars/'); @@ -141,9 +153,7 @@ describe('getSettings', () => { it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', async () => { const { user } = createAdmin(testDb); - testDb - .prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?') - .run('maps-key-value', 'weather-key-value', user.id); + await setUserColumns(user.id, { maps_api_key: 'maps-key-value', openweather_api_key: 'weather-key-value' }); const result = await profile.getSettings(user.id); expect(result.status).toBeUndefined(); expect(result.settings).toBeDefined(); @@ -233,7 +243,7 @@ describe('validateKeys', () => { it('AUTH-DB-017: returns { maps: true } when fetch returns 200', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id); + await setUserColumns(user.id, { maps_api_key: 'test-key' }); const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ status: 200, @@ -250,7 +260,7 @@ describe('validateKeys', () => { it('AUTH-DB-018: returns { maps: false } when fetch throws a network error', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id); + await setUserColumns(user.id, { maps_api_key: 'test-key' }); const fetchSpy = vi .spyOn(global, 'fetch') @@ -266,7 +276,7 @@ describe('validateKeys', () => { it('AUTH-DB-098: sends Referer from APP_URL so referrer-restricted keys validate like real requests', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id); + await setUserColumns(user.id, { maps_api_key: 'test-key' }); const prevAppUrl = process.env.APP_URL; process.env.APP_URL = 'https://trek.example.com'; @@ -315,8 +325,8 @@ describe('validateKeys', () => { // What a second admin sees after the instance key was saved by the first // one: their column still holds whatever they pasted long ago, while every // search on the install runs on the app_settings row. - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('stale-personal-key', user.id); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'live-instance-key')").run(); + await setUserColumns(user.id, { maps_api_key: 'stale-personal-key' }); + await setAppSetting(await sharedTestOrm(testDb), 'maps_api_key', 'live-instance-key'); const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ status: 200, @@ -351,9 +361,9 @@ describe('updateMapsKey / avatar', () => { it('AUTH-DB-070: deleteAvatar nulls the column; an OIDC https avatar skips the file rm', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('https://idp/pic.jpg', user.id); + await setUserColumns(user.id, { avatar: 'https://idp/pic.jpg' }); expect(await profile.deleteAvatar(user.id)).toEqual({ success: true }); - const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as { avatar: string | null }; + const row = await readUser(await sharedTestOrm(testDb), user.id); expect(row.avatar).toBeNull(); }); @@ -368,7 +378,7 @@ describe('updateMapsKey / avatar', () => { it('AUTH-DB-069b: saveAvatar reclaims the previous uploaded avatar file', async () => { const { user } = createUser(testDb); const old = writeAvatar('old.png'); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('old.png', user.id); + await setUserColumns(user.id, { avatar: 'old.png' }); await profile.saveAvatar(user.id, 'new.png'); expect(fs.existsSync(old)).toBe(false); @@ -377,7 +387,7 @@ describe('updateMapsKey / avatar', () => { it('AUTH-DB-070b: deleteAvatar removes the uploaded avatar file', async () => { const { user } = createUser(testDb); const fp = writeAvatar('mine.png'); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('mine.png', user.id); + await setUserColumns(user.id, { avatar: 'mine.png' }); expect(await profile.deleteAvatar(user.id)).toEqual({ success: true }); expect(fs.existsSync(fp)).toBe(false); @@ -385,7 +395,7 @@ describe('updateMapsKey / avatar', () => { it('AUTH-DB-070c: a hostile stored avatar value is swallowed, never thrown', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('../../etc/passwd', user.id); + await setUserColumns(user.id, { avatar: '../../etc/passwd' }); // Central key validation rejects the value; the delete swallows it exactly // like the old rm().catch did — the DB update still wins. expect(await profile.deleteAvatar(user.id)).toEqual({ success: true }); @@ -408,17 +418,16 @@ describe('profile quirk fixes', () => { // tests/integration/security.test.ts, which runs with a real ENCRYPTION_KEY. // --------------------------------------------------------------------------- -const instanceRow = (key: string) => - (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value; +const instanceRow = async (key: string) => (await readAppSetting(await sharedTestOrm(testDb), key)) ?? undefined; describe('instance-wide API keys', () => { it('AUTH-DB-102: an admin save lands in app_settings AND in their own column', async () => { const { user } = createAdmin(testDb); await profile.updateApiKeys(user.id, { maps_api_key: 'instance-google-key', unsplash_api_key: 'instance-unsplash-key' }); - expect(instanceRow('maps_api_key')).toBe('instance-google-key'); - expect(instanceRow('unsplash_api_key')).toBe('instance-unsplash-key'); + expect(await instanceRow('maps_api_key')).toBe('instance-google-key'); + expect(await instanceRow('unsplash_api_key')).toBe('instance-unsplash-key'); // The column stays in step so clearing the field clears both. - const row = testDb.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(user.id) as { maps_api_key: string }; + const row = await readUser(await sharedTestOrm(testDb), user.id); expect(row.maps_api_key).toBe('instance-google-key'); }); @@ -427,8 +436,8 @@ describe('instance-wide API keys', () => { await profile.updateApiKeys(admin.id, { maps_api_key: 'admin-set' }); const { user } = createUser(testDb); await profile.updateApiKeys(user.id, { maps_api_key: 'members-own' }); - expect(instanceRow('maps_api_key')).toBe('admin-set'); - const row = testDb.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(user.id) as { maps_api_key: string }; + expect(await instanceRow('maps_api_key')).toBe('admin-set'); + const row = await readUser(await sharedTestOrm(testDb), user.id); expect(row.maps_api_key).toBe('members-own'); }); @@ -438,13 +447,13 @@ describe('instance-wide API keys', () => { await profile.updateApiKeys(user.id, { maps_api_key: '' }); // '' and not a missing row: a missing row would fall through to whatever // still sat in the admin's own column. - expect(instanceRow('maps_api_key')).toBe(''); + expect(await instanceRow('maps_api_key')).toBe(''); }); it('AUTH-DB-105: getSettings reads the instance value, not the admin own column', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('stale-personal-key', user.id); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'live-instance-key')").run(); + await setUserColumns(user.id, { maps_api_key: 'stale-personal-key' }); + await setAppSetting(await sharedTestOrm(testDb), 'maps_api_key', 'live-instance-key'); expect((await profile.getSettings(user.id)).settings?.maps_api_key).toBe('live-instance-key'); // openweather is per-user and unaffected. expect((await profile.getSettings(user.id)).settings?.openweather_api_key).toBeNull(); @@ -453,10 +462,10 @@ describe('instance-wide API keys', () => { it('AUTH-DB-106: updateMapsKey mirrors for an admin and stays personal for a member', async () => { const { user: admin } = createAdmin(testDb); expect((await profile.updateMapsKey(admin.id, 'via-maps-key-route')).changedKeys).toEqual(['maps_api_key']); - expect(instanceRow('maps_api_key')).toBe('via-maps-key-route'); + expect(await instanceRow('maps_api_key')).toBe('via-maps-key-route'); const { user } = createUser(testDb); await profile.updateMapsKey(user.id, 'members-own'); - expect(instanceRow('maps_api_key')).toBe('via-maps-key-route'); + expect(await instanceRow('maps_api_key')).toBe('via-maps-key-route'); }); it('AUTH-DB-107: updateSettings mirrors the key half without touching name/email handling', async () => { @@ -464,7 +473,7 @@ describe('instance-wide API keys', () => { const result = await profile.updateSettings(user.id, { maps_api_key: 'from-settings-route', username: 'renamed' }); expect(result.success).toBe(true); expect(result.user?.username).toBe('renamed'); - expect(instanceRow('maps_api_key')).toBe('from-settings-route'); + expect(await instanceRow('maps_api_key')).toBe('from-settings-route'); expect(result.changedKeys).toEqual(['maps_api_key']); }); }); @@ -497,7 +506,7 @@ describe('changedKeys', () => { const result = await profile.updateApiKeys(user.id, { maps_api_key: 'operator-owns-this' }); expect(result.managed_keys).toEqual(['maps_api_key']); expect(result.changedKeys).toEqual([]); - expect(instanceRow('maps_api_key')).toBeUndefined(); + expect(await instanceRow('maps_api_key')).toBeUndefined(); expect((await profile.updateMapsKey(user.id, 'operator-owns-this')).changedKeys).toEqual([]); expect((await profile.updateSettings(user.id, { maps_api_key: 'operator-owns-this' })).changedKeys).toEqual([]); } finally { diff --git a/server/tests/unit/plugins/plugin-repository-sql-parity.test.ts b/server/tests/unit/plugins/plugin-repository-sql-parity.test.ts index ee0df2040..810b553a6 100644 --- a/server/tests/unit/plugins/plugin-repository-sql-parity.test.ts +++ b/server/tests/unit/plugins/plugin-repository-sql-parity.test.ts @@ -21,6 +21,8 @@ import { Users } from '../../../src/db/entities/Users.entity'; import { Days } from '../../../src/db/entities/Days.entity'; import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity'; import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -36,11 +38,16 @@ afterAll(async () => { testDb.close(); }); -function seed() { +async function seed(): Promise { for (let i = 1; i <= 6; i++) { - testDb - .prepare("INSERT INTO users (id, username, email, password_hash, display_name, avatar) VALUES (?, ?, ?, 'x', ?, ?)") - .run(i, 'u' + i, `u${i}@x`, i % 2 ? 'D' + i : null, i === 3 ? 'a.png' : null); + await insertRow(t, Users, { + id: i, + username: 'u' + i, + email: `u${i}@x`, + password_hash: 'x', + display_name: i % 2 ? 'D' + i : null, + avatar: i === 3 ? 'a.png' : null, + }); } const trips: Array<[number, number]> = [ [1, 1], @@ -48,7 +55,7 @@ function seed() { [3, 1], [4, 5], ]; - for (const [id, owner] of trips) testDb.prepare('INSERT INTO trips (id, user_id, title) VALUES (?, ?, ?)').run(id, owner, 't'); + for (const [id, owner] of trips) await insertRow(t, Trips, { id, user: owner, title: 't' }); for (const [trip, user] of [ [1, 2], [1, 3], @@ -56,15 +63,23 @@ function seed() { [3, 4], [4, 1], ]) { - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip, user); + await insertRow(t, TripMembers, { trip, user }); } - for (let d = 1; d <= 5; d++) testDb.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (?, ?, ?)').run(10 + d, d % 2 ? 1 : 2, d); + for (let d = 1; d <= 5; d++) await insertRow(t, Days, { id: 10 + d, trip: d % 2 ? 1 : 2, day_number: d }); +} + +/** The stored metadata row for the plugin and key; fails the case when it is gone. */ +async function metadataRow(pluginId: string, key: string) { + const row = await findRow(t, PluginEntityMetadata, { plugin_id: pluginId, key }); + if (!row) throw new Error(`no metadata ${pluginId}/${key}`); + return row; } describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => { it('HR9 Trips.sharesTripWith == legacy bilateral SQL for every ordered user pair (incl. missing users)', async () => { - seed(); + await seed(); const repo = t.repo(Trips); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(`SELECT 1 FROM trips t LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ? LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ? @@ -83,18 +98,20 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => { }); it('Trips.existsById == legacy row-exists SQL, including a miss', async () => { - seed(); + await seed(); const repo = t.repo(Trips); for (const id of [1, 2, 3, 4, 99]) { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = !!testDb.prepare('SELECT 1 FROM trips WHERE id = ?').get(id); expect(await repo.existsById(id)).toBe(legacy); } }); it('HR1 Users.findPublicIdentity == legacy row (full key, key order) and miss', async () => { - seed(); + await seed(); const repo = t.repo(Users); for (let i = 1; i <= 7; i++) { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(i); const converted = (await repo.findPublicIdentity(i)) ?? undefined; // JSON.stringify: same keys, same order, same values — catches M15 (key order) @@ -110,32 +127,30 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => { }); it('CT1/CT2 Days.listIdsByTrip + MR9 Days.findTripId == legacy', async () => { - seed(); + await seed(); const repo = t.repo(Days); for (const trip of [1, 2, 3, 99]) { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = (testDb.prepare('SELECT id FROM days WHERE trip_id = ?').all(trip) as Array<{ id: number }>).map((r) => r.id); expect(await repo.listIdsByTrip(trip)).toEqual(legacy); } for (const id of [11, 12, 99]) { + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = (testDb.prepare('SELECT trip_id FROM days WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id; expect(await repo.findTripId(id)).toEqual(legacy); } }); it('MR1-MR6 PluginEntityMetadata repository == legacy statements', async () => { - seed(); + await seed(); const repo = t.repo(PluginEntityMetadata); await repo.upsertValue('p', 'trip', 1, 'b', '"1"'); await repo.upsertValue('p', 'trip', 1, 'a', '"2"'); await repo.upsertValue('q', 'trip', 1, 'a', '"x"'); - const before = testDb.prepare("SELECT updated_at FROM plugin_entity_metadata WHERE plugin_id='p' AND key='b'").get() as { updated_at: string }; - testDb.prepare("UPDATE plugin_entity_metadata SET updated_at='2000-01-01 00:00:00' WHERE plugin_id='p' AND key='b'").run(); + const before = await metadataRow('p', 'b'); + await updateRows(t, PluginEntityMetadata, { plugin_id: 'p', key: 'b' }, { updated_at: '2000-01-01 00:00:00' }); await repo.upsertValue('p', 'trip', 1, 'b', '"3"'); - const after = testDb.prepare("SELECT id, value, updated_at FROM plugin_entity_metadata WHERE plugin_id='p' AND key='b'").get() as { - id: number; - value: string; - updated_at: string; - }; + const after = await metadataRow('p', 'b'); expect(after.value).toBe('"3"'); // MR4: upsert replaces the value expect(after.updated_at).not.toBe('2000-01-01 00:00:00'); // MR4: updated_at refreshed on conflict expect(after.updated_at).toMatch(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/); @@ -144,19 +159,29 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => { expect(await repo.findValue('p', 'trip', 1, 'zz')).toBeNull(); // MR1 miss expect(await repo.countForEntity('p', 'trip', 1)).toBe(2); // MR3 expect(await repo.listForEntity('p', 'trip', 1)).toEqual( + // test-sql-allow: the legacy statement is the oracle the repository read is held to. testDb.prepare("SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key").all('p', 'trip', 1), ); // MR5, ORDER BY key expect(await repo.deleteValue('p', 'trip', 1, 'a')).toBe(true); // MR6 expect(await repo.deleteValue('p', 'trip', 1, 'a')).toBe(false); // MR6, already gone - expect(testDb.prepare('SELECT COUNT(*) c FROM plugin_entity_metadata').get()).toEqual({ c: 2 }); // 'p'/'b' + 'q'/'a' survive + expect(await countRows(t, PluginEntityMetadata)).toBe(2); // 'p'/'b' + 'q'/'a' survive }); it('HR5-HR8 PluginScheduledTasks repository == legacy statements (upsert keeps id, replaces fields)', async () => { const repo = t.repo(PluginScheduledTasks); await repo.upsertTask({ plugin_id: 'p', name: 'n', due_at: 5, payload: '1', every_ms: null }); - const id1 = (testDb.prepare("SELECT id FROM plugin_scheduled_tasks WHERE name='n'").get() as { id: number }).id; + const id1 = (await findRow(t, PluginScheduledTasks, { name: 'n' }))!.id; await repo.upsertTask({ plugin_id: 'p', name: 'n', due_at: 9, payload: '2', every_ms: 60000 }); - expect(testDb.prepare('SELECT id, plugin_id, name, due_at, payload, every_ms FROM plugin_scheduled_tasks').all()).toEqual([ + expect( + (await findRows(t, PluginScheduledTasks)).map(({ id, plugin_id, name, due_at, payload, every_ms }) => ({ + id, + plugin_id, + name, + due_at, + payload, + every_ms, + })), + ).toEqual([ { id: id1, plugin_id: 'p', name: 'n', due_at: 9, payload: '2', every_ms: 60000 }, ]); // HR7: same id, replaced fields — an upsert, not a delete+insert expect(await repo.existsForPluginAndName('p', 'n')).toBeTruthy(); // HR5 @@ -170,14 +195,14 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => { const repo = t.repo(PluginScheduledTasks); const now = Date.now(); await repo.upsertTask({ plugin_id: 'p', name: 'claim', due_at: now - 1000, payload: '1', every_ms: 60000 }); - const id = (testDb.prepare("SELECT id FROM plugin_scheduled_tasks WHERE name='claim'").get() as { id: number }).id; + const id = (await findRow(t, PluginScheduledTasks, { name: 'claim' }))!.id; // The winning claim: due_at <= claimedFromDueAt (the row's own current due_at). expect(await repo.rearm(id, now + 60000, now - 1000)).toBe(true); // A second, losing claim against the SAME stale claimedFromDueAt now fails — the // row's due_at already moved forward past it (RACE-SCHED-001/002's guard). expect(await repo.rearm(id, now + 120000, now - 1000)).toBe(false); - const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE id = ?').get(id) as { due_at: number }; - expect(row.due_at).toBe(now + 60000); // the losing rearm never wrote + const row = await findRow(t, PluginScheduledTasks, { id }); + expect(row?.due_at).toBe(now + 60000); // the losing rearm never wrote // deleteById carries the identical guard shape for the one-shot branch. expect(await repo.deleteById(id, now - 1000)).toBe(false); // stale claim, no-op expect(await repo.deleteById(id, now + 60000)).toBe(true); // current due_at, claims + deletes @@ -196,7 +221,7 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => { ); expect(results.filter(Boolean).length).toBe(100); // exactly 100 writes accepted expect(await repo.countForEntity('capbomb', 'trip', 1)).toBe(100); // never more than 100 rows - expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_entity_metadata WHERE plugin_id='capbomb'").get()).toEqual({ c: 100 }); + expect(await countRows(t, PluginEntityMetadata, { plugin_id: 'capbomb' })).toBe(100); }); it('must-land 3: 130 concurrent PluginScheduledTasks.upsertTaskCapped calls for the SAME plugin, 130 DIFFERENT new names, never exceed the 100 cap', async () => { @@ -207,6 +232,6 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => { ); expect(results.filter(Boolean).length).toBe(100); // exactly 100 writes accepted expect(await repo.countForPlugin('schedbomb')).toBe(100); // never more than 100 rows - expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_scheduled_tasks WHERE plugin_id='schedbomb'").get()).toEqual({ c: 100 }); + expect(await countRows(t, PluginScheduledTasks, { plugin_id: 'schedbomb' })).toBe(100); }); });