test(server): seed the days, tours, assignments and journey share specs through the factories

The days and tours e2e suites, the assignments service suite and the
journey share suite build their fixtures through the ORM factories and
read back through findRow/findRows/countRows. Fixtures that pin ids (the
trip, day and places a session cookie or a route targets) keep them.
Readers that the old suites called synchronously (order, dayOrder,
viaAnchors, insertJourneyPhoto) are async and awaited, Promise.all where
a case compares several at once.

Left raw and marked: the legacy statements the parity cases hold the
repository reads to, the COLLATE NOCASE lookups that prove the token
match is exact, and total_changes(), which counts writes on the
connection itself.
This commit is contained in:
Maurice
2026-10-08 16:40:19 +02:00
parent a3781ed27b
commit 5947631db6
4 changed files with 375 additions and 295 deletions
+72 -54
View File
@@ -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();
});
});
+73 -42
View File
@@ -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 <K extends keyof PlaceRow>(id: number, ...keys: K[]) => {
const row = await findRow(orm, Places, { id });
return Object.fromEntries(keys.map((k) => [k, row?.[k]])) as Pick<PlaceRow, K>;
};
/** 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<string, unknown> = 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);
});
});
});
+141 -126
View File
@@ -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<number> {
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);
});
});
@@ -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<number> {
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();