diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index 61910558f..d4e7f5477 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -46,7 +46,24 @@ import { DayNotesModule } from '../../src/nest/day-notes/day-notes.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { countRows, deleteRows, findRow, findRows, insertRow, insertRows, updateRows } from '../helpers/factories/rows'; +import { makeUser } from '../helpers/factories/users'; +import { makeTour } from '../helpers/factories/tours'; +import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; +import { DayNotes } from '../../src/db/entities/DayNotes.entity'; +import { Days } from '../../src/db/entities/Days.entity'; +import { Places } from '../../src/db/entities/Places.entity'; +import { Tours } from '../../src/db/entities/Tours.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; + +let orm: TestOrm; + +/** The day's position and date, as the reorder and the date shift leave them. */ +async function dayColumns(id: number) { + const day = await findRow(orm, Days, { id }); + return { day_number: day?.day_number, date: day?.date }; +} describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', () => { let server: Server; @@ -63,28 +80,26 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( } beforeAll(async () => { - // harness.ts's seedUser() omits password_hash, which the real migrated - // schema requires NOT NULL (addons.e2e.test.ts/share.e2e.test.ts hit the - // same thing) — a raw insert here instead, matching the SeededUser shape - // id/role/password_version=0 that sessionCookie(1) needs. - db.prepare( - "INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)", - ).run(); - db.prepare('INSERT INTO trips (id, user_id, title) VALUES (5, 1, ?)').run('Trip'); - db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1)').run(); + // The user sessionCookie(1) is signed for, pinned to id 1 with + // password_version 0, then the one trip and day every case starts from. + orm = await createTestOrm(db); + await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 }); + await insertRow(orm, Trips, { id: 5, user: 1, title: 'Trip' }); + await insertRow(orm, Days, { id: 3, trip: 5, day_number: 1 }); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); server = app.getHttpServer(); }); - beforeEach(() => { - db.prepare('DELETE FROM day_notes').run(); - db.prepare('DELETE FROM day_assignments').run(); + beforeEach(async () => { + await deleteRows(orm, DayNotes); + await deleteRows(orm, DayAssignments); checkPermission.mockReturnValue(true); }); afterAll(async () => { await app.close(); + await orm.close(); }); it('401 without a cookie', async () => { @@ -101,13 +116,16 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( it('200 list projects Tour facets once per assignment and leaves other tracks ordinary', async () => { const legacyGeometry = JSON.stringify([[48, 11, 600], [48.01, 11.02, 650]]); const tourGeometry = JSON.stringify([[48.02, 11.03, 700], [48.03, 11.04, 750]]); - db.prepare('INSERT INTO places (id, trip_id, name, route_geometry) VALUES (2, 5, ?, ?), (3, 5, ?, ?), (4, 5, ?, ?)') - .run('Ordinary', null, 'Legacy track', legacyGeometry, 'Tour', tourGeometry); - db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (4, 'hike')").run(); - const assignmentIds = [2, 3, 4, 4].map((placeId, orderIndex) => Number( - db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, ?)') - .run(placeId, orderIndex).lastInsertRowid, - )); + await insertRows(orm, Places, [ + { id: 2, trip: 5, name: 'Ordinary', route_geometry: null }, + { id: 3, trip: 5, name: 'Legacy track', route_geometry: legacyGeometry }, + { id: 4, trip: 5, name: 'Tour', route_geometry: tourGeometry }, + ]); + await makeTour(orm, 4, { tourTypeRef: 'hike' }); + const assignmentIds: number[] = []; + for (const [orderIndex, placeId] of [2, 3, 4, 4].entries()) { + assignmentIds.push(await insertRow(orm, DayAssignments, { day: 3, place: placeId, order_index: orderIndex })); + } const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1)); @@ -123,9 +141,9 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( expect(assignments.map(a => a.id)).toEqual(assignmentIds); expect(assignments.map(a => a.order_index)).toEqual([0, 1, 2, 3]); expect(assignments[0]).toMatchObject({ place: { id: 2 }, tour_place_id: null, tour_route_geometry: null }); - expect(db.prepare('SELECT route_geometry FROM places WHERE id = 3').get()).toEqual({ route_geometry: legacyGeometry }); - expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 3').get()).toBeUndefined(); - expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 4').get()).toEqual({ place_id: 4 }); + expect((await findRow(orm, Places, { id: 3 }))?.route_geometry).toBe(legacyGeometry); + expect(await findRow(orm, Tours, { place: 3 })).toBeNull(); + expect((await findRow(orm, Tours, { place: 4 }))?.place_id).toBe(4); expect(assignments[1]).toMatchObject({ place: { id: 3 }, tour_place_id: null, tour_route_geometry: null }); expect(assignments.slice(2).map(a => ({ place_id: a.place.id, @@ -141,37 +159,37 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( const ok = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ date: '2026-07-01' }); expect(ok.status).toBe(201); expect(ok.body.day).toMatchObject({ trip_id: 5, day_number: 2, date: '2026-07-01', assignments: [] }); - const row = db.prepare('SELECT * FROM days WHERE id = ?').get(ok.body.day.id); + const row = await findRow(orm, Days, { id: ok.body.day.id }); expect(row).toMatchObject({ trip_id: 5, day_number: 2, date: '2026-07-01' }); - db.prepare('DELETE FROM days WHERE id = ?').run(ok.body.day.id); + await deleteRows(orm, Days, { id: ok.body.day.id }); const miss = await request(server).get('/api/trips/77/days').set('Cookie', sessionCookie(1)); expect(miss.status).toBe(404); expect(miss.body).toEqual({ error: 'Trip not found' }); }); it('201 create dated day extends the trip, and the days without a date move back', async () => { - db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (10, 1, ?, ?, ?)').run('Grow', '2026-09-01', '2026-09-02'); - db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (10, 1, ?)').run('2026-09-01'); - db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (10, 2, ?)').run('2026-09-02'); - const spare = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (10, 3)').run().lastInsertRowid); + await insertRow(orm, Trips, { id: 10, user: 1, title: 'Grow', start_date: '2026-09-01', end_date: '2026-09-02' }); + await insertRow(orm, Days, { trip: 10, day_number: 1, date: '2026-09-01' }); + await insertRow(orm, Days, { trip: 10, day_number: 2, date: '2026-09-02' }); + const spare = await insertRow(orm, Days, { trip: 10, day_number: 3 }); const res = await request(server).post('/api/trips/10/days').set('Cookie', sessionCookie(1)).send({ dated: true }); expect(res.status).toBe(201); expect(res.body.day).toMatchObject({ trip_id: 10, day_number: 3, date: '2026-09-03', assignments: [], notes_items: [] }); expect(res.body.trip).toMatchObject({ id: 10, end_date: '2026-09-03', day_count: 4, is_owner: 1 }); - expect(db.prepare('SELECT end_date FROM trips WHERE id = 10').get()).toEqual({ end_date: '2026-09-03' }); - expect(db.prepare('SELECT day_number, date FROM days WHERE id = ?').get(spare)).toEqual({ day_number: 4, date: null }); + expect((await findRow(orm, Trips, { id: 10 }))?.end_date).toBe('2026-09-03'); + expect(await dayColumns(spare)).toEqual({ day_number: 4, date: null }); }); it('400 dated with position, 400 dated on a trip without dates', async () => { - db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (11, 1, ?, ?, ?)').run('Mixed', '2026-09-01', '2026-09-01'); - db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (11, 1, ?)').run('2026-09-01'); + await insertRow(orm, Trips, { id: 11, user: 1, title: 'Mixed', start_date: '2026-09-01', end_date: '2026-09-01' }); + await insertRow(orm, Days, { trip: 11, day_number: 1, date: '2026-09-01' }); const mixed = await request(server).post('/api/trips/11/days').set('Cookie', sessionCookie(1)).send({ dated: true, position: 1 }); expect(mixed.status).toBe(400); - expect(db.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = 11').get()).toEqual({ n: 1 }); + expect(await countRows(orm, Days, { trip: 11 })).toBe(1); const undated = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ dated: true }); expect(undated.status).toBe(400); expect(undated.body).toEqual({ error: 'This trip has no dates. Add a day without a date instead.' }); - expect(db.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = 5').get()).toEqual({ n: 1 }); + expect(await countRows(orm, Days, { trip: 5 })).toBe(1); }); it('200 update day notes/title, 404 Day not found, 403 without permission', async () => { @@ -200,7 +218,7 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( }); it('200 transport setter changes only default_transport_mode', async () => { - db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = 3').run('Keep', 'Kept'); + await updateRows(orm, Days, { id: 3 }, { notes: 'Keep', title: 'Kept' }); const res = await request(server).put('/api/trips/5/days/3/transport').set('Cookie', sessionCookie(1)) .send({ transport_mode: 'walk' }); expect(res.status).toBe(200); @@ -208,14 +226,14 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( }); it('200 reorder permutes day_number, 400 on a non-permutation', async () => { - db.prepare('INSERT INTO trips (id, user_id, title) VALUES (6, 1, ?)').run('Reorder'); - const a = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (6, 1, ?)').run('2026-03-01').lastInsertRowid); - const b = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (6, 2, ?)').run('2026-03-02').lastInsertRowid); + await insertRow(orm, Trips, { id: 6, user: 1, title: 'Reorder' }); + const a = await insertRow(orm, Days, { trip: 6, day_number: 1, date: '2026-03-01' }); + const b = await insertRow(orm, Days, { trip: 6, day_number: 2, date: '2026-03-02' }); const ok = await request(server).put('/api/trips/6/days/reorder').set('Cookie', sessionCookie(1)) .send({ orderedIds: [b, a] }); expect(ok.status).toBe(200); expect(ok.body).toEqual({ success: true }); - const after = db.prepare('SELECT id, date FROM days WHERE trip_id = 6 ORDER BY day_number').all() as { id: number; date: string }[]; + const after = await findRows(orm, Days, { trip: 6 }, { day_number: 'asc' }); // Dates stay pinned to slots; the rows swapped positions. expect(after.map(d => d.id)).toEqual([b, a]); expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02']); @@ -226,33 +244,33 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( }); it('200 delete day removes the row, closes the gap and answers with the trip', async () => { - db.prepare('INSERT INTO trips (id, user_id, title) VALUES (7, 1, ?)').run('Delete'); - const id = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 1)').run().lastInsertRowid); - const kept = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 2)').run().lastInsertRowid); + await insertRow(orm, Trips, { id: 7, user: 1, title: 'Delete' }); + const id = await insertRow(orm, Days, { trip: 7, day_number: 1 }); + const kept = await insertRow(orm, Days, { trip: 7, day_number: 2 }); const res = await request(server).delete(`/api/trips/7/days/${id}`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toMatchObject({ success: true, trip: { id: 7, day_count: 1, is_owner: 1 } }); - expect(db.prepare('SELECT * FROM days WHERE id = ?').get(id)).toBeUndefined(); - expect(db.prepare('SELECT day_number FROM days WHERE id = ?').get(kept)).toEqual({ day_number: 1 }); + expect(await findRow(orm, Days, { id })).toBeNull(); + expect((await findRow(orm, Days, { id: kept }))?.day_number).toBe(1); }); it('200 delete a dated day with no spare day ends the trip a day earlier', async () => { - db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (8, 1, ?, ?, ?)').run('Shrink', '2026-09-01', '2026-09-02'); - const first = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 1, ?)').run('2026-09-01').lastInsertRowid); - const second = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 2, ?)').run('2026-09-02').lastInsertRowid); + await insertRow(orm, Trips, { id: 8, user: 1, title: 'Shrink', start_date: '2026-09-01', end_date: '2026-09-02' }); + const first = await insertRow(orm, Days, { trip: 8, day_number: 1, date: '2026-09-01' }); + const second = await insertRow(orm, Days, { trip: 8, day_number: 2, date: '2026-09-02' }); const res = await request(server).delete(`/api/trips/8/days/${first}`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body.trip).toMatchObject({ id: 8, end_date: '2026-09-01', day_count: 1 }); - expect(db.prepare('SELECT day_number, date FROM days WHERE id = ?').get(second)).toEqual({ day_number: 1, date: '2026-09-01' }); + expect(await dayColumns(second)).toEqual({ day_number: 1, date: '2026-09-01' }); }); it('400 delete the last day of a trip, 403 without day_edit', async () => { - db.prepare('INSERT INTO trips (id, user_id, title) VALUES (9, 1, ?)').run('Last'); - const only = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (9, 1)').run().lastInsertRowid); + await insertRow(orm, Trips, { id: 9, user: 1, title: 'Last' }); + const only = await insertRow(orm, Days, { trip: 9, day_number: 1 }); const last = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1)); expect(last.status).toBe(400); expect(last.body).toEqual({ error: 'A trip needs at least one day.' }); - expect(db.prepare('SELECT id FROM days WHERE id = ?').get(only)).toEqual({ id: only }); + expect((await findRow(orm, Days, { id: only }))?.id).toBe(only); checkPermission.mockReturnValue(false); const forbidden = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1)); expect(forbidden.status).toBe(403); @@ -264,7 +282,7 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( .send({ text: ' Lunch ', time: '', icon: '', sort_order: 0 }); expect(ok.status).toBe(201); expect(ok.body.note).toMatchObject({ day_id: 3, trip_id: 5, text: 'Lunch', time: null, icon: '📝', sort_order: 0 }); - const row = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(ok.body.note.id); + const row = await findRow(orm, DayNotes, { id: ok.body.note.id }); expect(row).toMatchObject({ text: 'Lunch', time: null, icon: '📝', sort_order: 0 }); const long = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: 'x'.repeat(501) }); expect(long.status).toBe(400); @@ -309,6 +327,6 @@ describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', ( const res = await request(server).delete(`/api/trips/5/days/3/notes/${id}`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id)).toBeUndefined(); + expect(await findRow(orm, DayNotes, { id })).toBeNull(); }); }); diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index d309edf41..dc90059e1 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -46,7 +46,21 @@ import { TripsModule } from '../../src/nest/trips/trips.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows'; +import { makeUser } from '../helpers/factories/users'; +import { addTripMember } from '../helpers/factories/trips'; +import { makePlace, type PlaceRow } from '../helpers/factories/places'; +import { makeDayAssignment } from '../helpers/factories/itinerary'; +import { setAddonEnabled } from '../helpers/factories/settings'; +import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; +import { Days } from '../../src/db/entities/Days.entity'; +import { Places } from '../../src/db/entities/Places.entity'; +import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity'; +import { Tours } from '../../src/db/entities/Tours.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; + +let orm: TestOrm; let checkPermission: MockInstance; @@ -107,8 +121,17 @@ describe('Tours e2e (real guards + temp SQLite)', () => { return nest; } - const setAddon = (enabled: boolean) => - db.prepare("UPDATE addons SET enabled = ? WHERE id = 'tours'").run(enabled ? 1 : 0); + const setAddon = (enabled: boolean) => setAddonEnabled(orm, 'tours', enabled); + + /** The named columns of a stored place, as a projected SELECT would return them. */ + const placeColumns = async (id: number, ...keys: K[]) => { + const row = await findRow(orm, Places, { id }); + return Object.fromEntries(keys.map((k) => [k, row?.[k]])) as Pick; + }; + + /** A plain place on the trip, no coordinates and no category. */ + const plainPlace = async (tripId: number, name: string) => + (await makePlace(orm, tripId, { name, lat: null, lng: null, category: null })).id; const createViaApi = async (tripId = 5, body: Record = tourBody()) => { const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER)).send(body); @@ -122,33 +145,41 @@ describe('Tours e2e (real guards + temp SQLite)', () => { }; beforeAll(async () => { - db.prepare(`INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES - (1, 'owner', 'owner@example.test', 'x', 'user', 0), - (2, 'viewer', 'viewer@example.test', 'x', 'user', 0), - (3, 'outsider', 'outsider@example.test', 'x', 'user', 0)`).run(); - db.prepare("INSERT INTO trips (id, user_id, title) VALUES (5, 1, 'Alps'), (6, 1, 'Other')").run(); - db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run(); - db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1), (4, 5, 2)').run(); + orm = await createTestOrm(db); + // Pinned ids: the session cookies sign for exactly these users. + await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { id: 3, username: 'outsider', email: 'outsider@example.test', role: 'user', password_version: 0 }); + await insertRows(orm, Trips, [ + { id: 5, user: 1, title: 'Alps' }, + { id: 6, user: 1, title: 'Other' }, + ]); + await addTripMember(orm, 5, 2); + await insertRows(orm, Days, [ + { id: 3, trip: 5, day_number: 1 }, + { id: 4, trip: 5, day_number: 2 }, + ]); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockResolvedValue([]); server = app.getHttpServer(); }); - beforeEach(() => { + beforeEach(async () => { // The viewer is a trip member whose 'place_edit' is withheld; every other // action and user passes, so the owner can create, move and copy. checkPermission.mockImplementation((action: string, _role: string, _owner: number, userId: number) => !(action === 'place_edit' && userId === VIEWER)); - setAddon(true); + await setAddon(true); broadcast.mockClear(); - db.prepare('DELETE FROM day_assignments').run(); - db.prepare('DELETE FROM places').run(); - db.prepare('DELETE FROM trips WHERE id NOT IN (5, 6)').run(); + await deleteRows(orm, DayAssignments); + await deleteRows(orm, Places); + await deleteRows(orm, Trips, { id: { $nin: [5, 6] } }); }); afterAll(async () => { await app.close(); + await orm.close(); }); describe('addon and access gates', () => { @@ -158,7 +189,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => { it('404 on every route while the addon is disabled', async () => { const placeId = await createViaApi(); - setAddon(false); + await setAddon(false); const off = { error: 'Tours addon is not enabled' }; const cookie = sessionCookie(OWNER); const responses = [ @@ -172,8 +203,8 @@ describe('Tours e2e (real guards + temp SQLite)', () => { expect(res.status).toBe(404); expect(res.body).toEqual(off); } - expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 }); - expect(db.prepare('SELECT name FROM places WHERE id = ?').get(placeId)).toEqual({ name: 'Ridge walk' }); + expect(await countRows(orm, Tours)).toBe(1); + expect(await placeColumns(placeId, 'name')).toEqual({ name: 'Ridge walk' }); }); it('404 Trip not found for a user without access to the trip', async () => { @@ -191,7 +222,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => { expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); } - expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 }); + expect(await countRows(orm, Tours)).toBe(1); }); it('a member without place_edit can read but gets 403 on every write', async () => { @@ -212,8 +243,8 @@ describe('Tours e2e (real guards + temp SQLite)', () => { expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'No permission' }); } - expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 }); - expect(db.prepare('SELECT name FROM places WHERE id = ?').get(placeId)).toEqual({ name: 'Ridge walk' }); + expect(await countRows(orm, Tours)).toBe(1); + expect(await placeColumns(placeId, 'name')).toEqual({ name: 'Ridge walk' }); }); }); @@ -235,7 +266,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => { expect(res.body.tour.elevation_loss).toBe(50); expect(res.body.waypoints).toEqual(tourBody().waypoints); - expect(db.prepare('SELECT trip_id, lat, lng, transport_mode, route_geometry FROM places WHERE id = ?').get(placeId)).toEqual({ + expect(await placeColumns(placeId, 'trip_id', 'lat', 'lng', 'transport_mode', 'route_geometry')).toEqual({ trip_id: 5, lat: 47, lng: 11, transport_mode: 'walking', route_geometry: JSON.stringify(GEOMETRY), }); const events = broadcast.mock.calls.map(c => c[1]); @@ -254,7 +285,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => { it('lists newest first and reports planned once a day holds the tour', async () => { const first = await createViaApi(5, tourBody({ name: 'First' })); const second = await createViaApi(5, tourBody({ name: 'Second' })); - db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, 0)').run(first); + await makeDayAssignment(orm, 3, first, { order_index: 0 }); const res = await request(server).get('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)); expect(res.body.tours.map((t: { place_id: number; planned: boolean }) => [t.place_id, t.planned])).toEqual([ [second, false], @@ -272,12 +303,12 @@ describe('Tours e2e (real guards + temp SQLite)', () => { expect(shortRoute.status).toBe(400); const badDifficulty = await request(server).post('/api/trips/5/tours').set('Cookie', cookie).send(tourBody({ max_hiking_difficulty: 7 })); expect(badDifficulty.status).toBe(400); - expect(db.prepare('SELECT COUNT(*) AS n FROM places').get()).toEqual({ n: 0 }); + expect(await countRows(orm, Places)).toBe(0); }); it('GET 404 for a tour of another trip, a plain place and a non-numeric id', async () => { const foreign = await createViaApi(6); - const plain = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Cafe')").run().lastInsertRowid); + const plain = await plainPlace(5, 'Cafe'); for (const id of [foreign, plain, 'abc', 999999]) { const res = await request(server).get(`/api/trips/5/tours/${id}`).set('Cookie', sessionCookie(OWNER)); expect(res.status).toBe(404); @@ -298,7 +329,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => { { lat: 49, lng: 12, role: 'start', sequence: 0 }, { lat: 49.02, lng: 12.02, role: 'end', sequence: 1 }, ]); - expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 0 }); + expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(0); }); }); @@ -320,25 +351,25 @@ describe('Tours e2e (real guards + temp SQLite)', () => { place_id: placeId, name: 'Summit', elevation_gain: 200, elevation_loss: 100, duration: null, max_hiking_difficulty: 5, }); expect(res.body.waypoints).toEqual(waypoints); - expect(db.prepare('SELECT name, lat, lng, route_geometry FROM places WHERE id = ?').get(placeId)).toEqual({ + expect(await placeColumns(placeId, 'name', 'lat', 'lng', 'route_geometry')).toEqual({ name: 'Summit', lat: 46, lng: 10, route_geometry: JSON.stringify(geometry), }); - expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 3 }); + expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(3); expect(broadcast.mock.calls.map(c => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated'])); }); it('404 for a tour of another trip, a plain place and a non-numeric id, nothing changes', async () => { const foreign = await createViaApi(6); - const plain = Number(db.prepare("INSERT INTO places (trip_id, name) VALUES (5, 'Cafe')").run().lastInsertRowid); + const plain = await plainPlace(5, 'Cafe'); for (const id of [foreign, plain, 'abc']) { const res = await request(server).put(`/api/trips/5/tours/${id}`).set('Cookie', sessionCookie(OWNER)).send(tourBody({ name: 'Moved' })); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Tour not found' }); } - expect(db.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(foreign)).toEqual({ trip_id: 6, name: 'Ridge walk' }); - expect(db.prepare('SELECT name, route_geometry FROM places WHERE id = ?').get(plain)).toEqual({ name: 'Cafe', route_geometry: null }); - expect(db.prepare('SELECT COUNT(*) AS n FROM tours WHERE place_id = ?').get(plain)).toEqual({ n: 0 }); - expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(foreign)).toEqual({ n: 2 }); + expect(await placeColumns(foreign, 'trip_id', 'name')).toEqual({ trip_id: 6, name: 'Ridge walk' }); + expect(await placeColumns(plain, 'name', 'route_geometry')).toEqual({ name: 'Cafe', route_geometry: null }); + expect(await countRows(orm, Tours, { place: plain })).toBe(0); + expect(await countRows(orm, TourWaypoints, { place: foreign })).toBe(2); }); }); @@ -350,8 +381,8 @@ describe('Tours e2e (real guards + temp SQLite)', () => { expect(res.body.tours).toHaveLength(1); const tour = res.body.tours[0]; expect(tour).toMatchObject({ name: 'Alpine loop', tour_type: 'hike', match_confidence: 1, max_hiking_difficulty: 2, has_waypoints: false }); - expect(db.prepare('SELECT trip_id FROM places WHERE id = ?').get(tour.place_id)).toEqual({ trip_id: 5 }); - expect(db.prepare('SELECT tour_type FROM tours WHERE place_id = ?').get(tour.place_id)).toEqual({ tour_type: 'hike' }); + expect(await placeColumns(tour.place_id, 'trip_id')).toEqual({ trip_id: 5 }); + expect((await findRow(orm, Tours, { place: tour.place_id }))?.tour_type).toBe('hike'); expect(broadcast.mock.calls.map(c => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:created'])); }); @@ -367,7 +398,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => { const again = await importGpx(OWNER, TRACK_GPX); expect(again.status).toBe(201); expect(again.body).toEqual({ tours: [], caution: false, skipped: 1 }); - expect(db.prepare('SELECT COUNT(*) AS n FROM tours').get()).toEqual({ n: 1 }); + expect(await countRows(orm, Tours)).toBe(1); }); it('400 without a file, and for a file with no track or route', async () => { @@ -379,7 +410,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => { expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'No track or route found in GPX file' }); } - expect(db.prepare('SELECT COUNT(*) AS n FROM places').get()).toEqual({ n: 0 }); + expect(await countRows(orm, Places)).toBe(0); }); }); @@ -400,8 +431,8 @@ describe('Tours e2e (real guards + temp SQLite)', () => { const move = await request(server).put(`/api/trips/5/assignments/${moving}/move`).set('Cookie', cookie).send({ new_day_id: 4, order_index: 1 }); expect(move.status).toBe(409); expect(move.body).toEqual({ error: 'Tour is already assigned to this day' }); - expect(db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(moving)).toEqual({ day_id: 3 }); - expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(placeId)).toEqual({ n: 2 }); + expect((await findRow(orm, DayAssignments, { id: moving }))?.day_id).toBe(3); + expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(2); }); it('copying the trip carries its tours and waypoints onto the new places', async () => { @@ -428,7 +459,7 @@ describe('Tours e2e (real guards + temp SQLite)', () => { const detail = await request(server).get(`/api/trips/${copyId}/tours/${copied.place_id}`).set('Cookie', sessionCookie(OWNER)); expect(detail.body.waypoints).toEqual(source.waypoints); - expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 3 }); + expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(3); }); it('deleting the place removes its tour and waypoints and reports the tour id', async () => { @@ -436,8 +467,8 @@ describe('Tours e2e (real guards + temp SQLite)', () => { const res = await request(server).delete(`/api/trips/5/places/${placeId}`).set('Cookie', sessionCookie(OWNER)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, tourPlaceIds: [placeId] }); - expect(db.prepare('SELECT COUNT(*) AS n FROM tours WHERE place_id = ?').get(placeId)).toEqual({ n: 0 }); - expect(db.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId)).toEqual({ n: 0 }); + expect(await countRows(orm, Tours, { place: placeId })).toBe(0); + expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(0); }); }); }); diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index 0adc0ad28..304dd8132 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -18,21 +18,8 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); - const mock = { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: () => null, - canAccessTrip: (tripId: unknown, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), - isOwner: (tripId: unknown, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), - }; - return mock; + // Trip access reads through TripsRepository now; the module only hands out the handle. + return { db, closeDb: () => {}, reinitialize: () => {} }; }); @@ -65,6 +52,14 @@ import { createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, } from '../../helpers/journey-repos'; import { createTestToursRepo, createTour } from '../../helpers/tours-repos'; +import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { tagPlace } from '../../helpers/factories/places'; +import { makeDayAccommodation } from '../../helpers/factories/itinerary'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; let svc: AssignmentsService; beforeAll(async () => { @@ -122,9 +117,26 @@ function fixture() { return { user, trip, day, place }; } +const orm = () => sharedTestOrm(testDb); + +/** The stored stop; fails the case when it is gone. */ +async function assignmentRow(id: number) { + const row = await findRow(await orm(), DayAssignments, { id }); + if (!row) throw new Error(`no day assignment ${id}`); + return row; +} + +async function setAssignment(id: number, data: { assignment_time?: string; assignment_end_time?: string; place?: number; accommodation_id?: number }) { + await updateRows(await orm(), DayAssignments, { id }, data); +} + +async function setPlace(id: number, data: { route_geometry?: string; lat?: null; lng?: null; place_time?: string }) { + await updateRows(await orm(), Places, { id }, data); +} + /** Makes `placeId` a Tour: a `tours` facet row plus the route it was planned along. */ -function addTourFacet(placeId: number, routeGeometry = '[[48.1,11.5],[48.2,11.6]]') { - testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run(routeGeometry, placeId); +async function addTourFacet(placeId: number, routeGeometry = '[[48.1,11.5],[48.2,11.6]]') { + await setPlace(placeId, { route_geometry: routeGeometry }); createTour(testDb, placeId); } @@ -259,7 +271,7 @@ describe('createAssignment', () => { it('ASG-SVC-009: returns the re-selected nested shape with place, category and compact tags', async () => { const { user, trip, day, place } = fixture(); const tag = createTag(testDb, user.id, { name: 'museum' }); - testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id); + await tagPlace(await orm(), place.id, [tag.id]); const a = await svc.createAssignment(day.id, place.id, 'skip the line'); expect(a).toMatchObject({ day_id: day.id, @@ -281,7 +293,7 @@ describe('createAssignment', () => { const { trip, day, place: tourPlace } = fixture(); const secondDay = createDay(testDb, trip.id); const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }); - addTourFacet(tourPlace.id); + await addTourFacet(tourPlace.id); const firstTourAssignment = await svc.createAssignment(day.id, tourPlace.id); await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow(ConflictException); @@ -289,16 +301,16 @@ describe('createAssignment', () => { const otherDayTourAssignment = await svc.createAssignment(secondDay.id, tourPlace.id); expect(firstTourAssignment?.day_id).toBe(day.id); expect(otherDayTourAssignment?.day_id).toBe(secondDay.id); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlace.id)).toEqual({ n: 2 }); + expect(await countRows(await orm(), DayAssignments, { place: tourPlace.id })).toBe(2); await svc.createAssignment(day.id, ordinaryPlace.id); await svc.createAssignment(day.id, ordinaryPlace.id); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(ordinaryPlace.id)).toEqual({ n: 2 }); + expect(await countRows(await orm(), DayAssignments, { place: ordinaryPlace.id })).toBe(2); }); it('ASG-SVC-032: near-concurrent Tour assignments serialize to one row and one conflict', async () => { const { day, place } = fixture(); - addTourFacet(place.id); + await addTourFacet(place.id); const results = await Promise.allSettled([ svc.createAssignment(day.id, place.id), @@ -309,15 +321,15 @@ describe('createAssignment', () => { const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); expect(rejected).toHaveLength(1); expect(rejected[0].reason).toBeInstanceOf(ConflictException); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ? AND place_id = ?').get(day.id, place.id)).toEqual({ n: 1 }); + expect(await countRows(await orm(), DayAssignments, { day: day.id, place: place.id })).toBe(1); }); it('ASG-SVC-048: a Tour stop carries its facet id and route geometry, an ordinary stop nulls both', async () => { const { trip, day, place: tourPlace } = fixture(); const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }); // Geometry on a plain place (an imported track) must not leak into the Tour fields. - testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', ordinaryPlace.id); - addTourFacet(tourPlace.id, '[[47,11],[47.5,11.5]]'); + await setPlace(ordinaryPlace.id, { route_geometry: '[[1,2],[3,4]]' }); + await addTourFacet(tourPlace.id, '[[47,11],[47.5,11.5]]'); const tour = await svc.createAssignment(day.id, tourPlace.id); const ordinary = await svc.createAssignment(day.id, ordinaryPlace.id); @@ -339,7 +351,7 @@ describe('listDayAssignments', () => { const { user, trip, day, place } = fixture(); const second = createPlace(testDb, trip.id, { name: 'Orsay' }); const tag = createTag(testDb, user.id, { name: 'art' }); - testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id); + await tagPlace(await orm(), place.id, [tag.id]); const a1 = createDayAssignment(testDb, day.id, place.id, { order_index: 1 }); const a2 = createDayAssignment(testDb, day.id, second.id, { order_index: 0 }); await svc.setParticipants(a1.id, [user.id], trip.id); @@ -366,7 +378,7 @@ describe('deleteAssignment / reorderAssignments', () => { const { day, place } = fixture(); const a = createDayAssignment(testDb, day.id, place.id); await svc.deleteAssignment(a.id); - expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(a.id)).toBeUndefined(); + expect(await findRow(await orm(), DayAssignments, { id: a.id })).toBeNull(); }); it('ASG-SVC-013: reorderAssignments applies positional order, scoped to the day', async () => { @@ -378,11 +390,11 @@ describe('deleteAssignment / reorderAssignments', () => { await svc.reorderAssignments(day.id, [a2.id, a1.id, foreign.id]); - const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index; - expect(order(a2.id)).toBe(0); - expect(order(a1.id)).toBe(1); + const order = async (id: number) => (await assignmentRow(id)).order_index; + expect(await order(a2.id)).toBe(0); + expect(await order(a1.id)).toBe(1); // The day-scoped WHERE leaves another day's assignment untouched. - expect(order(foreign.id)).toBe(5); + expect(await order(foreign.id)).toBe(5); }); }); @@ -395,9 +407,9 @@ describe('clearDay (#2470)', () => { const kept = createDayAssignment(testDb, otherDay.id, place.id); expect((await svc.clearDay(day.id)).sort()).toEqual([a1.id, a2.id].sort()); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 }); - expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(kept.id)).toEqual({ id: kept.id }); - expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toEqual({ id: day.id }); + expect(await countRows(await orm(), DayAssignments, { day: day.id })).toBe(0); + expect((await findRow(await orm(), DayAssignments, { id: kept.id }))?.id).toBe(kept.id); + expect((await findRow(await orm(), Days, { id: day.id }))?.id).toBe(day.id); }); it('ASG-SVC-013c: an empty day clears to an empty list', async () => { @@ -439,18 +451,19 @@ describe('moveAssignment', () => { it('ASG-SVC-049: refuses to move a Tour onto a day that already holds it, and leaves the row where it was', async () => { const { trip, day, place } = fixture(); const target = createDay(testDb, trip.id); - addTourFacet(place.id); + await addTourFacet(place.id); createDayAssignment(testDb, target.id, place.id); const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 }); await expect(svc.moveAssignment(a.id, target.id, 0)).rejects.toThrow(ConflictException); - expect(testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ day_id: day.id, order_index: 3 }); + const stayed = await assignmentRow(a.id); + expect({ day_id: stayed.day_id, order_index: stayed.order_index }).toEqual({ day_id: day.id, order_index: 3 }); }); it('ASG-SVC-050: a Tour may be reordered within its own day, and an ordinary place may join a day that already holds it', async () => { const { trip, day, place } = fixture(); const target = createDay(testDb, trip.id); - addTourFacet(place.id); + await addTourFacet(place.id); const tour = createDayAssignment(testDb, day.id, place.id, { order_index: 0 }); expect((await svc.moveAssignment(tour.id, day.id, 2)).assignment).toMatchObject({ day_id: day.id, order_index: 2 }); @@ -485,7 +498,7 @@ describe('getParticipants / setParticipants', () => { it('ASG-SVC-019: username COALESCEs display_name over username', async () => { const { user, trip, day, place } = fixture(); - testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Fancy Name', user.id); + await updateRows(await orm(), Users, { id: user.id }, { display_name: 'Fancy Name' }); const a = createDayAssignment(testDb, day.id, place.id); const rows = await svc.setParticipants(a.id, [user.id], trip.id); expect(rows).toEqual([{ user_id: user.id, username: 'Fancy Name', avatar: null }]); @@ -529,33 +542,35 @@ describe('getParticipants / setParticipants', () => { // ── updateTime / setLegTransportMode ────────────────────────────────────────── describe('updateTime', () => { - const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index; + const order = async (id: number) => (await assignmentRow(id)).order_index; /** The day the way every reader lists it: by order_index, then insertion. */ - const dayOrder = (dayId: number) => - (testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index, created_at, id').all(dayId) as { id: number }[]).map(r => r.id); + const dayOrder = async (dayId: number) => + (await findRows(await orm(), DayAssignments, { day: dayId }, { order_index: 'asc', created_at: 'asc', id: 'asc' })).map(r => r.id); /** * A day of stops at the given order_index keys, each with its own start (null for * none). Times go in by SQL so the setup itself never sorts anything. */ - function dayOf(stops: [string | null, number][]) { + async function dayOf(stops: [string | null, number][]) { const { trip, day, place } = fixture(); - const ids = stops.map(([time, orderIndex]) => { + const ids: number[] = []; + for (const [time, orderIndex] of stops) { const a = createDayAssignment(testDb, day.id, place.id, { order_index: orderIndex }); - if (time) testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, a.id); - return a.id; - }); + if (time) await setAssignment(a.id, { assignment_time: time }); + ids.push(a.id); + } return { trip, day, ids }; } - function addVia(dayId: number, afterOrderIndex: number, sequence = 0): number { - return Number(testDb.prepare( - 'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, 48.1, 11.5)' - ).run(dayId, afterOrderIndex, sequence).lastInsertRowid); + async function addVia(dayId: number, afterOrderIndex: number, sequence = 0): Promise { + return insertRow(await orm(), RoadtripVias, { day: dayId, after_order_index: afterOrderIndex, sequence, lat: 48.1, lng: 11.5 }); } - const viaAnchors = (dayId: number) => - testDb.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId) as - { id: number; after_order_index: number; sequence: number }[]; + const viaAnchors = async (dayId: number) => + (await findRows(await orm(), RoadtripVias, { day: dayId }, { id: 'asc' })).map((v) => ({ + id: v.id, + after_order_index: v.after_order_index, + sequence: v.sequence, + })); it('ASG-SVC-021: persists both times and re-selects the nested shape', async () => { const { day, place } = fixture(); @@ -567,19 +582,19 @@ describe('updateTime', () => { it('ASG-SVC-022: a start sorts the timed stops and leaves every untimed stop behind the one it followed', async () => { // The tester's day: two stops without a time, then a start on the last one. It // used to jump to the top, because every untimed stop was appended after it. - const untimedHead = dayOf([[null, 0], [null, 1], [null, 2]]); + const untimedHead = await dayOf([[null, 0], [null, 1], [null, 2]]); await svc.updateTime(untimedHead.ids[2], '14:00', null); - expect(dayOrder(untimedHead.day.id)).toEqual(untimedHead.ids); + expect(await dayOrder(untimedHead.day.id)).toEqual(untimedHead.ids); - const between = dayOf([['09:00', 0], [null, 1], [null, 2]]); + const between = await dayOf([['09:00', 0], [null, 1], [null, 2]]); await svc.updateTime(between.ids[2], '14:00', null); - expect(dayOrder(between.day.id)).toEqual(between.ids); + expect(await dayOrder(between.day.id)).toEqual(between.ids); // Timed stops still keep time order among themselves. A has nothing timed in // front of it and stays first. - const [a, b, c] = dayOf([[null, 0], ['15:00', 1], [null, 2]]).ids; + const [a, b, c] = (await dayOf([[null, 0], ['15:00', 1], [null, 2]])).ids; await svc.updateTime(c, '10:00', null); - expect([order(a), order(c), order(b)]).toEqual([0, 1, 2]); + expect(await Promise.all([order(a), order(c), order(b)])).toEqual([0, 1, 2]); }); it('ASG-SVC-022b: a booked night sorts by its check-in, which is where its hour lives', async () => { @@ -590,40 +605,38 @@ describe('updateTime', () => { const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' }); const afternoon = createDayAssignment(testDb, day.id, place.id, { order_index: 0 }); const night = createDayAssignment(testDb, day.id, hotel.id, { order_index: 1 }); - const stay = testDb.prepare( - "INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (?, ?, ?, ?, '11:00')" - ).run(trip.id, hotel.id, day.id, day.id); - testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(Number(stay.lastInsertRowid), night.id); + const stay = await makeDayAccommodation(await orm(), trip.id, hotel.id, day.id, day.id, { check_in: '11:00' }); + await setAssignment(night.id, { accommodation_id: stay.id }); await svc.updateTime(afternoon.id, '16:00', null); - expect(order(night.id)).toBeLessThan(order(afternoon.id)); + expect(await order(night.id)).toBeLessThan(await order(afternoon.id)); }); it('ASG-SVC-023: clearing with null persists but skips the auto-sort (falsy gate)', async () => { - const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 1], ['12:00', 2]]); + const { day, ids: [a, b, c] } = await dayOf([['09:00', 0], [null, 1], ['12:00', 2]]); await svc.updateTime(b, '06:00', null); // sorts b first - expect(dayOrder(day.id)).toEqual([b, a, c]); + expect(await dayOrder(day.id)).toEqual([b, a, c]); // Dragged back out of time order on purpose: a sort now would put b first again, // with c behind it. await svc.reorderAssignments(day.id, [a, b, c]); const { assignment } = await svc.updateTime(c, null, null); // clear, no re-sort expect((await assignment!).assignment_time).toBeNull(); - expect(dayOrder(day.id)).toEqual([a, b, c]); + expect(await dayOrder(day.id)).toEqual([a, b, c]); }); it('ASG-SVC-027: an empty-string time clears the override like null (and skips the auto-sort)', async () => { - const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 1], ['12:00', 2]]); + const { day, ids: [a, b, c] } = await dayOf([['09:00', 0], [null, 1], ['12:00', 2]]); await svc.updateTime(b, '06:00', '07:00'); // sorts b first - expect(dayOrder(day.id)).toEqual([b, a, c]); + expect(await dayOrder(day.id)).toEqual([b, a, c]); await svc.reorderAssignments(day.id, [a, b, c]); const { assignment } = await svc.updateTime(c, '', ''); expect((await assignment!).assignment_time).toBeNull(); expect((await assignment!).assignment_end_time).toBeNull(); - expect(testDb.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(c)).toEqual({ assignment_time: null }); - expect(dayOrder(day.id)).toEqual([a, b, c]); + expect((await assignmentRow(c)).assignment_time).toBeNull(); + expect(await dayOrder(day.id)).toEqual([a, b, c]); }); it('ASG-SVC-024: a time without ":" sorts last among timed via the 99:99 sentinel', async () => { @@ -632,23 +645,24 @@ describe('updateTime', () => { const b = createDayAssignment(testDb, day.id, place.id, { order_index: 1 }); await svc.updateTime(a.id, 'morning', null); // no colon → '99:99' in the sort await svc.updateTime(b.id, '13:00', null); - expect(order(b.id)).toBe(0); - expect(order(a.id)).toBe(1); + expect(await order(b.id)).toBe(0); + expect(await order(a.id)).toBe(1); }); it('ASG-SVC-033: writes nothing but the time when the day is already in order', async () => { // Keys with gaps, as a deleted stop leaves them. A day the start leaves in order // keeps them: nothing about it changed. - const { day, ids: [a, b, c] } = dayOf([['09:00', 0], [null, 5], [null, 9]]); - const via = addVia(day.id, 0); + const { day, ids: [a, b, c] } = await dayOf([['09:00', 0], [null, 5], [null, 9]]); + const via = await addVia(day.id, 0); + // test-sql-allow: total_changes() counts the writes on the connection itself, which no entity maps. const changes = () => (testDb.prepare('SELECT total_changes() AS n').get() as { n: number }).n; const before = changes(); const update = await svc.updateTime(c, '14:00', null); expect(changes() - before).toBe(1); - expect([order(a), order(b), order(c)]).toEqual([0, 5, 9]); - expect(viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]); + expect(await Promise.all([order(a), order(b), order(c)])).toEqual([0, 5, 9]); + expect(await viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]); expect(update.reordered).toBeNull(); expect(update.vias).toBeNull(); }); @@ -657,46 +671,47 @@ describe('updateTime', () => { // A gap, as a deleted stop leaves it. Kept, the writer would read 0, 4, 7 back and // everyone else would apply 0, 1, 2, and a note at 3 would sit behind a different // stop for each of them. - const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 4], [null, 7]]); + const { day, ids: [a, b, c] } = await dayOf([[null, 0], ['15:00', 4], [null, 7]]); + // test-sql-allow: total_changes() counts the writes on the connection itself, which no entity maps. const changes = () => (testDb.prepare('SELECT total_changes() AS n').get() as { n: number }).n; const before = changes(); const update = await svc.updateTime(c, '10:00', null); expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, c, b] }); - expect(update.reordered!.orderedIds.map(order)).toEqual([0, 1, 2]); + expect(await Promise.all(update.reordered!.orderedIds.map(order))).toEqual([0, 1, 2]); // The time, then c and b. A already holds 0 and is not written. expect(changes() - before).toBe(3); }); it('ASG-SVC-035: numbers a day with colliding keys from 0 again, since a shared key cannot hold an order', async () => { // A move drops a stop in at order_index 0 next to the one already there. - const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 0], [null, 0]]); + const { day, ids: [a, b, c] } = await dayOf([[null, 0], ['15:00', 0], [null, 0]]); await svc.updateTime(c, '10:00', null); - expect(dayOrder(day.id)).toEqual([a, c, b]); - expect([order(a), order(c), order(b)]).toEqual([0, 1, 2]); + expect(await dayOrder(day.id)).toEqual([a, c, b]); + expect(await Promise.all([order(a), order(c), order(b)])).toEqual([0, 1, 2]); }); it('ASG-SVC-036: keeps each drawn road behind the stop it was drawn after, counting located stops only', async () => { // X has no coordinates, so the router never sees it and the vias do not count it. - const { trip, day, ids: [a, x, b, c, d] } = dayOf([[null, 0], [null, 1], ['15:00', 2], [null, 3], [null, 4]]); + const { trip, day, ids: [a, x, b, c, d] } = await dayOf([[null, 0], [null, 1], ['15:00', 2], [null, 3], [null, 4]]); const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' }); - testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unmapped.id); - testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(unmapped.id, x); - const afterA = addVia(day.id, 0); - const afterB = addVia(day.id, 1); - const afterBToo = addVia(day.id, 1, 1); - addVia(day.id, 2); // after C + await setPlace(unmapped.id, { lat: null, lng: null }); + await setAssignment(x, { place: unmapped.id }); + const afterA = await addVia(day.id, 0); + const afterB = await addVia(day.id, 1); + const afterBToo = await addVia(day.id, 1, 1); + await addVia(day.id, 2); // after C // D at 10:00 goes in front of B at 15:00, and C stays behind B: A, X, D, B, C. const update = await svc.updateTime(d, '10:00', null); - expect(dayOrder(day.id)).toEqual([a, x, d, b, c]); + expect(await dayOrder(day.id)).toEqual([a, x, d, b, c]); // A keeps its leg, B's two points follow B to 2 in their order, and C, now last, // has no leg left to bend. - expect(viaAnchors(day.id)).toEqual([ + expect(await viaAnchors(day.id)).toEqual([ { id: afterA, after_order_index: 0, sequence: 0 }, { id: afterB, after_order_index: 2, sequence: 0 }, { id: afterBToo, after_order_index: 2, sequence: 1 }, @@ -707,32 +722,32 @@ describe('updateTime', () => { }); it('ASG-SVC-037: reports no vias when the reorder leaves every one on its stop', async () => { - const { day, ids: [a, b, c] } = dayOf([[null, 0], ['15:00', 1], [null, 2]]); - const via = addVia(day.id, 0); + const { day, ids: [a, b, c] } = await dayOf([[null, 0], ['15:00', 1], [null, 2]]); + const via = await addVia(day.id, 0); const update = await svc.updateTime(c, '10:00', null); expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, c, b] }); - expect(viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]); + expect(await viaAnchors(day.id)).toEqual([{ id: via, after_order_index: 0, sequence: 0 }]); expect(update.vias).toBeNull(); }); it('ASG-SVC-038: leaves every via alone when the sort moves only stops without coordinates', async () => { // X has no coordinates. It goes between A and B, and the located stops stay A, B. - const { trip, day, ids: [a, b, x] } = dayOf([['09:00', 0], ['12:00', 1], [null, 2]]); + const { trip, day, ids: [a, b, x] } = await dayOf([['09:00', 0], ['12:00', 1], [null, 2]]); const unmapped = createPlace(testDb, trip.id, { name: 'Somewhere unmapped' }); - testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unmapped.id); - testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(unmapped.id, x); - const afterA = addVia(day.id, 0); + await setPlace(unmapped.id, { lat: null, lng: null }); + await setAssignment(x, { place: unmapped.id }); + const afterA = await addVia(day.id, 0); // Behind the day's last stop: the drive into the next day on a trip with connected // days. It was deleted here, since a last stop has no leg of its own. - const intoTomorrow = addVia(day.id, 1); + const intoTomorrow = await addVia(day.id, 1); const update = await svc.updateTime(x, '10:00', null); - expect(dayOrder(day.id)).toEqual([a, x, b]); + expect(await dayOrder(day.id)).toEqual([a, x, b]); expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [a, x, b] }); - expect(viaAnchors(day.id)).toEqual([ + expect(await viaAnchors(day.id)).toEqual([ { id: afterA, after_order_index: 0, sequence: 0 }, { id: intoTomorrow, after_order_index: 1, sequence: 0 }, ]); @@ -740,15 +755,15 @@ describe('updateTime', () => { }); it('ASG-SVC-039: keeps the via behind the last stop when that stop is still last', async () => { - const { day, ids: [a, b, c, d] } = dayOf([[null, 0], ['15:00', 1], [null, 2], ['18:00', 3]]); - const afterB = addVia(day.id, 1); - const intoTomorrow = addVia(day.id, 3); + const { day, ids: [a, b, c, d] } = await dayOf([[null, 0], ['15:00', 1], [null, 2], ['18:00', 3]]); + const afterB = await addVia(day.id, 1); + const intoTomorrow = await addVia(day.id, 3); // C at 10:00 goes in front of B. D stays last, and so does the drive out of it. const update = await svc.updateTime(c, '10:00', null); - expect(dayOrder(day.id)).toEqual([a, c, b, d]); - expect(viaAnchors(day.id)).toEqual([ + expect(await dayOrder(day.id)).toEqual([a, c, b, d]); + expect(await viaAnchors(day.id)).toEqual([ { id: afterB, after_order_index: 2, sequence: 0 }, { id: intoTomorrow, after_order_index: 3, sequence: 0 }, ]); @@ -760,23 +775,23 @@ describe('updateTime', () => { // there only while that stop is last. Once another stop is, it goes: it lies on the // road to tomorrow, and read as the leg its stop leaves by now it would bend the // drive from C to A through a point on that road. - const { day, ids: [a, b, c] } = dayOf([['09:00', 0], ['12:00', 1], ['18:00', 2]]); - const afterA = addVia(day.id, 0); - const intoTomorrow = addVia(day.id, 2); + const { day, ids: [a, b, c] } = await dayOf([['09:00', 0], ['12:00', 1], ['18:00', 2]]); + const afterA = await addVia(day.id, 0); + const intoTomorrow = await addVia(day.id, 2); const update = await svc.updateTime(c, '08:00', null); - expect(dayOrder(day.id)).toEqual([c, a, b]); - expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]); - expect(viaAnchors(day.id).map(via => via.id)).not.toContain(intoTomorrow); + expect(await dayOrder(day.id)).toEqual([c, a, b]); + expect(await viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]); + expect((await viaAnchors(day.id)).map(via => via.id)).not.toContain(intoTomorrow); expect(update.vias?.dayId).toBe(day.id); }); it('ASG-SVC-040: an End saved with the start as it stood leaves a day dragged out of time order alone', async () => { // B belongs first by time, and the traveller put it second on purpose. - const { day, ids: [a, b, c] } = dayOf([['14:00', 0], ['10:00', 1], ['16:00', 2]]); - testDb.prepare("UPDATE day_assignments SET assignment_end_time = '11:00' WHERE id = ?").run(b); - const afterA = addVia(day.id, 0); + const { day, ids: [a, b, c] } = await dayOf([['14:00', 0], ['10:00', 1], ['16:00', 2]]); + await setAssignment(b, { assignment_end_time: '11:00' }); + const afterA = await addVia(day.id, 0); // The stay dialog taking the End off, then the place form setting a new one. for (const end of [null, '12:00']) { @@ -784,28 +799,28 @@ describe('updateTime', () => { expect(update.assignment).toMatchObject({ assignment_time: '10:00', assignment_end_time: end }); expect(update.reordered).toBeNull(); expect(update.vias).toBeNull(); - expect(dayOrder(day.id)).toEqual([a, b, c]); - expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0, sequence: 0 }]); + expect(await dayOrder(day.id)).toEqual([a, b, c]); + expect(await viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0, sequence: 0 }]); } // A start that moves still sorts, and the via follows A. const update = await svc.updateTime(b, '09:00', '12:00'); - expect(dayOrder(day.id)).toEqual([b, a, c]); + expect(await dayOrder(day.id)).toEqual([b, a, c]); expect(update.reordered).toEqual({ dayId: day.id, orderedIds: [b, a, c] }); - expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]); + expect(await viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 1, sequence: 0 }]); }); it('ASG-SVC-041: a visit given the start its place already had does not sort either', async () => { // The place form fills Start from the place when the visit has none of its own. - const { trip, day, ids: [a, b] } = dayOf([['14:00', 0], [null, 1]]); + const { trip, day, ids: [a, b] } = await dayOf([['14:00', 0], [null, 1]]); const timedPlace = createPlace(testDb, trip.id, { name: 'Opens at ten' }); - testDb.prepare("UPDATE places SET place_time = '10:00' WHERE id = ?").run(timedPlace.id); - testDb.prepare('UPDATE day_assignments SET place_id = ? WHERE id = ?').run(timedPlace.id, b); + await setPlace(timedPlace.id, { place_time: '10:00' }); + await setAssignment(b, { place: timedPlace.id }); const update = await svc.updateTime(b, '10:00', '11:00'); expect(update.reordered).toBeNull(); - expect(dayOrder(day.id)).toEqual([a, b]); + expect(await dayOrder(day.id)).toEqual([a, b]); }); }); @@ -815,7 +830,7 @@ describe('updateNotes (#2163)', () => { const a = createDayAssignment(testDb, day.id, place.id); const updated = await svc.updateNotes(a.id, 'Book the 10:00 timed entry'); expect(updated).toMatchObject({ id: a.id, notes: 'Book the 10:00 timed entry' }); - expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ notes: 'Book the 10:00 timed entry' }); + expect((await assignmentRow(a.id)).notes).toBe('Book the 10:00 timed entry'); }); it('ASG-SVC-031: empty string and null both clear the column (same normalisation as createAssignment)', async () => { @@ -825,7 +840,7 @@ describe('updateNotes (#2163)', () => { expect((await svc.updateNotes(a.id, '')!).notes).toBeNull(); await svc.updateNotes(a.id, 'gone again'); expect((await svc.updateNotes(a.id, null)!).notes).toBeNull(); - expect(testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id)).toEqual({ notes: null }); + expect((await assignmentRow(a.id)).notes).toBeNull(); }); it('ASG-SVC-032: leaves order and times untouched', async () => { @@ -836,7 +851,7 @@ describe('updateNotes (#2163)', () => { expect(updated).toMatchObject({ assignment_time: '09:00', assignment_end_time: '10:00' }); // 3, not 0: a start on a day already in order no longer renumbers it, so the key // this note edit must not touch is still the one the stop was created with. - expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(3); + expect((await assignmentRow(a.id)).order_index).toBe(3); }); }); diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts index b4061d5cd..9225e7393 100644 --- a/server/tests/unit/nest/journey-share.service.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -50,6 +50,13 @@ import type { JourneyPublicGalleryRow, JourneyShareTokensRepository } from '../. import type { JourneyEntriesRepository } from '../../../src/db/repositories/JourneyEntries.repository'; import type { JourneyEntryPhotosRepository } from '../../../src/db/repositories/JourneyEntryPhotos.repository'; import { GALLERY_CHRONOLOGICAL_ORDER } from '../../helpers/legacy-gallery-order'; +import { countRows, findRow, insertRow, insertRowIgnoringConflict, updateRows } from '../../helpers/factories/rows'; +import { setAppSetting, setUserSetting } from '../../helpers/factories/settings'; +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 { JourneyShareTokens } from '../../../src/db/entities/JourneyShareTokens.entity'; +import { Journeys } from '../../../src/db/entities/Journeys.entity'; let svc: JourneyShareService; let t: TestOrm; @@ -99,34 +106,34 @@ afterAll(async () => { // -- Helpers ------------------------------------------------------------------ /** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */ -function insertJourneyPhoto( +async function insertJourneyPhoto( entryId: number, opts: { filePath?: string; assetId?: string; ownerId?: number } = {} -): number { +): Promise { const provider = opts.assetId ? 'immich' : 'local'; const filePath = !opts.assetId ? (opts.filePath ?? '/photos/test.jpg') : null; - const trekResult = testDb.prepare(` - INSERT INTO trek_photos (provider, asset_id, file_path, owner_id, created_at) - VALUES (?, ?, ?, ?, ?) - `).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now()); - const trekId = trekResult.lastInsertRowid as number; + const trekId = await insertRow(t, TrekPhotos, { + provider, + asset_id: opts.assetId ?? null, + file_path: filePath, + owner: opts.ownerId ?? null, + // Epoch milliseconds, as these fixtures always stored them; the DATETIME + // column's numeric affinity keeps the digits an integer. + created_at: String(Date.now()), + }); // Look up journey_id from entry so gallery row is keyed to the journey (not entry). - const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number }; + const entryRow = await findRow(t, JourneyEntries, { id: entryId }); + if (!entryRow) throw new Error(`no journey entry ${entryId}`); const journeyId = entryRow.journey_id; const now = Date.now(); - testDb.prepare(` - INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at) - VALUES (?, ?, NULL, 0, ?) - `).run(journeyId, trekId, now); + await insertRowIgnoringConflict(t, JourneyPhotos, { journey: journeyId, photo: trekId, caption: null, sort_order: 0, created_at: now }); - const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number }; + const galleryRow = await findRow(t, JourneyPhotos, { journey: journeyId, photo: trekId }); + if (!galleryRow) throw new Error(`no gallery row for photo ${trekId}`); - testDb.prepare(` - INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) - VALUES (?, ?, 0, ?) - `).run(entryId, galleryRow.id, now); + await insertRowIgnoringConflict(t, JourneyEntryPhotos, { entry: entryId, journeyPhoto: galleryRow.id, sort_order: 0, created_at: now }); // Return trek_photos.id — this is p.photo_id in the public API response // and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind @@ -274,8 +281,7 @@ describe('createOrUpdateJourneyShareLink', () => { // Both callers agree on the one token that exists. expect(a!.token).toBe(b!.token); - const rows = testDb.prepare('SELECT COUNT(*) AS n FROM journey_share_tokens WHERE journey_id = ?').get(journey.id) as { n: number }; - expect(rows.n).toBe(1); + expect(await countRows(t, JourneyShareTokens, { journey: journey.id })).toBe(1); }); }); @@ -334,7 +340,7 @@ describe('validateShareTokenForPhoto', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); - const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id }); + const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const result = await svc.validateShareTokenForPhoto(token, photoId); @@ -354,7 +360,7 @@ describe('validateShareTokenForPhoto', () => { const journey1 = createJourney(testDb, user.id); const journey2 = createJourney(testDb, user.id); const entry2 = createJourneyEntry(testDb, journey2.id, user.id); - const photoId = insertJourneyPhoto(entry2.id); + const photoId = await insertJourneyPhoto(entry2.id); const { token } = await svc.createOrUpdateJourneyShareLink(journey1.id, user.id, {}); const result = await svc.validateShareTokenForPhoto(token, photoId); @@ -366,7 +372,7 @@ describe('validateShareTokenForPhoto', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); - const photoId = insertJourneyPhoto(entry.id, { ownerId: undefined }); + const photoId = await insertJourneyPhoto(entry.id, { ownerId: undefined }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const result = await svc.validateShareTokenForPhoto(token, photoId); @@ -380,7 +386,7 @@ describe('validateShareTokenForPhoto', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); - const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id }); + const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true }); expect(await svc.validateShareTokenForPhoto(token, photoId)).toBeNull(); @@ -396,11 +402,11 @@ describe('validateShareTokenForPhoto', () => { // Pre-populate trek_photos to push the autoincrement higher for (let i = 0; i < 5; i++) { - testDb.prepare(`INSERT INTO trek_photos (provider, asset_id, owner_id, created_at) VALUES ('immich', ?, ?, ?)`).run(`bulk-asset-${i}`, user.id, Date.now()); + await insertRow(t, TrekPhotos, { provider: 'immich', asset_id: `bulk-asset-${i}`, owner: user.id, created_at: String(Date.now()) }); } // This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1 - const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id }); + const trekPhotoId = await insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); // photoId = trek_photos.id (6), not journey_photos.id (1) @@ -417,7 +423,7 @@ describe('validateShareTokenForAsset', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); - insertJourneyPhoto(entry.id, { assetId: 'immich-asset-123', ownerId: user.id }); + await insertJourneyPhoto(entry.id, { assetId: 'immich-asset-123', ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const result = await svc.validateShareTokenForAsset(token, 'immich-asset-123'); @@ -436,7 +442,7 @@ describe('validateShareTokenForAsset', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); - insertJourneyPhoto(entry.id, { assetId: 'immich-asset-999', ownerId: user.id }); + await insertJourneyPhoto(entry.id, { assetId: 'immich-asset-999', ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true }); expect(await svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull(); @@ -446,7 +452,7 @@ describe('validateShareTokenForAsset', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); - insertJourneyPhoto(entry.id, { assetId: 'immich-asset-orphan' }); + await insertJourneyPhoto(entry.id, { assetId: 'immich-asset-orphan' }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); // Without the fallback the controller filled the gap from the :ownerId path @@ -482,7 +488,7 @@ describe('validateShareTokenForAsset', () => { const sharedJourney = createJourney(testDb, user.id); const otherJourney = createJourney(testDb, user.id); const otherEntry = createJourneyEntry(testDb, otherJourney.id, user.id); - insertJourneyPhoto(otherEntry.id, { assetId: 'other-journey-asset', ownerId: user.id }); + await insertJourneyPhoto(otherEntry.id, { assetId: 'other-journey-asset', ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(sharedJourney.id, user.id, {}); const result = await svc.validateShareTokenForAsset(token, 'other-journey-asset'); @@ -515,7 +521,7 @@ describe('getPublicJourney', () => { entry_date: '2026-03-22', location_name: 'Kyoto', }); - insertJourneyPhoto(entry1.id); + await insertJourneyPhoto(entry1.id); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, @@ -543,7 +549,7 @@ describe('getPublicJourney', () => { it('JOURNEY-SHARE-017b: a field the owner switched off is switched off for the reader too', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - testDb.prepare('UPDATE journeys SET show_mood = 0, show_weather = 0 WHERE id = ?').run(journey.id); + await updateRows(t, Journeys, { id: journey.id }, { show_mood: 0, show_weather: 0 }); createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Tag 1', entry_date: '2026-03-20' }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true, @@ -581,13 +587,14 @@ describe('getPublicJourney', () => { const journey = createJourney(testDb, user.id); const published = createJourneyEntry(testDb, journey.id, user.id, { title: 'Published', entry_date: '2026-01-10' }); const draft = createJourneyEntry(testDb, journey.id, user.id, { title: 'Draft', entry_date: '2026-01-11' }); - testDb.prepare('UPDATE journey_entries SET is_draft = 1 WHERE id = ?').run(draft.id); - const shownPhoto = insertJourneyPhoto(published.id, { ownerId: user.id }); - const draftPhoto = insertJourneyPhoto(draft.id, { ownerId: user.id }); + await updateRows(t, JourneyEntries, { id: draft.id }, { is_draft: 1 }); + const shownPhoto = await insertJourneyPhoto(published.id, { ownerId: user.id }); + const draftPhoto = await insertJourneyPhoto(draft.id, { ownerId: user.id }); // A photo on both a draft and a published entry is published. - const sharedPhoto = insertJourneyPhoto(draft.id, { ownerId: user.id, filePath: '/photos/both.jpg' }); - const sharedRow = testDb.prepare('SELECT id FROM journey_photos WHERE photo_id = ?').get(sharedPhoto) as { id: number }; - testDb.prepare('INSERT INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) VALUES (?, ?, 1, 0)').run(published.id, sharedRow.id); + const sharedPhoto = await insertJourneyPhoto(draft.id, { ownerId: user.id, filePath: '/photos/both.jpg' }); + const sharedRow = await findRow(t, JourneyPhotos, { photo: sharedPhoto }); + if (!sharedRow) throw new Error('the shared photo should be in the gallery'); + await insertRow(t, JourneyEntryPhotos, { entry: published.id, journeyPhoto: sharedRow.id, sort_order: 1, created_at: 0 }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true }); const result = (await svc.getPublicJourney(token))!; @@ -607,10 +614,9 @@ describe('getPublicJourney', () => { entry_date: '2026-04-01', }); // Set tags on the entry directly - testDb.prepare('UPDATE journey_entries SET tags = ? WHERE id = ?') - .run(JSON.stringify(['food', 'culture']), entry.id); - insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' }); - insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' }); + await updateRows(t, JourneyEntries, { id: entry.id }, { tags: JSON.stringify(['food', 'culture']) }); + await insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' }); + await insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const result = await svc.getPublicJourney(token); @@ -641,8 +647,8 @@ describe('getPublicJourney', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris', }); - testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id); - insertJourneyPhoto(entry.id); + await updateRows(t, JourneyEntries, { id: entry.id }, { location_lat: 48.8566, location_lng: 2.3522 }); + await insertJourneyPhoto(entry.id); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false, share_gallery: false, share_map: false, }); @@ -659,7 +665,7 @@ describe('getPublicJourney', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', location_name: 'Paris', }); - testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id); + await updateRows(t, JourneyEntries, { id: entry.id }, { location_lat: 48.8566, location_lng: 2.3522 }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: false, }); @@ -678,7 +684,7 @@ describe('getPublicJourney', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris', }); - testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id); + await updateRows(t, JourneyEntries, { id: entry.id }, { location_lat: 48.8566, location_lng: 2.3522 }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false, share_gallery: false, share_map: true, }); @@ -699,9 +705,8 @@ describe('getPublicJourney', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', }); - const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id }); - testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ?, taken_at = ? WHERE id = ?') - .run(48.8584, 2.2945, '2026-05-01T10:00:00Z', trekId); + const trekId = await insertJourneyPhoto(entry.id, { ownerId: user.id }); + await updateRows(t, TrekPhotos, { id: trekId }, { lat: 48.8584, lng: 2.2945, taken_at: '2026-05-01T10:00:00Z' }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: false, @@ -726,9 +731,8 @@ describe('getPublicJourney', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Day 1', entry_date: '2026-05-01', }); - const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id }); - testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ? WHERE id = ?') - .run(48.8584, 2.2945, trekId); + const trekId = await insertJourneyPhoto(entry.id, { ownerId: user.id }); + await updateRows(t, TrekPhotos, { id: trekId }, { lat: 48.8584, lng: 2.2945 }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: true, @@ -745,7 +749,7 @@ describe('getPublicJourney', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', }); - insertJourneyPhoto(entry.id, { ownerId: user.id }); + await insertJourneyPhoto(entry.id, { ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true, }); @@ -768,9 +772,9 @@ describe('getPublicJourney', () => { type: 'entry', title: 'Day 2', entry_date: '2026-05-02', }); - const late = insertJourneyPhoto(day2.id, { filePath: '/photos/day2.jpg' }); - insertJourneyPhoto(day1.id, { filePath: '/photos/day1.jpg' }); - testDb.prepare('UPDATE trek_photos SET taken_at = ? WHERE id = ?').run('2026-05-02T16:00:00.000Z', late); + const late = await insertJourneyPhoto(day2.id, { filePath: '/photos/day2.jpg' }); + await insertJourneyPhoto(day1.id, { filePath: '/photos/day1.jpg' }); + await updateRows(t, TrekPhotos, { id: late }, { taken_at: '2026-05-02T16:00:00.000Z' }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: true, @@ -786,9 +790,9 @@ describe('getPublicJourney', () => { const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true }); expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe(''); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run(); + await setAppSetting(t, 'default_user_setting_carto_api_key', 'instance-key'); expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('instance-key'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id); + await setUserSetting(t, user.id, 'carto_api_key', ' owner-key '); expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('owner-key'); }); }); @@ -823,6 +827,7 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme }); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare('SELECT token, share_timeline, share_gallery, share_map, newest_first FROM journey_share_tokens WHERE journey_id = ?') .get(journey.id); @@ -836,7 +841,9 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme share_timeline: true, share_gallery: false, share_map: true, newest_first: false, }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacyByJourney = testDb.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journey.id); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacyByToken = testDb.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token); expect(await shareTokensRepo.findByJourneyId(journey.id)).toEqual(legacyByJourney); @@ -848,6 +855,7 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme const journey = createJourney(testDb, user.id); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token); expect(await shareTokensRepo.findAccessByToken(token)).toEqual(legacy); @@ -858,13 +866,14 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme const journey = createJourney(testDb, user.id); createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01', location_name: 'No GPS' }); const withGps = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-02', location_name: 'Has GPS' }); - testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.85, 2.35, withGps.id); + await updateRows(t, JourneyEntries, { id: withGps.id }, { location_lat: 48.85, location_lng: 2.35 }); // A skeleton and a dismissed entry — both must be excluded, same as the legacy WHERE. createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', entry_date: '2026-01-03' }); const dismissed = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-04' }); - testDb.prepare('UPDATE journey_entries SET dismissed = 1 WHERE id = ?').run(dismissed.id); + await updateRows(t, JourneyEntries, { id: dismissed.id }, { dismissed: 1 }); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare(` SELECT je.* FROM journey_entries je WHERE je.journey_id = ? AND je.type != 'skeleton' AND je.dismissed = 0 @@ -879,9 +888,10 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' }); - insertJourneyPhoto(entry.id, { ownerId: user.id }); + await insertJourneyPhoto(entry.id, { ownerId: user.id }); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare(` SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at, tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height, @@ -901,16 +911,19 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' }); - insertJourneyPhoto(entry.id, { ownerId: user.id, filePath: '/photos/linked.jpg' }); // linked to an entry + await insertJourneyPhoto(entry.id, { ownerId: user.id, filePath: '/photos/linked.jpg' }); // linked to an entry // An unattached gallery photo — uploaded straight to the gallery, no journey_entry_photos row. - const unattachedTrekId = testDb - .prepare('INSERT INTO trek_photos (provider, asset_id, owner_id, file_path, created_at) VALUES (?, ?, ?, ?, ?)') - .run('local', null, user.id, '/photos/unattached.jpg', Date.now()).lastInsertRowid as number; - testDb - .prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, ?, ?)') - .run(journey.id, unattachedTrekId, 1, Date.now()); + const unattachedTrekId = await insertRow(t, TrekPhotos, { + provider: 'local', + asset_id: null, + owner: user.id, + file_path: '/photos/unattached.jpg', + created_at: String(Date.now()), + }); + await insertRow(t, JourneyPhotos, { journey: journey.id, photo: unattachedTrekId, sort_order: 1, created_at: Date.now() }); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare(` SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at, tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, @@ -954,7 +967,7 @@ describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-cas const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' }); - const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id }); + const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); await svc.deleteJourneyShareLink(journey.id, user.id); @@ -965,7 +978,7 @@ describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-cas const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' }); - insertJourneyPhoto(entry.id, { assetId: 'revoked-asset', ownerId: user.id }); + await insertJourneyPhoto(entry.id, { assetId: 'revoked-asset', ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); await svc.deleteJourneyShareLink(journey.id, user.id); @@ -987,7 +1000,7 @@ describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-cas const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' }); - const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id }); + const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const wrongCase = flipCase(token); @@ -995,6 +1008,7 @@ describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-cas // Mutation proof (loosen → red): a COLLATE NOCASE lookup on the SAME row // WOULD wrongly match — proving the null above depends on the real // exact-match WHERE, not a coincidence of the fixture. + // test-sql-allow: a COLLATE NOCASE lookup is the mutation this pins against, so it has to run as that SQL. expect(testDb.prepare('SELECT 1 FROM journey_share_tokens WHERE token = ? COLLATE NOCASE').get(wrongCase)).toBeTruthy(); }); @@ -1002,11 +1016,12 @@ describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-cas const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' }); - insertJourneyPhoto(entry.id, { assetId: 'case-asset', ownerId: user.id }); + await insertJourneyPhoto(entry.id, { assetId: 'case-asset', ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const wrongCase = flipCase(token); expect(await svc.validateShareTokenForAsset(wrongCase, 'case-asset')).toBeNull(); + // test-sql-allow: a COLLATE NOCASE lookup is the mutation this pins against, so it has to run as that SQL. expect(testDb.prepare('SELECT 1 FROM journey_share_tokens WHERE token = ? COLLATE NOCASE').get(wrongCase)).toBeTruthy(); }); @@ -1017,6 +1032,7 @@ describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-cas const wrongCase = flipCase(token); expect(await svc.getPublicJourney(wrongCase)).toBeNull(); + // test-sql-allow: a COLLATE NOCASE lookup is the mutation this pins against, so it has to run as that SQL. expect(testDb.prepare('SELECT 1 FROM journey_share_tokens WHERE token = ? COLLATE NOCASE').get(wrongCase)).toBeTruthy(); }); @@ -1024,7 +1040,7 @@ describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-cas const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' }); - const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id }); + const photoId = await insertJourneyPhoto(entry.id, { ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); expect(await svc.validateShareTokenForPhoto(`${token}\0trailing`, photoId)).toBeNull(); @@ -1035,7 +1051,7 @@ describe('R4 — JS6/JS9/JS11 are exact-match token lookups (revoked / wrong-cas const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' }); - insertJourneyPhoto(entry.id, { assetId: 'nul-asset', ownerId: user.id }); + await insertJourneyPhoto(entry.id, { assetId: 'nul-asset', ownerId: user.id }); const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); expect(await svc.validateShareTokenForAsset(`${token}\0trailing`, 'nul-asset')).toBeNull();