From aec925cffb58c86a1efa3a9e61f5cc64310ee4c6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 17:23:11 +0200 Subject: [PATCH] test(server): seed the journey domain spec through the factories The journey domain suite now writes its photos, entries, contributors, trip links, plan edits and place changes and reads back what a call left through the ORM factories. Three small readers keep the shape a statement's .get()/.all() handed back: one() answers undefined when no row matches, pickOne()/pickAll() return just the named columns, and a count stays { n }, so every toBeDefined, toBeUndefined and toEqual keeps its meaning. The helpers that wrote or read rows (photoOnEntry, photoOn, insertEntry, orderOf, daysOf, skeletonFor, skeletonsFor, skeletonsOf, withGeometry) are async and awaited where they are used, and the M5d parity seed binds its values in the old column order through columnsOf. Kept raw and marked: the legacy statements of both repository parity blocks (JG8, JG14, JG17, JG18, JG34 and the LEG set) and the unguarded delete the JG119 mutation proof measures the guard against. --- .../unit/nest/journey-domain.service.test.ts | 735 ++++++++---------- 1 file changed, 337 insertions(+), 398 deletions(-) diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index b3c98c37a..851a6c566 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -60,6 +60,50 @@ import { // ported from the reviewer's probe (scratchpad r3g/probe/zz-r3g-parity-probe.test.ts). import { todayUtc } from '@trek/shared'; import { GALLERY_CHRONOLOGICAL_ORDER } from '../../helpers/legacy-gallery-order'; +import type { EntityClass, EntityDTO, FilterQuery, FindOptions, RequiredEntityData } from '@mikro-orm/core'; +import { countRows, deleteRows, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { JourneyContributors } from '../../../src/db/entities/JourneyContributors.entity'; +import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; +import { JourneyEntryPhotos } from '../../../src/db/entities/JourneyEntryPhotos.entity'; +import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity'; +import { JourneyTrips } from '../../../src/db/entities/JourneyTrips.entity'; +import { Journeys } from '../../../src/db/entities/Journeys.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; + +const orm = () => sharedTestOrm(testDb); + +/** The first matching row, or undefined when none matches, as a statement's .get() handed it back. */ +async function one(entity: EntityClass, where: FilterQuery, orderBy?: FindOptions['orderBy']) { + return (await findRows(await orm(), entity, where, orderBy))[0] as EntityDTO | undefined; +} + +/** A row from parallel lists of property names and values. */ +function columnsOf(keys: readonly string[], values: readonly unknown[]): RequiredEntityData { + return Object.fromEntries(keys.map((k, i) => [k, values[i]])) as RequiredEntityData; +} + +function pick(row: T, keys: readonly K[]): Pick { + return Object.fromEntries(keys.map((k) => [k, row[k]])) as Pick; +} + +/** The named columns of the first matching row, or undefined when none matches. */ +async function pickOne>( + entity: EntityClass, where: FilterQuery, keys: readonly K[], orderBy?: FindOptions['orderBy'], +): Promise, K> | undefined> { + const row = await one(entity, where, orderBy); + return row && pick(row, keys); +} + +/** The named columns of every matching row. */ +async function pickAll>( + entity: EntityClass, where: FilterQuery, keys: readonly K[], orderBy?: FindOptions['orderBy'], +): Promise, K>[]> { + return (await findRows(await orm(), entity, where, orderBy)).map((row) => pick(row, keys)); +} let svc: JourneyDomainService; // Plan 3g Task 1's own additions (below, "repositories (R9's parity + mutation @@ -86,7 +130,7 @@ let placesRepoDirect: Awaited>; // stays a constructor param and is still exercised directly: Part B's methods // (journey stats, entries CRUD, the photos surface, contributors CRUD, // suggestions — Task 2's own, unconverted by this task) still issue raw -// `this.db.prepare(...)` calls the ~40 `describe` blocks below covering them +// raw `this.db` statements the ~40 `describe` blocks below covering them // exercise unchanged. Every test in this file calls `svc.()` directly // with no wrapper — this single `beforeAll` construction is the whole of // R9's "fix the constructor call for the entire file in one pass" edit; nothing @@ -285,9 +329,7 @@ describe('createJourney (service)', () => { expect(journey.status).toBe('active'); // owner should be added as contributor - const contrib = testDb.prepare( - 'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?' - ).get(journey.id, user.id) as { role: string } | undefined; + const contrib = await one(JourneyContributors, { journey: journey.id, user: user.id }) as { role: string } | undefined; expect(contrib).toBeDefined(); expect(contrib!.role).toBe('owner'); }); @@ -298,9 +340,7 @@ describe('createJourney (service)', () => { const journey = await svc.createJourney(user.id, { title: 'Euro Trip', trip_ids: [trip.id] }); - const link = testDb.prepare( - 'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?' - ).get(journey.id, trip.id); + const link = await one(JourneyTrips, { journey: journey.id, trip: trip.id }); expect(link).toBeDefined(); }); }); @@ -338,44 +378,42 @@ describe('getJourneyFull', () => { describe('placeEntriesFromPhotos (#1003)', () => { /** A geotagged (or not) photo on an entry, wired the way the upload wires it. */ - function photoOnEntry(journeyId: number, entryId: number, ownerId: number, coords: [number, number] | null, sort = 0) { - const tp = testDb.prepare("INSERT INTO trek_photos (provider, owner_id, file_path, lat, lng) VALUES ('local', ?, 'journey/x.jpg', ?, ?)") - .run(ownerId, coords?.[0] ?? null, coords?.[1] ?? null).lastInsertRowid as number; - const gp = testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) VALUES (?, ?, 1, 0, ?)') - .run(journeyId, tp, Date.now()).lastInsertRowid as number; - testDb.prepare('INSERT INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) VALUES (?, ?, ?, ?)').run(entryId, gp, sort, Date.now()); + async function photoOnEntry(journeyId: number, entryId: number, ownerId: number, coords: [number, number] | null, sort = 0) { + const tp = await insertRow(await orm(), TrekPhotos, { provider: 'local', owner: ownerId, file_path: 'journey/x.jpg', lat: coords?.[0] ?? null, lng: coords?.[1] ?? null }); + const gp = await insertRow(await orm(), JourneyPhotos, { journey: journeyId, photo: tp, shared: 1, sort_order: 0, created_at: Date.now() }); + await insertRow(await orm(), JourneyEntryPhotos, { entry: entryId, journeyPhoto: gp, sort_order: sort, created_at: Date.now() }); return tp; } it('JOURNEY-SVC-1003-1: with the setting on, a placeless entry takes the position of its first geotagged photo, once', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - testDb.prepare('UPDATE journeys SET photo_location = 1 WHERE id = ?').run(journey.id); + await updateRows(await orm(), Journeys, { id: journey.id }, { photo_location: 1 }); const entry = createJourneyEntry(testDb, journey.id, user.id); - const blank = photoOnEntry(journey.id, entry.id, user.id, null, 0); - const first = photoOnEntry(journey.id, entry.id, user.id, [48.137, 11.575], 1); - const second = photoOnEntry(journey.id, entry.id, user.id, [40.4, -3.7], 2); + const blank = await photoOnEntry(journey.id, entry.id, user.id, null, 0); + const first = await photoOnEntry(journey.id, entry.id, user.id, [48.137, 11.575], 1); + const second = await photoOnEntry(journey.id, entry.id, user.id, [40.4, -3.7], 2); expect(await svc.placeEntriesFromPhotos([blank, first, second])).toEqual([{ entryId: entry.id, journeyId: journey.id, lat: 48.137, lng: 11.575 }]); - const row = testDb.prepare('SELECT location_lat, location_lng, country_code FROM journey_entries WHERE id = ?').get(entry.id) as any; + const row = await pickOne(JourneyEntries, { id: entry.id }, ['location_lat', 'location_lng', 'country_code']) as any; expect(row).toMatchObject({ location_lat: 48.137, location_lng: 11.575 }); // Placed now: a later photo does not move it, and a name only lands while there is none. expect(await svc.placeEntriesFromPhotos([second])).toEqual([]); await svc.nameEntryLocation(entry.id, 'Marienplatz'); await svc.nameEntryLocation(entry.id, 'Somewhere else'); - expect((testDb.prepare('SELECT location_name FROM journey_entries WHERE id = ?').get(entry.id) as any).location_name).toBe('Marienplatz'); + expect((await pickOne(JourneyEntries, { id: entry.id }, ['location_name']) as any).location_name).toBe('Marienplatz'); }); it('JOURNEY-SVC-1003-2: off by default, and it leaves entries with a place alone', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); - const photo = photoOnEntry(journey.id, entry.id, user.id, [1, 2]); + const photo = await photoOnEntry(journey.id, entry.id, user.id, [1, 2]); expect(await svc.placeEntriesFromPhotos([photo])).toEqual([]); - testDb.prepare('UPDATE journeys SET photo_location = 1 WHERE id = ?').run(journey.id); - testDb.prepare('UPDATE journey_entries SET location_lat = 5, location_lng = 6 WHERE id = ?').run(entry.id); + await updateRows(await orm(), Journeys, { id: journey.id }, { photo_location: 1 }); + await updateRows(await orm(), JourneyEntries, { id: entry.id }, { location_lat: 5, location_lng: 6 }); expect(await svc.placeEntriesFromPhotos([photo])).toEqual([]); expect(await svc.placeEntriesFromPhotos([])).toEqual([]); }); @@ -479,8 +517,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const { user: editor } = createUser(testDb, { username: 'tracks-editor' }); const journey = createJourney(testDb, user.id, { title: 'Norway' }); - testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)") - .run(journey.id, editor.id); + await insertRow(await orm(), JourneyContributors, { journey: journey.id, user: editor.id, role: 'editor', added_at: 0 }); // Journey-level settings stay owner-only, same as title and status. expect(await svc.updateJourney(journey.id, editor.id, { show_trip_tracks: true })).toBeNull(); @@ -495,7 +532,7 @@ describe('deleteJourney', () => { const result = await svc.deleteJourney(journey.id, user.id); expect(result).toBe(true); - const row = testDb.prepare('SELECT * FROM journeys WHERE id = ?').get(journey.id); + const row = await one(Journeys, { id: journey.id }); expect(row).toBeUndefined(); }); @@ -508,7 +545,7 @@ describe('deleteJourney', () => { const result = await svc.deleteJourney(journey.id, editor.id); expect(result).toBe(false); - const row = testDb.prepare('SELECT * FROM journeys WHERE id = ?').get(journey.id); + const row = await one(Journeys, { id: journey.id }); expect(row).toBeDefined(); }); }); @@ -524,9 +561,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { const result = await svc.addTripToJourney(journey.id, trip.id, user.id); expect(result).toBe(true); - const link = testDb.prepare( - 'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?' - ).get(journey.id, trip.id); + const link = await one(JourneyTrips, { journey: journey.id, trip: trip.id }); expect(link).toBeDefined(); }); @@ -540,9 +575,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { const result = await svc.addTripToJourney(journey.id, foreignTrip.id, user.id); expect(result).toBe(false); - const link = testDb.prepare( - 'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?' - ).get(journey.id, foreignTrip.id); + const link = await one(JourneyTrips, { journey: journey.id, trip: foreignTrip.id }); expect(link).toBeUndefined(); }); @@ -555,14 +588,12 @@ describe('addTripToJourney / removeTripFromJourney', () => { end_date: '2026-03-03', }); const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); - const day025 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day025 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day025.id, place.id); await svc.addTripToJourney(journey.id, trip.id, user.id); - const skeletons = testDb.prepare( - "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" - ).all(journey.id, place.id); + const skeletons = await findRows(await orm(), JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' }); expect(skeletons.length).toBe(1); }); @@ -575,9 +606,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { const result = await svc.removeTripFromJourney(journey.id, trip.id, user.id); expect(result).toBe(true); - const link = testDb.prepare( - 'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?' - ).get(journey.id, trip.id); + const link = await one(JourneyTrips, { journey: journey.id, trip: trip.id }); expect(link).toBeUndefined(); }); @@ -786,7 +815,7 @@ describe('updateEntry', () => { expect(updated!.tags).toEqual([]); expect(updated!.pros_cons).toBeNull(); - const row = testDb.prepare('SELECT tags, pros_cons FROM journey_entries WHERE id = ?').get(entry.id) as { tags: string | null; pros_cons: string | null }; + const row = await pickOne(JourneyEntries, { id: entry.id }, ['tags', 'pros_cons']) as { tags: string | null; pros_cons: string | null }; expect(row.tags).toBeNull(); expect(row.pros_cons).toBeNull(); @@ -842,27 +871,27 @@ describe('updateEntry', () => { expect(created!.is_draft).toBe(true); const published = await svc.updateEntry(created!.id, user.id, { is_draft: false }); expect(published!.is_draft).toBe(false); - expect((testDb.prepare('SELECT is_draft FROM journey_entries WHERE id = ?').get(created!.id) as { is_draft: number }).is_draft).toBe(0); + expect((await pickOne(JourneyEntries, { id: created!.id }, ['is_draft']) as { is_draft: number }).is_draft).toBe(0); }); it('switches a stop off and back on, and answers with the flag as a boolean', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const flagOf = (id: number) => - (testDb.prepare('SELECT stats_excluded FROM journey_entries WHERE id = ?').get(id) as { stats_excluded: number }).stats_excluded; + const flagOf = async (id: number) => + (await pickOne(JourneyEntries, { id: id }, ['stats_excluded']) as { stats_excluded: number }).stats_excluded; const spy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {}); try { const off = await svc.updateEntry(entry.id, user.id, { stats_excluded: true }); expect(off!.stats_excluded).toBe(true); - expect(flagOf(entry.id)).toBe(1); + expect(await flagOf(entry.id)).toBe(1); const payload = spy.mock.calls.at(-1)?.[1] as { type: string; entry: { stats_excluded: unknown } }; expect(payload.entry.stats_excluded).toBe(true); const on = await svc.updateEntry(entry.id, user.id, { stats_excluded: false }); expect(on!.stats_excluded).toBe(false); - expect(flagOf(entry.id)).toBe(0); + expect(await flagOf(entry.id)).toBe(0); } finally { spy.mockRestore(); } @@ -954,7 +983,7 @@ describe('deleteEntry', () => { const result = await svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); - const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id); + const row = await one(JourneyEntries, { id: entry.id }); expect(row).toBeUndefined(); }); @@ -982,17 +1011,14 @@ describe('deleteEntry', () => { // Create a filled entry that originated from a trip skeleton const now = Date.now(); - testDb.prepare(` - INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, mood, entry_date, location_name, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, 'entry', 'Tokyo Tower', 'Amazing view!', 'amazing', '2026-03-01', 'Tokyo', 'private', 0, ?, ?) - `).run(journey.id, trip.id, place.id, user.id, now, now); - const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any; + await insertRow(await orm(), JourneyEntries, { journey: journey.id, sourceTrip: trip.id, sourcePlace: place.id, author: user.id, type: 'entry', title: 'Tokyo Tower', story: 'Amazing view!', mood: 'amazing', entry_date: '2026-03-01', location_name: 'Tokyo', visibility: 'private', sort_order: 0, created_at: now, updated_at: now }); + const entry = await one(JourneyEntries, { journey: journey.id, sourcePlace: place.id }) as any; const result = await svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); // Entry should still exist but reverted to skeleton - const reverted = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id) as any; + const reverted = await one(JourneyEntries, { id: entry.id }) as any; expect(reverted).toBeDefined(); expect(reverted.type).toBe('skeleton'); expect(reverted.story).toBeNull(); @@ -1010,7 +1036,7 @@ describe('deleteEntry', () => { const result = await svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); - const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id); + const row = await one(JourneyEntries, { id: entry.id }); expect(row).toBeUndefined(); }); }); @@ -1074,7 +1100,7 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { expect(deleted).not.toBeNull(); expect(deleted!.id).toBe(photo!.id); - const row = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id); + const row = await one(JourneyPhotos, { id: photo!.id }); expect(row).toBeUndefined(); }); @@ -1108,9 +1134,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const result = await svc.addContributor(journey.id, owner.id, newContrib.id, 'editor'); expect(result).toBe(true); - const row = testDb.prepare( - 'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?' - ).get(journey.id, newContrib.id) as { role: string } | undefined; + const row = await one(JourneyContributors, { journey: journey.id, user: newContrib.id }) as { role: string } | undefined; expect(row).toBeDefined(); expect(row!.role).toBe('editor'); }); @@ -1145,9 +1169,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const result = await svc.updateContributorRole(journey.id, owner.id, contrib.id, 'editor'); expect(result).toBe(true); - const row = testDb.prepare( - 'SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?' - ).get(journey.id, contrib.id) as { role: string }; + const row = await pickOne(JourneyContributors, { journey: journey.id, user: contrib.id }, ['role']) as { role: string }; expect(row.role).toBe('editor'); }); @@ -1181,7 +1203,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const result = await svc.updateContributorRole(journey.id, owner.id, target.id, 'owner' as unknown as 'editor' | 'viewer'); expect(result).toBe(true); - const row = testDb.prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(journey.id, target.id) as { role: string }; + const row = await pickOne(JourneyContributors, { journey: journey.id, user: target.id }, ['role']) as { role: string }; expect(row.role).toBe('owner'); }); @@ -1194,9 +1216,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const result = await svc.removeContributor(journey.id, owner.id, contrib.id); expect(result).toBe(true); - const row = testDb.prepare( - 'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?' - ).get(journey.id, contrib.id); + const row = await one(JourneyContributors, { journey: journey.id, user: contrib.id }); expect(row).toBeUndefined(); }); @@ -1208,9 +1228,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { // (the SQL filters role != 'owner') await svc.removeContributor(journey.id, owner.id, owner.id); - const row = testDb.prepare( - 'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?' - ).get(journey.id, owner.id); + const row = await one(JourneyContributors, { journey: journey.id, user: owner.id }); expect(row).toBeDefined(); }); }); @@ -1231,16 +1249,14 @@ describe('R3 composite-PK writes — reset-on-conflict behaviour and rendered SQ await svc.addContributor(journey.id, owner.id, contrib.id, 'editor'); await svc.updateJourneyPreferences(journey.id, contrib.id, { hide_skeletons: true }); expect( - (testDb.prepare('SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(journey.id, contrib.id) as { hide_skeletons: number }).hide_skeletons, + (await pickOne(JourneyContributors, { journey: journey.id, user: contrib.id }, ['hide_skeletons']) as { hide_skeletons: number }).hide_skeletons, ).toBe(1); // Re-add (same target, a possibly different role) — the ON CONFLICT // merge branch of upsertContributor, exercised through the real service. await svc.addContributor(journey.id, owner.id, contrib.id, 'viewer'); - const row = testDb - .prepare('SELECT role, hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?') - .get(journey.id, contrib.id) as { role: string; hide_skeletons: number }; + const row = await pickOne(JourneyContributors, { journey: journey.id, user: contrib.id }, ['role', 'hide_skeletons']) as { role: string; hide_skeletons: number }; expect(row.role).toBe('viewer'); expect(row.hide_skeletons).toBe(0); }); @@ -1285,14 +1301,9 @@ describe('R3 composite-PK writes — reset-on-conflict behaviour and rendered SQ const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); - const trekResult = testDb - .prepare('INSERT INTO trek_photos (provider, file_path, owner_id, created_at) VALUES (?, ?, ?, ?)') - .run('local', '/photos/m5c.jpg', user.id, Date.now()); - const trekId = trekResult.lastInsertRowid as number; - const jpResult = testDb - .prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, ?, ?)') - .run(journey.id, trekId, 0, Date.now()); - const journeyPhotoId = jpResult.lastInsertRowid as number; + // The epoch-ms string lands as the integer the old statement bound: the column has numeric affinity. + const trekId = await insertRow(await orm(), TrekPhotos, { provider: 'local', file_path: '/photos/m5c.jpg', owner: user.id, created_at: String(Date.now()) }); + const journeyPhotoId = await insertRow(await orm(), JourneyPhotos, { journey: journey.id, photo: trekId, sort_order: 0, created_at: Date.now() }); const connection = entryPhotosRepoDirect.getEntityManager().getConnection(); const spy = vi.spyOn(connection, 'execute'); @@ -1383,15 +1394,13 @@ describe('syncTripPlaces', () => { }); const place1 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); const place2 = createPlace(testDb, trip.id, { name: 'Louvre' }); - const days055 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[]; + const days055 = (await pickAll(Days, { trip: trip.id }, ['id'], { date: 'asc' })).slice(0, 2) as { id: number }[]; createDayAssignment(testDb, days055[0].id, place1.id); createDayAssignment(testDb, days055[1].id, place2.id); await svc.syncTripPlaces(journey.id, trip.id, user.id); - const skeletons = testDb.prepare( - "SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'" - ).all(journey.id) as any[]; + const skeletons = await findRows(await orm(), JourneyEntries, { journey: journey.id, type: 'skeleton' }) as any[]; expect(skeletons.length).toBe(2); const names = skeletons.map((s: any) => s.title).sort(); expect(names).toEqual(['Eiffel Tower', 'Louvre']); @@ -1406,15 +1415,13 @@ describe('syncTripPlaces', () => { end_date: '2026-05-02', }); const place056 = createPlace(testDb, trip.id, { name: 'Notre Dame' }); - const day056 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day056 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day056.id, place056.id); await svc.syncTripPlaces(journey.id, trip.id, user.id); await svc.syncTripPlaces(journey.id, trip.id, user.id); // second call - const skeletons = testDb.prepare( - "SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'" - ).all(journey.id); + const skeletons = await findRows(await orm(), JourneyEntries, { journey: journey.id, type: 'skeleton' }); expect(skeletons.length).toBe(1); }); @@ -1427,17 +1434,13 @@ describe('syncTripPlaces', () => { start_date: '2026-06-10', end_date: '2026-06-12', }); - const day = testDb.prepare( - "SELECT * FROM days WHERE trip_id = ? AND date = '2026-06-11'" - ).get(trip.id) as { id: number }; + const day = await one(Days, { trip: trip.id, date: '2026-06-11' }) as { id: number }; const place = createPlace(testDb, trip.id, { name: 'Colosseum' }); createDayAssignment(testDb, day.id, place.id); await svc.syncTripPlaces(journey.id, trip.id, user.id); - const skeleton = testDb.prepare( - "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" - ).get(journey.id, place.id) as any; + const skeleton = await one(JourneyEntries, { journey: journey.id, sourcePlace: place.id }) as any; expect(skeleton).toBeDefined(); expect(skeleton.entry_date).toBe('2026-06-11'); }); @@ -1458,13 +1461,11 @@ describe('onPlaceCreated', () => { // Create a new place after trip is linked const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' }); - const day058 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day058 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day058.id, place.id); await svc.onPlaceCreated(trip.id, place.id); - const skeleton = testDb.prepare( - "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" - ).get(journey.id, place.id); + const skeleton = await one(JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' }); expect(skeleton).toBeDefined(); }); @@ -1475,9 +1476,7 @@ describe('onPlaceCreated', () => { await svc.onPlaceCreated(trip.id, place.id); - const entries = testDb.prepare( - "SELECT * FROM journey_entries WHERE source_place_id = ?" - ).all(place.id); + const entries = await findRows(await orm(), JourneyEntries, { sourcePlace: place.id }); expect(entries.length).toBe(0); }); @@ -1492,14 +1491,12 @@ describe('onPlaceCreated', () => { await svc.addTripToJourney(journey.id, trip.id, user.id); const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' }); - const day060 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day060 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day060.id, place.id); await svc.onPlaceCreated(trip.id, place.id); await svc.onPlaceCreated(trip.id, place.id); // second call - const entries = testDb.prepare( - "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" - ).all(journey.id, place.id); + const entries = await findRows(await orm(), JourneyEntries, { journey: journey.id, sourcePlace: place.id }); expect(entries.length).toBe(1); }); }); @@ -1514,17 +1511,15 @@ describe('onPlaceUpdated', () => { end_date: '2026-08-03', }); const place = createPlace(testDb, trip.id, { name: 'Old Name' }); - const day061 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day061 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day061.id, place.id); await svc.addTripToJourney(journey.id, trip.id, user.id); // Update the place name directly in DB - testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('New Name', 'New Address', place.id); + await updateRows(await orm(), Places, { id: place.id }, { name: 'New Name', address: 'New Address' }); await svc.onPlaceUpdated(place.id); - const entry = testDb.prepare( - "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" - ).get(journey.id, place.id) as any; + const entry = await one(JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' }) as any; expect(entry).toBeDefined(); expect(entry.title).toBe('New Name'); expect(entry.location_name).toBe('New Address'); @@ -1539,23 +1534,19 @@ describe('onPlaceUpdated', () => { end_date: '2026-08-02', }); const place = createPlace(testDb, trip.id, { name: 'Original Place' }); - const day062 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day062 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day062.id, place.id); await svc.addTripToJourney(journey.id, trip.id, user.id); // Promote the skeleton to a full entry - const skeleton = testDb.prepare( - "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" - ).get(journey.id, place.id) as { id: number }; + const skeleton = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id }, ['id']) as { id: number }; await svc.updateEntry(skeleton.id, user.id, { story: 'My story', title: 'Custom Title' }); // Now update the place - testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('Changed Place', 'Changed Addr', place.id); + await updateRows(await orm(), Places, { id: place.id }, { name: 'Changed Place', address: 'Changed Addr' }); await svc.onPlaceUpdated(place.id); - const entry = testDb.prepare( - "SELECT * FROM journey_entries WHERE id = ?" - ).get(skeleton.id) as any; + const entry = await one(JourneyEntries, { id: skeleton.id }) as any; expect(entry.title).toBe('Custom Title'); // title unchanged expect(entry.location_name).toBe('Changed Addr'); // location updated }); @@ -1568,9 +1559,7 @@ describe('onPlaceUpdated', () => { // Should not throw await svc.onPlaceUpdated(place.id); - const entries = testDb.prepare( - "SELECT * FROM journey_entries WHERE source_place_id = ?" - ).all(place.id); + const entries = await findRows(await orm(), JourneyEntries, { sourcePlace: place.id }); expect(entries.length).toBe(0); }); }); @@ -1589,9 +1578,7 @@ describe('onPlaceDeleted', () => { await svc.onPlaceDeleted(place.id); - const entry = testDb.prepare( - "SELECT * FROM journey_entries WHERE source_place_id = ?" - ).get(place.id); + const entry = await one(JourneyEntries, { sourcePlace: place.id }); expect(entry).toBeUndefined(); }); @@ -1604,21 +1591,17 @@ describe('onPlaceDeleted', () => { end_date: '2026-09-02', }); const place = createPlace(testDb, trip.id, { name: 'Detach Place' }); - const day065 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day065 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day065.id, place.id); await svc.addTripToJourney(journey.id, trip.id, user.id); // Promote the skeleton to a filled entry - const skeleton = testDb.prepare( - "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" - ).get(journey.id, place.id) as { id: number }; + const skeleton = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id }, ['id']) as { id: number }; await svc.updateEntry(skeleton.id, user.id, { story: 'I really enjoyed this place' }); await svc.onPlaceDeleted(place.id); - const entry = testDb.prepare( - "SELECT * FROM journey_entries WHERE id = ?" - ).get(skeleton.id) as any; + const entry = await one(JourneyEntries, { id: skeleton.id }) as any; expect(entry).toBeDefined(); expect(entry.source_place_id).toBeNull(); expect(entry.source_trip_id).toBeNull(); @@ -1632,9 +1615,7 @@ describe('onPlaceDeleted', () => { await expect(svc.onPlaceDeleted(place.id)).resolves.toBeUndefined(); - const orphaned = testDb.prepare( - "SELECT COUNT(*) AS n FROM journey_entries WHERE source_place_id = ?" - ).get(place.id) as { n: number }; + const orphaned = { n: await countRows(await orm(), JourneyEntries, { sourcePlace: place.id }) } as { n: number }; expect(orphaned.n).toBe(0); }); }); @@ -1708,14 +1689,11 @@ describe('setPhotoProvider', () => { await svc.setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id); - const updated = testDb.prepare(` - SELECT jp.*, tkp.provider, tkp.asset_id, tkp.owner_id - FROM journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id - WHERE jp.id = ? - `).get(photo!.id) as any; - expect(updated.provider).toBe('immich'); - expect(updated.asset_id).toBe('immich-asset-789'); - expect(updated.owner_id).toBe(user.id); + const journeyPhoto = await one(JourneyPhotos, { id: photo!.id }); + const updated = await one(TrekPhotos, { id: journeyPhoto!.photo_id }); + expect(updated!.provider).toBe('immich'); + expect(updated!.asset_id).toBe('immich-asset-789'); + expect(updated!.owner_id).toBe(user.id); }); }); @@ -1827,7 +1805,7 @@ describe('Edge cases', () => { // Junction row must be gone (ON DELETE CASCADE from journey_entries). // Gallery row (journey_photos) is preserved — photo may belong to other entries. - const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any; + const junctionRow = await one(JourneyEntryPhotos, { entry: entry.id }) as any; expect(junctionRow).toBeUndefined(); }); @@ -1863,7 +1841,7 @@ describe('Edge cases', () => { expect(entry).not.toBeNull(); // Read raw from DB - const raw = testDb.prepare('SELECT tags, pros_cons FROM journey_entries WHERE id = ?').get(entry!.id) as any; + const raw = await pickOne(JourneyEntries, { id: entry!.id }, ['tags', 'pros_cons']) as any; expect(JSON.parse(raw.tags)).toEqual(['food', 'culture']); expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Great view'], cons: ['Expensive'] }); }); @@ -1879,7 +1857,7 @@ describe('Edge cases', () => { }); expect(result).not.toBeNull(); - const raw = testDb.prepare('SELECT tags, pros_cons FROM journey_entries WHERE id = ?').get(entry.id) as any; + const raw = await pickOne(JourneyEntries, { id: entry.id }, ['tags', 'pros_cons']) as any; expect(JSON.parse(raw.tags)).toEqual(['beach', 'adventure']); expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Fun'], cons: [] }); }); @@ -1900,7 +1878,7 @@ describe('Edge cases', () => { expect(await svc.addTripToJourney(journey.id, trip.id, user.id)).toBe(true); - const photos = testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id); + const photos = await findRows(await orm(), JourneyPhotos, { journey: journey.id }); expect(photos).toHaveLength(0); }); @@ -1914,29 +1892,23 @@ describe('Edge cases', () => { }); const place1 = createPlace(testDb, trip.id, { name: 'Skeleton Place' }); const place2 = createPlace(testDb, trip.id, { name: 'Filled Place' }); - const days087 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[]; + const days087 = (await pickAll(Days, { trip: trip.id }, ['id'], { date: 'asc' })).slice(0, 2) as { id: number }[]; createDayAssignment(testDb, days087[0].id, place1.id); createDayAssignment(testDb, days087[1].id, place2.id); await svc.addTripToJourney(journey.id, trip.id, user.id); // Promote one skeleton to a filled entry - const filled = testDb.prepare( - "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" - ).get(journey.id, place2.id) as { id: number }; + const filled = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place2.id, type: 'skeleton' }, ['id']) as { id: number }; await svc.updateEntry(filled.id, user.id, { story: 'Now filled!' }); await svc.removeTripFromJourney(journey.id, trip.id, user.id); // skeleton for place1 should be deleted - const skeletonRow = testDb.prepare( - "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" - ).get(journey.id, place1.id); + const skeletonRow = await one(JourneyEntries, { journey: journey.id, sourcePlace: place1.id }); expect(skeletonRow).toBeUndefined(); // filled entry for place2 should be detached but still present - const filledRow = testDb.prepare( - "SELECT * FROM journey_entries WHERE id = ?" - ).get(filled.id) as any; + const filledRow = await one(JourneyEntries, { id: filled.id }) as any; expect(filledRow).toBeDefined(); expect(filledRow.source_trip_id).toBeNull(); expect(filledRow.source_place_id).toBeNull(); @@ -1955,8 +1927,7 @@ describe('addProviderPhoto — passphrase', () => { expect(photo).not.toBeNull(); - const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?') - .get('synologyphotos', 'pp-asset-1', user.id) as { passphrase: string | null } | undefined; + const row = await pickOne(TrekPhotos, { provider: 'synologyphotos', asset_id: 'pp-asset-1', owner: user.id }, ['passphrase']) as { passphrase: string | null } | undefined; expect(row?.passphrase).not.toBeNull(); expect(typeof row?.passphrase).toBe('string'); // stored value must be encrypted (not plaintext) @@ -1966,46 +1937,44 @@ describe('addProviderPhoto — passphrase', () => { // -- reorderEntries (#846) ---------------------------------------------------- -function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } { +async function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): Promise<{ id: number }> { const now = Date.now(); - const res = testDb.prepare(` - INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at) - VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?) - `).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now); - return { id: Number(res.lastInsertRowid) }; + const id = await insertRow(await orm(), JourneyEntries, { journey: journeyId, author: authorId, type: 'entry', entry_date: opts.entry_date, entry_time: opts.entry_time ?? null, sort_order: opts.sort_order ?? 0, visibility: 'private', created_at: now, updated_at: now }); + return { id }; } describe('reorderEntryPhotos (#824)', () => { - function photoOn(journeyId: number, entryIds: number[], order = 0): number { - const trek = testDb.prepare("INSERT INTO trek_photos (provider, file_path, created_at) VALUES ('local', '/p.jpg', ?)").run(Date.now()).lastInsertRowid; - const gp = Number(testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, 0, ?)').run(journeyId, trek, Date.now()).lastInsertRowid); - for (const entryId of entryIds) testDb.prepare('INSERT INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) VALUES (?, ?, ?, ?)').run(entryId, gp, order, Date.now()); + async function photoOn(journeyId: number, entryIds: number[], order = 0): Promise { + // The epoch-ms string lands as the integer the old statement bound: the column has numeric affinity. + const trek = await insertRow(await orm(), TrekPhotos, { provider: 'local', file_path: '/p.jpg', created_at: String(Date.now()) }); + const gp = await insertRow(await orm(), JourneyPhotos, { journey: journeyId, photo: trek, sort_order: 0, created_at: Date.now() }); + for (const entryId of entryIds) await insertRow(await orm(), JourneyEntryPhotos, { entry: entryId, journeyPhoto: gp, sort_order: order, created_at: Date.now() }); return gp; } - const orderOf = (entryId: number) => - (testDb.prepare('SELECT journey_photo_id FROM journey_entry_photos WHERE entry_id = ? ORDER BY sort_order').all(entryId) as { journey_photo_id: number }[]).map(r => r.journey_photo_id); + const orderOf = async (entryId: number) => + (await pickAll(JourneyEntryPhotos, { entry: entryId }, ['journey_photo_id'], { sort_order: 'asc' }) as { journey_photo_id: number }[]).map(r => r.journey_photo_id); it('JOURNEY-SVC-089b: orders the photos of one entry and leaves the same photo elsewhere alone', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01' }); - const b = insertEntry(journey.id, user.id, { entry_date: '2026-08-02' }); - const p1 = photoOn(journey.id, [a.id, b.id], 0); - const p2 = photoOn(journey.id, [a.id], 1); - const p3 = photoOn(journey.id, [a.id], 2); + const a = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01' }); + const b = await insertEntry(journey.id, user.id, { entry_date: '2026-08-02' }); + const p1 = await photoOn(journey.id, [a.id, b.id], 0); + const p2 = await photoOn(journey.id, [a.id], 1); + const p3 = await photoOn(journey.id, [a.id], 2); expect(await svc.reorderEntryPhotos(a.id, user.id, [p3, p1, p2])).toBe(true); - expect(orderOf(a.id)).toEqual([p3, p1, p2]); - expect(testDb.prepare('SELECT sort_order FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?').get(b.id, p1)).toEqual({ sort_order: 0 }); + expect(await orderOf(a.id)).toEqual([p3, p1, p2]); + expect(await pickOne(JourneyEntryPhotos, { entry: b.id, journeyPhoto: p1 }, ['sort_order'])).toEqual({ sort_order: 0 }); }); it('JOURNEY-SVC-089c: refuses a list that is not exactly the photos of the entry, and a stranger', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, user.id); - const a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01' }); - const p1 = photoOn(journey.id, [a.id]); - const p2 = photoOn(journey.id, [a.id]); - const other = photoOn(journey.id, []); + const a = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01' }); + const p1 = await photoOn(journey.id, [a.id]); + const p2 = await photoOn(journey.id, [a.id]); + const other = await photoOn(journey.id, []); expect(await svc.reorderEntryPhotos(a.id, user.id, [p1])).toBe(false); expect(await svc.reorderEntryPhotos(a.id, user.id, [p1, other])).toBe(false); expect(await svc.reorderEntryPhotos(a.id, user.id, [p1, p1])).toBe(false); @@ -2018,8 +1987,8 @@ describe('reorderEntries', () => { it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 }); - const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 }); + const e1 = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 }); + const e2 = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 }); const ok = await svc.reorderEntries(journey.id, user.id, [e2.id, e1.id]); expect(ok).toBe(true); @@ -2042,9 +2011,9 @@ describe('reorderEntries', () => { it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 }); - const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 }); - const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 }); + const day1a = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 }); + const day1b = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 }); + const day2 = await insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 }); await svc.reorderEntries(journey.id, user.id, [day1b.id, day1a.id]); @@ -2063,7 +2032,7 @@ describe('syncTripPlaces sort_order', () => { start_date: '2026-09-01', end_date: '2026-09-02', }); - const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; const p1 = createPlace(testDb, trip.id, { name: 'Place A' }); const p2 = createPlace(testDb, trip.id, { name: 'Place B' }); const p3 = createPlace(testDb, trip.id, { name: 'Place C' }); @@ -2073,9 +2042,7 @@ describe('syncTripPlaces sort_order', () => { await svc.syncTripPlaces(journey.id, trip.id, user.id); - const rows = testDb.prepare( - 'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC' - ).all(journey.id) as { sort_order: number }[]; + const rows = await pickAll(JourneyEntries, { journey: journey.id }, ['sort_order'], { sort_order: 'asc' }) as { sort_order: number }[]; const orders = rows.map(r => r.sort_order); expect(new Set(orders).size).toBe(orders.length); expect(orders).toEqual([0, 1, 2]); @@ -2093,16 +2060,14 @@ describe('onPlaceCreated sort_order', () => { }); await svc.addTripToJourney(journey.id, trip.id, user.id); - const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string }; - insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 }); + const day = await pickOne(Days, { trip: trip.id }, ['id', 'date'], { date: 'asc' }) as { id: number; date: string }; + await insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 }); const place = createPlace(testDb, trip.id, { name: 'Late Addition' }); createDayAssignment(testDb, day.id, place.id); await svc.onPlaceCreated(trip.id, place.id); - const newEntry = testDb.prepare( - 'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?' - ).get(journey.id, place.id) as { sort_order: number } | undefined; + const newEntry = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id }, ['sort_order']) as { sort_order: number } | undefined; expect(newEntry).toBeDefined(); expect(newEntry!.sort_order).toBe(6); }); @@ -2124,28 +2089,26 @@ describe('reconcileTripSkeletons', () => { return { user, journey, trip }; } - function daysOf(tripId: number) { - return testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC').all(tripId) as { + async function daysOf(tripId: number) { + return await pickAll(Days, { trip: tripId }, ['id', 'date'], { date: 'asc' }) as { id: number; date: string; }[]; } - function skeletonFor(journeyId: number, placeId: number) { - return testDb - .prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?') - .get(journeyId, placeId) as any; + async function skeletonFor(journeyId: number, placeId: number) { + return await one(JourneyEntries, { journey: journeyId, sourcePlace: placeId }) as any; } it('JOURNEY-SVC-094: adds a skeleton for a newly assigned place', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'New Museum' }); createDayAssignment(testDb, days[0].id, place.id); await svc.reconcileTripSkeletons(trip.id); - const skeleton = skeletonFor(journey.id, place.id); + const skeleton = await skeletonFor(journey.id, place.id); expect(skeleton).toBeDefined(); expect(skeleton.type).toBe('skeleton'); expect(skeleton.title).toBe('New Museum'); @@ -2154,34 +2117,32 @@ describe('reconcileTripSkeletons', () => { it('JOURNEY-SVC-095: removes a pure skeleton when its place is unassigned', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'To Remove' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); await svc.reconcileTripSkeletons(trip.id); - expect(skeletonFor(journey.id, place.id)).toBeDefined(); + expect(await skeletonFor(journey.id, place.id)).toBeDefined(); - testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignment.id); + await deleteRows(await orm(), DayAssignments, { id: assignment.id }); await svc.reconcileTripSkeletons(trip.id); - expect(skeletonFor(journey.id, place.id)).toBeUndefined(); + expect(await skeletonFor(journey.id, place.id)).toBeUndefined(); }); it('JOURNEY-SVC-096: preserves a filled entry on unassign (detaches + notes it)', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Filled Place' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); await svc.reconcileTripSkeletons(trip.id); - const skeleton = skeletonFor(journey.id, place.id); + const skeleton = await skeletonFor(journey.id, place.id); // Promote to a filled entry with content. - testDb - .prepare("UPDATE journey_entries SET type = 'entry', story = 'A wonderful visit' WHERE id = ?") - .run(skeleton.id); + await updateRows(await orm(), JourneyEntries, { id: skeleton.id }, { type: 'entry', story: 'A wonderful visit' }); - testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignment.id); + await deleteRows(await orm(), DayAssignments, { id: assignment.id }); await svc.reconcileTripSkeletons(trip.id); - const kept = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(skeleton.id) as any; + const kept = await one(JourneyEntries, { id: skeleton.id }) as any; expect(kept).toBeDefined(); expect(kept.type).toBe('entry'); expect(kept.source_place_id).toBeNull(); @@ -2192,32 +2153,28 @@ describe('reconcileTripSkeletons', () => { it('JOURNEY-SVC-097: refreshes skeleton entry_date when a place is moved to another day', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Moving Place' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); await svc.reconcileTripSkeletons(trip.id); - expect(skeletonFor(journey.id, place.id).entry_date).toBe(days[0].date); + expect((await skeletonFor(journey.id, place.id)).entry_date).toBe(days[0].date); - testDb.prepare('UPDATE day_assignments SET day_id = ? WHERE id = ?').run(days[1].id, assignment.id); + await updateRows(await orm(), DayAssignments, { id: assignment.id }, { day: days[1].id }); await svc.reconcileTripSkeletons(trip.id); - expect(skeletonFor(journey.id, place.id).entry_date).toBe(days[1].date); + expect((await skeletonFor(journey.id, place.id)).entry_date).toBe(days[1].date); }); it('JOURNEY-SVC-098: is idempotent — a second call makes no changes', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Stable Place' }); createDayAssignment(testDb, days[0].id, place.id); await svc.reconcileTripSkeletons(trip.id); - const before = testDb - .prepare('SELECT id, updated_at FROM journey_entries WHERE journey_id = ? ORDER BY id') - .all(journey.id) as { id: number; updated_at: number }[]; + const before = await pickAll(JourneyEntries, { journey: journey.id }, ['id', 'updated_at'], { id: 'asc' }) as { id: number; updated_at: number }[]; await svc.reconcileTripSkeletons(trip.id); - const after = testDb - .prepare('SELECT id, updated_at FROM journey_entries WHERE journey_id = ? ORDER BY id') - .all(journey.id) as { id: number; updated_at: number }[]; + const after = await pickAll(JourneyEntries, { journey: journey.id }, ['id', 'updated_at'], { id: 'asc' }) as { id: number; updated_at: number }[]; expect(after).toEqual(before); }); @@ -2225,12 +2182,12 @@ describe('reconcileTripSkeletons', () => { it('JOURNEY-SVC-099: no-ops when the trip is linked to no journey', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Unlinked', start_date: '2026-05-01', end_date: '2026-05-02' }); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Orphan' }); createDayAssignment(testDb, days[0].id, place.id); await expect(svc.reconcileTripSkeletons(trip.id)).resolves.toBeUndefined(); - const anyEntry = testDb.prepare('SELECT COUNT(*) AS n FROM journey_entries').get() as { n: number }; + const anyEntry = { n: await countRows(await orm(), JourneyEntries, {}) } as { n: number }; expect(anyEntry.n).toBe(0); }); @@ -2243,7 +2200,7 @@ describe('reconcileTripSkeletons', () => { describe('concurrency (M2) — exactly one skeleton per (place, assignment) under a race', () => { it('two concurrent reconcileTripSkeletons(trip) calls after two new assignments produce exactly one skeleton per place, not two', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const placeA = createPlace(testDb, trip.id, { name: 'Race Place A' }); const placeB = createPlace(testDb, trip.id, { name: 'Race Place B' }); createDayAssignment(testDb, days[0].id, placeA.id); @@ -2254,15 +2211,14 @@ describe('reconcileTripSkeletons', () => { svc.reconcileTripSkeletons(trip.id), ]); - const countFor = (placeId: number) => - (testDb.prepare('SELECT COUNT(*) AS n FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, placeId) as { n: number }).n; - expect(countFor(placeA.id)).toBe(1); - expect(countFor(placeB.id)).toBe(1); + const countFor = async (placeId: number) => countRows(await orm(), JourneyEntries, { journey: journey.id, sourcePlace: placeId }); + expect(await countFor(placeA.id)).toBe(1); + expect(await countFor(placeB.id)).toBe(1); }); it('onPlaceCreated racing reconcileTripSkeletons for the same new assignment produces exactly one skeleton, not two', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Race Place C' }); createDayAssignment(testDb, days[0].id, place.id); @@ -2271,7 +2227,7 @@ describe('reconcileTripSkeletons', () => { svc.reconcileTripSkeletons(trip.id), ]); - const count = (testDb.prepare('SELECT COUNT(*) AS n FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as { n: number }).n; + const count = ({ n: await countRows(await orm(), JourneyEntries, { journey: journey.id, sourcePlace: place.id }) } as { n: number }).n; expect(count).toBe(1); }); }); @@ -2292,38 +2248,36 @@ describe('a place standing on more than one day', () => { return { user, journey, trip }; } - function daysOf(tripId: number) { - return testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC').all(tripId) as { + async function daysOf(tripId: number) { + return await pickAll(Days, { trip: tripId }, ['id', 'date'], { date: 'asc' }) as { id: number; date: string; }[]; } - function skeletonsFor(journeyId: number, placeId: number) { - return testDb - .prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? ORDER BY entry_date ASC') - .all(journeyId, placeId) as any[]; + async function skeletonsFor(journeyId: number, placeId: number) { + return await findRows(await orm(), JourneyEntries, { journey: journeyId, sourcePlace: placeId }, { entry_date: 'asc' }) as any[]; } it('JOURNEY-SVC-REPEAT-001: syncTripPlaces writes one skeleton per day, not one per place', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Two Nights', start_date: '2026-05-01', end_date: '2026-05-03' }); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Reykjavík' }); createDayAssignment(testDb, days[0].id, place.id); createDayAssignment(testDb, days[1].id, place.id); await svc.syncTripPlaces(journey.id, trip.id, user.id); - expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]); + expect((await skeletonsFor(journey.id, place.id)).map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]); }); it('JOURNEY-SVC-REPEAT-002: a second call adds nothing', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Idempotent', start_date: '2026-05-01', end_date: '2026-05-03' }); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Vík' }); createDayAssignment(testDb, days[0].id, place.id); createDayAssignment(testDb, days[1].id, place.id); @@ -2331,34 +2285,34 @@ describe('a place standing on more than one day', () => { await svc.syncTripPlaces(journey.id, trip.id, user.id); await svc.syncTripPlaces(journey.id, trip.id, user.id); - expect(skeletonsFor(journey.id, place.id)).toHaveLength(2); + expect(await skeletonsFor(journey.id, place.id)).toHaveLength(2); }); it('JOURNEY-SVC-REPEAT-003: onPlaceCreated fires once per day the place already stands on', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Höfn' }); createDayAssignment(testDb, days[1].id, place.id); createDayAssignment(testDb, days[2].id, place.id); await svc.onPlaceCreated(trip.id, place.id); - expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[1].date, days[2].date]); + expect((await skeletonsFor(journey.id, place.id)).map((e) => e.entry_date)).toEqual([days[1].date, days[2].date]); }); it('JOURNEY-SVC-REPEAT-004: assigning the place to a second day adds a second entry and keeps the first', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Akureyri' }); createDayAssignment(testDb, days[0].id, place.id); await svc.reconcileTripSkeletons(trip.id); - const first = skeletonsFor(journey.id, place.id)[0]; - testDb.prepare("UPDATE journey_entries SET type = 'entry', story = 'Sunset' WHERE id = ?").run(first.id); + const first = (await skeletonsFor(journey.id, place.id))[0]; + await updateRows(await orm(), JourneyEntries, { id: first.id }, { type: 'entry', story: 'Sunset' }); createDayAssignment(testDb, days[1].id, place.id); await svc.reconcileTripSkeletons(trip.id); - const both = skeletonsFor(journey.id, place.id); + const both = await skeletonsFor(journey.id, place.id); expect(both.map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]); expect(both[0].id).toBe(first.id); expect(both[0].story).toBe('Sunset'); @@ -2367,67 +2321,65 @@ describe('a place standing on more than one day', () => { it('JOURNEY-SVC-REPEAT-005: unassigning one day drops only that day', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Selfoss' }); createDayAssignment(testDb, days[0].id, place.id); const second = createDayAssignment(testDb, days[1].id, place.id); await svc.reconcileTripSkeletons(trip.id); - expect(skeletonsFor(journey.id, place.id)).toHaveLength(2); + expect(await skeletonsFor(journey.id, place.id)).toHaveLength(2); - testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(second.id); + await deleteRows(await orm(), DayAssignments, { id: second.id }); await svc.reconcileTripSkeletons(trip.id); - expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[0].date]); + expect((await skeletonsFor(journey.id, place.id)).map((e) => e.entry_date)).toEqual([days[0].date]); }); it('JOURNEY-SVC-REPEAT-006: moving one of the two assignments moves its entry rather than replacing it', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Geysir' }); createDayAssignment(testDb, days[0].id, place.id); const second = createDayAssignment(testDb, days[1].id, place.id); await svc.reconcileTripSkeletons(trip.id); - const movedId = skeletonsFor(journey.id, place.id)[1].id; + const movedId = (await skeletonsFor(journey.id, place.id))[1].id; - testDb.prepare('UPDATE day_assignments SET day_id = ? WHERE id = ?').run(days[2].id, second.id); + await updateRows(await orm(), DayAssignments, { id: second.id }, { day: days[2].id }); await svc.reconcileTripSkeletons(trip.id); - const after = skeletonsFor(journey.id, place.id); + const after = await skeletonsFor(journey.id, place.id); expect(after.map((e) => e.entry_date)).toEqual([days[0].date, days[2].date]); expect(after[1].id).toBe(movedId); }); it('JOURNEY-SVC-REPEAT-007: editing the place leaves each entry on its own day', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Old Name' }); createDayAssignment(testDb, days[0].id, place.id); createDayAssignment(testDb, days[1].id, place.id); await svc.reconcileTripSkeletons(trip.id); - testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('New Name', place.id); + await updateRows(await orm(), Places, { id: place.id }, { name: 'New Name' }); await svc.onPlaceUpdated(place.id); - const after = skeletonsFor(journey.id, place.id); + const after = await skeletonsFor(journey.id, place.id); expect(after.map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]); expect(after.map((e) => e.title)).toEqual(['New Name', 'New Name']); }); it('JOURNEY-SVC-REPEAT-008: an entry with no assignment link is claimed, not annotated out', async () => { const { journey, trip } = await linkedJourneyTrip(); - const days = daysOf(trip.id); + const days = await daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Legacy Stop' }); createDayAssignment(testDb, days[0].id, place.id); await svc.reconcileTripSkeletons(trip.id); - const legacy = skeletonsFor(journey.id, place.id)[0]; + const legacy = (await skeletonsFor(journey.id, place.id))[0]; // What an install upgraded from before the column existed looks like. - testDb - .prepare("UPDATE journey_entries SET source_assignment_id = NULL, type = 'entry', story = 'Kept' WHERE id = ?") - .run(legacy.id); + await updateRows(await orm(), JourneyEntries, { id: legacy.id }, { source_assignment_id: null, type: 'entry', story: 'Kept' }); await svc.reconcileTripSkeletons(trip.id); - const after = skeletonsFor(journey.id, place.id); + const after = await skeletonsFor(journey.id, place.id); expect(after).toHaveLength(1); expect(after[0].id).toBe(legacy.id); expect(after[0].source_assignment_id).not.toBeNull(); @@ -2450,10 +2402,8 @@ function tripWithPlace(userId: number, opts: { name?: string; date?: string } = return { trip, day, place }; } -function skeletonsOf(journeyId: number) { - return testDb - .prepare("SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton' ORDER BY id") - .all(journeyId) as any[]; +async function skeletonsOf(journeyId: number) { + return await findRows(await orm(), JourneyEntries, { journey: journeyId, type: 'skeleton' }, { id: 'asc' }) as any[]; } describe('skeleton sync', () => { @@ -2462,7 +2412,7 @@ describe('skeleton sync', () => { const { trip, place } = tripWithPlace(user.id); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - const skeletons = skeletonsOf(journey.id); + const skeletons = await skeletonsOf(journey.id); expect(skeletons).toHaveLength(1); expect(skeletons[0].title).toBe('Fushimi Inari'); expect(skeletons[0].source_place_id).toBe(place.id); @@ -2474,7 +2424,7 @@ describe('skeleton sync', () => { const { trip } = tripWithPlace(user.id); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); await svc.syncTripPlaces(journey.id, trip.id, user.id); - expect(skeletonsOf(journey.id)).toHaveLength(1); + expect(await skeletonsOf(journey.id)).toHaveLength(1); }); it('JOURNEY-SVC-SKEL-003: onPlaceCreated adds a skeleton to every journey the trip is linked to', async () => { @@ -2488,7 +2438,7 @@ describe('skeleton sync', () => { await svc.onPlaceCreated(trip.id, extra.id); for (const j of [a, b]) { - expect(skeletonsOf(j.id).map((s) => s.title)).toContain('Nishiki Market'); + expect((await skeletonsOf(j.id)).map((s) => s.title)).toContain('Nishiki Market'); } }); @@ -2503,10 +2453,10 @@ describe('skeleton sync', () => { const { trip, place } = tripWithPlace(user.id); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('Kinkaku-ji', '1 Kinkakujicho', place.id); + await updateRows(await orm(), Places, { id: place.id }, { name: 'Kinkaku-ji', address: '1 Kinkakujicho' }); await svc.onPlaceUpdated(place.id); - const [skeleton] = skeletonsOf(journey.id); + const [skeleton] = await skeletonsOf(journey.id); expect(skeleton.title).toBe('Kinkaku-ji'); expect(skeleton.location_name).toBe('1 Kinkakujicho'); }); @@ -2521,22 +2471,22 @@ describe('skeleton sync', () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - expect(skeletonsOf(journey.id)).toHaveLength(1); + expect(await skeletonsOf(journey.id)).toHaveLength(1); await svc.onPlaceDeleted(place.id); - expect(skeletonsOf(journey.id)).toHaveLength(0); + expect(await skeletonsOf(journey.id)).toHaveLength(0); }); it('JOURNEY-SVC-SKEL-008: onPlaceDeleted keeps a skeleton that has a story, detaches it and appends the note', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - const [skeleton] = skeletonsOf(journey.id); - testDb.prepare('UPDATE journey_entries SET story = ? WHERE id = ?').run('We queued for an hour.', skeleton.id); + const [skeleton] = await skeletonsOf(journey.id); + await updateRows(await orm(), JourneyEntries, { id: skeleton.id }, { story: 'We queued for an hour.' }); await svc.onPlaceDeleted(place.id); - const kept = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(skeleton.id) as any; + const kept = await one(JourneyEntries, { id: skeleton.id }) as any; expect(kept).toBeDefined(); expect(kept.source_place_id).toBeNull(); expect(kept.source_trip_id).toBeNull(); @@ -2553,11 +2503,11 @@ describe('skeleton sync', () => { // A second place lands without firing the hook, and the first is unassigned. const second = createPlace(testDb, trip.id, { name: 'Gion' }); createDayAssignment(testDb, day.id, second.id); - testDb.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(place.id); + await deleteRows(await orm(), DayAssignments, { place: place.id }); await svc.reconcileTripSkeletons(trip.id); - const titles = skeletonsOf(journey.id).map((s) => s.title); + const titles = (await skeletonsOf(journey.id)).map((s) => s.title); expect(titles).toContain('Gion'); expect(titles).not.toContain('Fushimi Inari'); }); @@ -2571,10 +2521,10 @@ describe('skeleton sync', () => { it('JOURNEY-SVC-SKEL-011: createJourney takes its cover from the first linked trip and strips the /uploads prefix', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare('UPDATE trips SET cover_image = ? WHERE id = ?').run('/uploads/covers/kyoto.jpg', trip.id); + await updateRows(await orm(), Trips, { id: trip.id }, { cover_image: '/uploads/covers/kyoto.jpg' }); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - const row = testDb.prepare('SELECT cover_image FROM journeys WHERE id = ?').get(journey.id) as any; + const row = await pickOne(Journeys, { id: journey.id }, ['cover_image']) as any; expect(row.cover_image).toBe('covers/kyoto.jpg'); }); }); @@ -2616,9 +2566,7 @@ describe('journey gallery', () => { expect(first).toHaveLength(1); expect(second).toHaveLength(2); - const orders = testDb - .prepare('SELECT sort_order FROM journey_photos WHERE journey_id = ? ORDER BY sort_order') - .all(journey.id) as any[]; + const orders = await pickAll(JourneyPhotos, { journey: journey.id }, ['sort_order'], { sort_order: 'asc' }) as any[]; expect(orders.map((o) => o.sort_order)).toEqual([0, 1, 2]); expect(await svc.uploadGalleryPhotos(journey.id, stranger.id, [{ path: 'journey/x.jpg' }])).toEqual([]); @@ -2668,7 +2616,7 @@ describe('journey gallery', () => { expect(await svc.unlinkPhotoFromEntry(entry.id, gallery.id, user.id)).toBe(true); expect((await svc.listEntries(journey.id, user.id))!.find((e) => e.id === entry.id)!.photos).toHaveLength(0); // The gallery row survives the unlink — that is the whole point of the split. - expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE id = ?').get(gallery.id)).toBeDefined(); + expect(await one(JourneyPhotos, { id: gallery.id })).toBeDefined(); }); it('JOURNEY-SVC-PHOTO-005: link/unlink refuse an unknown entry and a non-editor', async () => { @@ -2688,7 +2636,7 @@ describe('journey gallery', () => { const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); expect(await svc.deleteGalleryPhoto(gallery.id, user.id)).toBeTruthy(); - expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE id = ?').get(gallery.id)).toBeUndefined(); + expect(await one(JourneyPhotos, { id: gallery.id })).toBeUndefined(); expect(await svc.deleteGalleryPhoto(999999, user.id)).toBeNull(); }); @@ -2708,7 +2656,7 @@ describe('journey gallery', () => { const shot = async (path: string, takenAt: string) => { const [row] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path }]); - testDb.prepare('UPDATE trek_photos SET taken_at = ? WHERE id = ?').run(takenAt, row.photo_id); + await updateRows(await orm(), TrekPhotos, { id: row.photo_id }, { taken_at: takenAt }); }; await shot('journey/day3.jpg', '2026-05-03T18:00:00.000Z'); @@ -2737,9 +2685,7 @@ describe('journey gallery', () => { await svc.linkPhotoToEntry(day2.id, dayTwoPhoto.id, user.id); await svc.linkPhotoToEntry(day1.id, dayOnePhoto.id, user.id); // Pin the loose photo's upload time so the run does not depend on today's date. - testDb - .prepare('UPDATE journey_photos SET created_at = ? WHERE id = ?') - .run(Date.parse('2026-06-01T00:00:00Z'), loose.id); + await updateRows(await orm(), JourneyPhotos, { id: loose.id }, { created_at: Date.parse('2026-06-01T00:00:00Z') }); const gallery = (await svc.getJourneyFull(journey.id, user.id))!.gallery as { file_path: string }[]; expect(gallery.map((p) => p.file_path)).toEqual([ @@ -2777,13 +2723,11 @@ describe('entry enrichment', () => { it('JOURNEY-SVC-ENRICH-001: tags and pros_cons come back parsed, and source_trip_name is resolved', async () => { const { user } = createUser(testDb); const { trip } = tripWithPlace(user.id); - testDb.prepare('UPDATE trips SET title = ? WHERE id = ?').run('Japan 2026', trip.id); + await updateRows(await orm(), Trips, { id: trip.id }, { title: 'Japan 2026' }); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - const [skeleton] = skeletonsOf(journey.id); - testDb - .prepare('UPDATE journey_entries SET tags = ?, pros_cons = ? WHERE id = ?') - .run(JSON.stringify(['shrine']), JSON.stringify({ pros: ['quiet'], cons: [] }), skeleton.id); + const [skeleton] = await skeletonsOf(journey.id); + await updateRows(await orm(), JourneyEntries, { id: skeleton.id }, { tags: JSON.stringify(['shrine']), pros_cons: JSON.stringify({ pros: ['quiet'], cons: [] }) }); const entry = (await svc.listEntries(journey.id, user.id))!.find((e) => e.id === skeleton.id)!; expect(entry.tags).toEqual(['shrine']); @@ -2806,15 +2750,13 @@ describe('entry enrichment', () => { // ── GPX tracks on the journey map (#1260) ───────────────────────────────────── describe('journeyTracks', () => { /** A GPX import stores the geometry on the place, as JSON [lat, lng] pairs. */ - const withGeometry = (placeId: number, geometry: unknown, color: string | null = null) => - testDb - .prepare('UPDATE places SET route_geometry = ?, route_color = ? WHERE id = ?') - .run(typeof geometry === 'string' ? geometry : JSON.stringify(geometry), color, placeId); + const withGeometry = async (placeId: number, geometry: unknown, color: string | null = null) => + updateRows(await orm(), Places, { id: placeId }, { route_geometry: typeof geometry === 'string' ? geometry : JSON.stringify(geometry), route_color: color }); it('JOURNEY-SVC-TRACKS-001: returns the tracks of the trips the entries came from', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); - withGeometry(place.id, [[35.1, 135.7], [35.2, 135.8]], '#ff0000'); + await withGeometry(place.id, [[35.1, 135.7], [35.2, 135.8]], '#ff0000'); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); const tracks = (await svc.journeyTracks(journey.id, user.id))!; @@ -2827,7 +2769,7 @@ describe('journeyTracks', () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); // The importer keeps elevation as a third value where the file had it. - withGeometry(place.id, [[47.1, 11.2, 1830], [47.2, 11.3, 1902]]); + await withGeometry(place.id, [[47.1, 11.2, 1830], [47.2, 11.3, 1902]]); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); expect((await svc.journeyTracks(journey.id, user.id))![0].points).toEqual([[47.1, 11.2], [47.2, 11.3]]); @@ -2845,8 +2787,8 @@ describe('journeyTracks', () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); const second = createPlace(testDb, trip.id, { name: 'Good one' }); - withGeometry(place.id, 'not json at all'); - withGeometry(second.id, [[1, 2], [3, 4]]); + await withGeometry(place.id, 'not json at all'); + await withGeometry(second.id, [[1, 2], [3, 4]]); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); const tracks = (await svc.journeyTracks(journey.id, user.id))!; @@ -2856,7 +2798,7 @@ describe('journeyTracks', () => { it('JOURNEY-SVC-TRACKS-005: a single point is a pin, not a line', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); - withGeometry(place.id, [[35.1, 135.7]]); + await withGeometry(place.id, [[35.1, 135.7]]); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); expect(await svc.journeyTracks(journey.id, user.id)).toEqual([]); @@ -2866,7 +2808,7 @@ describe('journeyTracks', () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); - withGeometry(place.id, [[1, 2], [3, 4]]); + await withGeometry(place.id, [[1, 2], [3, 4]]); const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); expect(await svc.journeyTracks(journey.id, stranger.id)).toBeNull(); @@ -2884,7 +2826,7 @@ describe('addTripToJourney guards', () => { // The stranger owns the trip, so the trip gate passes — only the journey gate stops this. expect(await svc.addTripToJourney(journey.id, trip.id, stranger.id)).toBe(false); - const links = testDb.prepare('SELECT * FROM journey_trips WHERE journey_id = ?').all(journey.id); + const links = await findRows(await orm(), JourneyTrips, { journey: journey.id }); expect(links).toHaveLength(0); }); @@ -2892,8 +2834,7 @@ describe('addTripToJourney guards', () => { const { user: owner } = createUser(testDb); const { user: helper } = createUser(testDb); const journey = createJourney(testDb, owner.id, { title: 'Shared journey' }); - testDb.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)') - .run(journey.id, helper.id, 'editor', new Date().toISOString()); + await insertRow(await orm(), JourneyContributors, { journey: journey.id, user: helper.id, role: 'editor', added_at: Date.now() }); const trip = createTrip(testDb, helper.id, { title: 'Helper trip' }); expect(await svc.addTripToJourney(journey.id, trip.id, helper.id)).toBe(true); @@ -2904,15 +2845,12 @@ describe('addTripToJourney guards', () => { const journey = createJourney(testDb, user.id, { title: 'Photo journey' }); const trip = createTrip(testDb, user.id, { title: 'Photo trip' }); - const r = testDb.prepare( - "INSERT INTO trek_photos (provider, asset_id, owner_id, media_type) VALUES ('immich', 'shared-asset', ?, 'image')", - ).run(user.id); - testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)') - .run(trip.id, user.id, Number(r.lastInsertRowid)); + const photoId = await insertRow(await orm(), TrekPhotos, { provider: 'immich', asset_id: 'shared-asset', owner: user.id, media_type: 'image' }); + await insertRow(await orm(), TripPhotos, { trip: trip.id, user: user.id, photo: photoId, shared: 1 }); await svc.addTripToJourney(journey.id, trip.id, user.id); - expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id)).toHaveLength(0); + expect(await findRows(await orm(), JourneyPhotos, { journey: journey.id })).toHaveLength(0); }); }); @@ -2931,7 +2869,7 @@ describe('dismissed suggestions', () => { expect(full.entries.map((e: { id: number }) => e.id)).toEqual([keep.id]); expect((await svc.listEntries(journey.id, user.id))!.map((e) => e.id)).toEqual([keep.id]); // The row has to survive, or syncTripPlaces offers the same place again. - expect(testDb.prepare('SELECT dismissed FROM journey_entries WHERE id = ?').get(drop.id)).toEqual({ + expect(await pickOne(JourneyEntries, { id: drop.id }, ['dismissed'])).toEqual({ dismissed: 1, }); }); @@ -3059,21 +2997,17 @@ describe('country_code', () => { const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Border', start_date: '2026-08-01', end_date: '2026-08-03' }); const place = createPlace(testDb, trip.id, { name: 'Grenzstein', lat: 48.8584, lng: 2.2945 }); - const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day.id, place.id); await svc.addTripToJourney(journey.id, trip.id, user.id); - const before = testDb.prepare( - "SELECT country_code FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" - ).get(journey.id, place.id) as { country_code: string | null }; + const before = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' }, ['country_code']) as { country_code: string | null }; expect(before.country_code).toBe('FR'); - testDb.prepare('UPDATE places SET lat = ?, lng = ? WHERE id = ?').run(52.52, 13.405, place.id); + await updateRows(await orm(), Places, { id: place.id }, { lat: 52.52, lng: 13.405 }); await svc.onPlaceUpdated(place.id); - const after = testDb.prepare( - "SELECT country_code FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" - ).get(journey.id, place.id) as { country_code: string | null }; + const after = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' }, ['country_code']) as { country_code: string | null }; expect(after.country_code).toBe('DE'); }); @@ -3082,18 +3016,16 @@ describe('country_code', () => { const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Border 2', start_date: '2026-08-01', end_date: '2026-08-03' }); const place = createPlace(testDb, trip.id, { name: 'Grenzstein', lat: 48.8584, lng: 2.2945 }); - const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const day = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number }; createDayAssignment(testDb, day.id, place.id); await svc.addTripToJourney(journey.id, trip.id, user.id); // Writing a story turns the skeleton into a filled entry. - testDb.prepare("UPDATE journey_entries SET type = 'entry' WHERE source_place_id = ?").run(place.id); + await updateRows(await orm(), JourneyEntries, { sourcePlace: place.id }, { type: 'entry' }); - testDb.prepare('UPDATE places SET lat = ?, lng = ? WHERE id = ?').run(52.52, 13.405, place.id); + await updateRows(await orm(), Places, { id: place.id }, { lat: 52.52, lng: 13.405 }); await svc.onPlaceUpdated(place.id); - const after = testDb.prepare( - 'SELECT country_code, location_lat FROM journey_entries WHERE source_place_id = ?' - ).get(place.id) as { country_code: string | null; location_lat: number }; + const after = await pickOne(JourneyEntries, { sourcePlace: place.id }, ['country_code', 'location_lat']) as { country_code: string | null; location_lat: number }; expect(after).toMatchObject({ country_code: 'DE', location_lat: 52.52 }); }); @@ -3192,15 +3124,13 @@ describe('Plan 3g Task 1 — repository parity (full-key toEqual against the leg // ordering precedent (the hook runs against the still-existing place // row — the places domain's own row delete is a separate statement the // hook does not itself issue or depend on). - const filledSkeleton = testDb - .prepare('SELECT id FROM journey_entries WHERE source_place_id = ? LIMIT 1') - .get(singleDayPlace.id) as { id: number }; - testDb.prepare("UPDATE journey_entries SET type = 'entry', story = 'Great fish' WHERE id = ?").run(filledSkeleton.id); + const filledSkeleton = await pickOne(JourneyEntries, { sourcePlace: singleDayPlace.id }, ['id']) as { id: number }; + await updateRows(await orm(), JourneyEntries, { id: filledSkeleton.id }, { type: 'entry', story: 'Great fish' }); await svc.onPlaceDeleted(singleDayPlace.id); // The place is now actually removed from the trip (the hook already // detached the entry, so this cascade touches nothing left referencing it). - testDb.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(singleDayPlace.id); - testDb.prepare('DELETE FROM places WHERE id = ?').run(singleDayPlace.id); + await deleteRows(await orm(), DayAssignments, { place: singleDayPlace.id }); + await deleteRows(await orm(), Places, { id: singleDayPlace.id }); return { owner, editor, viewer, journey, trip, day1, day2, multiDayPlace, singleDayPlace }; } @@ -3225,6 +3155,7 @@ describe('Plan 3g Task 1 — repository parity (full-key toEqual against the leg createJourney(testDb, owner.id, { title: 'Second Journey' }); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare( ` SELECT DISTINCT j.*, @@ -3249,6 +3180,7 @@ describe('Plan 3g Task 1 — repository parity (full-key toEqual against the leg const { journey } = await seedFullJourney(); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare( ` SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar @@ -3267,6 +3199,7 @@ describe('Plan 3g Task 1 — repository parity (full-key toEqual against the leg const { journey } = await seedFullJourney(); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare( ` SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency, @@ -3285,6 +3218,7 @@ describe('Plan 3g Task 1 — repository parity (full-key toEqual against the leg const { journey } = await seedFullJourney(); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC') .all(journey.id); @@ -3297,6 +3231,7 @@ describe('Plan 3g Task 1 — repository parity (full-key toEqual against the leg const legacy = ( testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare( ` SELECT p.*, da.id AS assignment_id, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number @@ -3348,13 +3283,13 @@ describe('Plan 3g Task 2 — repository parity (full-key toEqual against the leg const t2 = createTrip(testDb, owner.id, { title: 'T2 undated' }); const t3 = createTrip(testDb, member.id, { title: 'T3 recent', start_date: d(10), end_date: d(5) }); const t4 = createTrip(testDb, owner.id, { title: 'T4 recent', start_date: d(12), end_date: d(2) }); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(t3.id, owner.id); - const days = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date').all(t1.id) as { id: number }[]; + await insertRow(await orm(), TripMembers, { trip: t3.id, user: owner.id }); + const days = await pickAll(Days, { trip: t1.id }, ['id', 'date'], { date: 'asc' }) as { id: number }[]; const pA = createPlace(testDb, t1.id, { name: 'A' }); const pB = createPlace(testDb, t1.id, { name: 'B', lat: 1, lng: 2 }); createPlace(testDb, t1.id, { name: 'C unassigned' }); const pD = createPlace(testDb, t2.id, { name: 'D' }); - testDb.prepare("UPDATE places SET route_geometry = '[[1,2],[3,4]]', route_color = '#f00' WHERE id IN (?, ?)").run(pA.id, pD.id); + await updateRows(await orm(), Places, { id: { $in: [pA.id, pD.id] } }, { route_geometry: '[[1,2],[3,4]]', route_color: '#f00' }); createDayAssignment(testDb, days[1].id, pA.id); createDayAssignment(testDb, days[0].id, pA.id); createDayAssignment(testDb, days[0].id, pB.id); @@ -3364,22 +3299,32 @@ describe('Plan 3g Task 2 — repository parity (full-key toEqual against the leg createDayAssignment(testDb, d4.id, place4.id); const journey = createJourney(testDb, owner.id); for (const tid of [t1.id, t2.id]) { - testDb.prepare('INSERT INTO journey_trips (journey_id, trip_id, added_at) VALUES (?,?,1)').run(journey.id, tid); + await insertRow(await orm(), JourneyTrips, { journey: journey.id, trip: tid, added_at: 1 }); await svc.syncTripPlaces(journey.id, tid, owner.id); } const now = Date.now(); - const ins = testDb.prepare( - `INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at, stats_excluded, dismissed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - ); - const e1 = Number(ins.run(journey.id, owner.id, 'entry', 'E1', 's', '2026-01-02', '10:00', 'Paris', 48.8, 2.3, 0, now, now, 0, 0).lastInsertRowid); - const e2 = Number(ins.run(journey.id, owner.id, 'entry', 'E2', null, '2026-01-02', '', null, null, null, 0, now, now, 1, 0).lastInsertRowid); - const e3 = Number(ins.run(journey.id, owner.id, 'entry', 'E3 dismissed', null, '2026-01-01', null, 'X', null, null, 1, now, now, 0, 1).lastInsertRowid); - const e4 = Number(ins.run(journey.id, owner.id, 'entry', 'E4', null, '2025-12-31', null, 'Paris', 1, 1, 5, now, now, 0, 0).lastInsertRowid); - const tp = testDb.prepare( - `INSERT INTO trek_photos (provider, asset_id, owner_id, file_path, thumbnail_path, width, height, media_type, taken_at, lat, lng) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, - ); - const gp = testDb.prepare(`INSERT INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, provider, asset_id, owner_id, created_at) VALUES (?,?,?,?,?,?,?,?,?)`); - const jep = testDb.prepare(`INSERT INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) VALUES (?,?,?,?)`); + // Each writer takes its values in the column order the old statements bound them. + const ins = async (...values: unknown[]) => + insertRow(await orm(), JourneyEntries, columnsOf( + ['journey', 'author', 'type', 'title', 'story', 'entry_date', 'entry_time', 'location_name', 'location_lat', 'location_lng', 'sort_order', 'created_at', 'updated_at', 'stats_excluded', 'dismissed'], + values, + )); + const e1 = await ins(journey.id, owner.id, 'entry', 'E1', 's', '2026-01-02', '10:00', 'Paris', 48.8, 2.3, 0, now, now, 0, 0); + const e2 = await ins(journey.id, owner.id, 'entry', 'E2', null, '2026-01-02', '', null, null, null, 0, now, now, 1, 0); + const e3 = await ins(journey.id, owner.id, 'entry', 'E3 dismissed', null, '2026-01-01', null, 'X', null, null, 1, now, now, 0, 1); + const e4 = await ins(journey.id, owner.id, 'entry', 'E4', null, '2025-12-31', null, 'Paris', 1, 1, 5, now, now, 0, 0); + const tp = async (...values: unknown[]) => + insertRow(await orm(), TrekPhotos, columnsOf( + ['provider', 'asset_id', 'owner', 'file_path', 'thumbnail_path', 'width', 'height', 'media_type', 'taken_at', 'lat', 'lng'], + values, + )); + const gp = async (...values: unknown[]) => + insertRow(await orm(), JourneyPhotos, columnsOf( + ['journey', 'photo', 'caption', 'shared', 'sort_order', 'provider', 'asset_id', 'owner_id', 'created_at'], + values, + )); + const jep = async (...values: unknown[]) => + insertRow(await orm(), JourneyEntryPhotos, columnsOf(['entry', 'journeyPhoto', 'sort_order', 'created_at'], values)); // taken_at/media_type combinations: null, empty string, two ties at the // same taken_at (one video — excluded from listFirstPhotoPerEntry), and // a null media_type (treated as 'image'). @@ -3388,20 +3333,18 @@ describe('Plan 3g Task 2 — repository parity (full-key toEqual against the leg [null, 'image'], ['', null], [null, 'image'], ]; const gids: number[] = []; - specs.forEach(([taken, mt], i) => { - const pid = Number( - tp.run('local', null, owner.id, `f${i}.jpg`, i % 2 ? null : `t${i}.jpg`, 100, i % 3 ? null : 50, mt ?? 'image', taken, i % 2 ? 1.5 : null, i % 2 ? 2.5 : null).lastInsertRowid, - ); - gids.push(Number(gp.run(journey.id, pid, i % 2 ? `cap${i}` : null, i % 2, i % 3, null, null, null, 1700000000000 + (i % 4) * 1000).lastInsertRowid)); - }); - jep.run(e1, gids[0], 0, now); jep.run(e1, gids[1], 0, now); jep.run(e1, gids[2], 0, now); - jep.run(e2, gids[0], 1, now); jep.run(e2, gids[3], 0, now); - jep.run(e4, gids[5], 2, now); jep.run(e4, gids[4], 2, now); - jep.run(e3, gids[6], 0, now); + for (const [i, [taken, mt]] of specs.entries()) { + const pid = await tp('local', null, owner.id, `f${i}.jpg`, i % 2 ? null : `t${i}.jpg`, 100, i % 3 ? null : 50, mt ?? 'image', taken, i % 2 ? 1.5 : null, i % 2 ? 2.5 : null); + gids.push(await gp(journey.id, pid, i % 2 ? `cap${i}` : null, i % 2, i % 3, null, null, null, 1700000000000 + (i % 4) * 1000)); + } + await jep(e1, gids[0], 0, now); await jep(e1, gids[1], 0, now); await jep(e1, gids[2], 0, now); + await jep(e2, gids[0], 1, now); await jep(e2, gids[3], 0, now); + await jep(e4, gids[5], 2, now); await jep(e4, gids[4], 2, now); + await jep(e3, gids[6], 0, now); // An unrelated journey's photo — must never leak into this journey's reads. const other = createJourney(testDb, member.id); - const opid = Number(tp.run('local', null, member.id, 'o.jpg', null, null, null, 'image', null, null, null).lastInsertRowid); - gp.run(other.id, opid, null, 0, 0, null, null, null, 1); + const opid = await tp('local', null, member.id, 'o.jpg', null, null, null, 'image', null, null, null); + await gp(other.id, opid, null, 0, 0, null, null, null, 1); return { owner, member, journey }; } @@ -3435,6 +3378,7 @@ describe('Plan 3g Task 2 — repository parity (full-key toEqual against the leg it('JourneyEntryPhotosRepository.listForJourney (JG15) matches the legacy statement, grouped by entry (L8: flat order may tie-break differently)', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(LEG.JG15).all(journey.id) as { entry_id: number; id: number }[]; const converted = (await entryPhotosRepoDirect.listForJourney(journey.id)) as unknown as { entry_id: number; id: number }[]; @@ -3447,6 +3391,7 @@ describe('Plan 3g Task 2 — repository parity (full-key toEqual against the leg it('JourneyPhotosRepository.galleryRead (JG19, GALLERY_CHRONOLOGICAL_ORDER) matches the legacy statement exactly, including row order', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(LEG.JG19).all(journey.id); const converted = await photosRepoDirect.galleryRead(journey.id); expect(converted).toEqual(legacy); @@ -3454,42 +3399,49 @@ describe('Plan 3g Task 2 — repository parity (full-key toEqual against the leg it('JourneyEntriesRepository.listStatsRows (JG64) matches the legacy statement', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(LEG.STATS_ENTRIES).all(journey.id); expect(await entriesRepoDirect.listStatsRows(journey.id)).toEqual(legacy); }); it('JourneyEntriesRepository.listStatsTrips (JG65) matches the legacy statement, undated trips sorted last', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(LEG.STATS_TRIPS).all(journey.id); expect(await entriesRepoDirect.listStatsTrips(journey.id)).toEqual(legacy); }); it('JourneyEntriesRepository.listStatsPlaces (JG66) matches the legacy statement, one row per place at its earliest day', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(LEG.STATS_PLACES).all(journey.id); expect(await entriesRepoDirect.listStatsPlaces(journey.id)).toEqual(legacy); }); it('JourneyEntriesRepository.countStatsPlaces (JG67) matches the legacy count', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = (testDb.prepare(LEG.STATS_PLACECOUNT).get(journey.id) as { n: number }).n; expect(await entriesRepoDirect.countStatsPlaces(journey.id)).toBe(legacy); }); it('JourneyPhotosRepository.countForJourney (JG68) matches the legacy count', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = (testDb.prepare(LEG.STATS_PHOTOCOUNT).get(journey.id) as { n: number }).n; expect(await photosRepoDirect.countForJourney(journey.id)).toBe(legacy); }); it('JourneyEntryPhotosRepository.listFirstPhotoPerEntry (JG69) matches the legacy statement, videos excluded', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(LEG.STATS_EPHOTOS).all(journey.id); expect(await entryPhotosRepoDirect.listFirstPhotoPerEntry(journey.id)).toEqual(legacy); }); it('JourneyEntriesRepository.listTracksSource (JG63) matches the legacy statement', async () => { const { journey } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(LEG.TRACKS).all(journey.id); expect(await entriesRepoDirect.listTracksSource(journey.id)).toEqual(legacy); }); @@ -3497,13 +3449,16 @@ describe('Plan 3g Task 2 — repository parity (full-key toEqual against the leg it('JourneyEntriesRepository.listSuggestedTrips (JG120) matches the legacy statement', async () => { const { owner } = await seedParityJourney(); const since = new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0]; + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare(LEG.SUGG).all(owner.id, owner.id, owner.id, since); expect(await entriesRepoDirect.listSuggestedTrips(owner.id, since, todayUtc())).toEqual(legacy); }); it('JourneyEntriesRepository.listUserTripsPicker (JG121) matches the legacy statement — owner and a member', async () => { const { owner, member } = await seedParityJourney(); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacyOwner = testDb.prepare(LEG.PICKER).all(owner.id, owner.id, owner.id); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacyMember = testDb.prepare(LEG.PICKER).all(member.id, member.id, member.id); expect(await entriesRepoDirect.listUserTripsPicker(owner.id)).toEqual(legacyOwner); expect(await entriesRepoDirect.listUserTripsPicker(member.id)).toEqual(legacyMember); @@ -3518,9 +3473,7 @@ describe('Plan 3g Task 1 — mutation proofs (R5/R7)', () => { const changed = await contributorsRepoDirect.deleteNonOwner(journey.id, owner.id); expect(changed).toBe(0); - const row = testDb - .prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?') - .get(journey.id, owner.id) as { role: string } | undefined; + const row = await pickOne(JourneyContributors, { journey: journey.id, user: owner.id }, ['role']) as { role: string } | undefined; expect(row?.role).toBe('owner'); }); @@ -3529,9 +3482,8 @@ describe('Plan 3g Task 1 — mutation proofs (R5/R7)', () => { const journey = createJourney(testDb, owner.id); // The exact statement JG119's guard replaces (no `role != 'owner'` clause). - const res = testDb - .prepare('DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ?') - .run(journey.id, owner.id); + // test-sql-allow: the legacy statement without the guard is the oracle the guarded delete is measured against. + const res = testDb.prepare('DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ?').run(journey.id, owner.id); expect(res.changes).toBe(1); }); @@ -3547,9 +3499,7 @@ describe('Plan 3g Task 1 — mutation proofs (R5/R7)', () => { expect(refused).toBe(false); expect(broadcastSpy).not.toHaveBeenCalled(); - const link = testDb - .prepare('SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?') - .get(journey.id, foreignTrip.id); + const link = await one(JourneyTrips, { journey: journey.id, trip: foreignTrip.id }); expect(link).toBeUndefined(); // Mutation check: stub AP1's own primitive to always report access — the @@ -3565,9 +3515,7 @@ describe('Plan 3g Task 1 — mutation proofs (R5/R7)', () => { // succeeds — proving the refusal above genuinely depends on AP1's // real `findAccessible` check, not on some other guard. expect(bypassed).toBe(true); - const linkAfterBypass = testDb - .prepare('SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?') - .get(journey.id, foreignTrip.id); + const linkAfterBypass = await one(JourneyTrips, { journey: journey.id, trip: foreignTrip.id }); expect(linkAfterBypass).toBeDefined(); } finally { findAccessibleSpy.mockRestore(); @@ -3598,26 +3546,19 @@ describe('Plan 3g Task 1 — reconcileTripSkeletons: all three branches in one c // a backfill (or a row from before the assignment link existed) leaves // behind: `source_place_id` set, `source_assignment_id` NULL. const claimAssignment = createDayAssignment(testDb, day2.id, claimPlace.id); - testDb - .prepare( - ` - INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, source_assignment_id, author_id, type, title, entry_date, sort_order, created_at, updated_at) - VALUES (?, ?, ?, NULL, ?, 'skeleton', ?, ?, 0, ?, ?) - `, - ) - .run(journey.id, trip.id, claimPlace.id, user.id, claimPlace.name, '2026-06-02', Date.now(), Date.now()); + await insertRow(await orm(), JourneyEntries, { journey: journey.id, sourceTrip: trip.id, sourcePlace: claimPlace.id, source_assignment_id: null, author: user.id, type: 'skeleton', title: claimPlace.name, entry_date: '2026-06-02', sort_order: 0, created_at: Date.now(), updated_at: Date.now() }); // Change the plan three ways at once: drop dropPlace's assignment (branch // 3), leave keepPlace untouched (a control — no branch should fire for // it), and add a brand-new place to day 2 (branch 1). claimPlace's day-2 // assignment (already on the plan, above) is what branch 2 claims. - testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(dropAssignment.id); + await deleteRows(await orm(), DayAssignments, { id: dropAssignment.id }); const newPlace = createPlace(testDb, trip.id, { name: 'New' }); createDayAssignment(testDb, day2.id, newPlace.id); await svc.reconcileTripSkeletons(trip.id); - const entries = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ?').all(journey.id) as { + const entries = await findRows(await orm(), JourneyEntries, { journey: journey.id }) as { source_place_id: number | null; source_assignment_id: number | null; type: string; @@ -3658,7 +3599,7 @@ describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => { await expect(svc.addPhoto(entry!.id, user.id, 'journey/tx1.jpg')).rejects.toThrow('boom'); - const rows = testDb.prepare('SELECT * FROM journey_photos WHERE journey_id = ?').all(journey.id); + const rows = await findRows(await orm(), JourneyPhotos, { journey: journey.id }); expect(rows).toHaveLength(0); }); @@ -3671,7 +3612,7 @@ describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => { await expect(svc.addProviderPhoto(entry!.id, user.id, 'immich', 'asset-tx2')).rejects.toThrow('boom'); - const rows = testDb.prepare('SELECT * FROM journey_photos WHERE journey_id = ?').all(journey.id); + const rows = await findRows(await orm(), JourneyPhotos, { journey: journey.id }); expect(rows).toHaveLength(0); }); @@ -3683,7 +3624,7 @@ describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => { await expect(svc.addProviderPhotoToGallery(journey.id, user.id, 'immich', 'asset-tx3')).rejects.toThrow('boom'); - const rows = testDb.prepare('SELECT * FROM journey_photos WHERE journey_id = ?').all(journey.id); + const rows = await findRows(await orm(), JourneyPhotos, { journey: journey.id }); expect(rows).toHaveLength(0); }); @@ -3693,7 +3634,7 @@ describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => { const e1 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' }); const e2 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' }); const e3 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' }); - testDb.prepare('UPDATE journey_entries SET sort_order = 0 WHERE id IN (?, ?, ?)').run(e1.id, e2.id, e3.id); + await updateRows(await orm(), JourneyEntries, { id: { $in: [e1.id, e2.id, e3.id] } }, { sort_order: 0 }); vi.spyOn(journeysRepoDirect, 'updateFields').mockRejectedValueOnce(new Error('boom')); @@ -3702,7 +3643,7 @@ describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => { // None of the three sort_order writes the loop made before the journey // touch failed should have survived the rollback — every entry is still // at its pre-call sort_order (0), not just the last one in the loop. - const rows = testDb.prepare('SELECT id, sort_order FROM journey_entries WHERE journey_id = ? ORDER BY id').all(journey.id) as { + const rows = await pickAll(JourneyEntries, { journey: journey.id }, ['id', 'sort_order'], { id: 'asc' }) as { id: number; sort_order: number; }[]; @@ -3756,15 +3697,13 @@ describe('Plan 3g Task 2 — JG112 mutation proof (sort_order table targeting)', await svc.linkPhotoToEntry(entry!.id, photo.id, user.id); const galleryBefore = ( - testDb.prepare('SELECT sort_order FROM journey_photos WHERE id = ?').get(photo.id) as { sort_order: number } + await pickOne(JourneyPhotos, { id: photo.id }, ['sort_order']) as { sort_order: number } ).sort_order; const updated = await svc.updatePhoto(photo.id, user.id, { sort_order: 7 }); // The junction row (this entry's own view of the photo's position) moved. - const junctionRow = testDb - .prepare('SELECT sort_order FROM journey_entry_photos WHERE journey_photo_id = ?') - .get(photo.id) as { sort_order: number }; + const junctionRow = await pickOne(JourneyEntryPhotos, { journeyPhoto: photo.id }, ['sort_order']) as { sort_order: number }; expect(junctionRow.sort_order).toBe(7); expect(updated!.sort_order).toBe(7); @@ -3774,7 +3713,7 @@ describe('Plan 3g Task 2 — JG112 mutation proof (sort_order table targeting)', // table) to 7 — asserting it is STILL the pre-call value is what a // table-swap regression would fail. const galleryAfter = ( - testDb.prepare('SELECT sort_order FROM journey_photos WHERE id = ?').get(photo.id) as { sort_order: number } + await pickOne(JourneyPhotos, { id: photo.id }, ['sort_order']) as { sort_order: number } ).sort_order; expect(galleryAfter).toBe(galleryBefore); });