mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
be016f180e
The specs written alongside the session, transaction and lifecycle work still seeded and read with raw statements. They go through the ORM factories now; the few that test SQL below the ORM carry a test-sql-allow reason. The raw SQL baseline holds 177 statements in 6 files.
3728 lines
178 KiB
TypeScript
3728 lines
178 KiB
TypeScript
/**
|
|
* Unit tests for JourneyDomainService (JOURNEY-SVC-001 through JOURNEY-SVC-038).
|
|
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
|
|
|
// -- DB setup -----------------------------------------------------------------
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
|
|
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
// Plan 3g Task 1 (Part A, R9): `getPlaceWithTags`/`canAccessTrip`/`isOwner`
|
|
// dropped from this mock — none of the three are exports of
|
|
// `src/db/database.ts` any more (it exports only `db`/`closeDb`/
|
|
// `reinitialize`/`getRawConnection`/`registerReinitializeHook`/
|
|
// `runDemoSeed`; the three methods this mock used to shadow moved to real
|
|
// `DatabaseService` class methods, delegating to real repositories, long
|
|
// before this task). They were dead weight even before AP1 converted —
|
|
// nothing in this file ever read them off the mocked module. AP1's own
|
|
// guard now goes through a REAL `TripsRepository.findAccessible` below
|
|
// (`createTestTripsRepo`), not a hand-written closure.
|
|
const mock = { db, closeDb: () => {}, reinitialize: () => {} };
|
|
return mock;
|
|
});
|
|
|
|
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
|
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import {
|
|
createUser,
|
|
createTrip,
|
|
createJourney,
|
|
createJourneyEntry,
|
|
addJourneyContributor,
|
|
createPlace,
|
|
createDay,
|
|
createDayAssignment,
|
|
addTripPhoto,
|
|
} from '../../helpers/factories';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
|
|
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
|
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
|
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
|
import { db as dbConn } from '../../../src/db/database';
|
|
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
|
|
import {
|
|
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
|
|
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
|
|
} from '../../helpers/journey-repos';
|
|
// M5d (task-5-review.md) — full-key parity tests for Task 2's read models,
|
|
// ported from the reviewer's probe (scratchpad r3g/probe/zz-r3g-parity-probe.test.ts).
|
|
import { todayUtc } from '@trek/shared';
|
|
import { GALLERY_CHRONOLOGICAL_ORDER } from '../../helpers/legacy-gallery-order';
|
|
import type { EntityClass, EntityDTO, FilterQuery, FindOptions, RequiredEntityData } from '@mikro-orm/core';
|
|
import { countRows, deleteRows, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
|
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
|
import { Days } from '../../../src/db/entities/Days.entity';
|
|
import { JourneyContributors } from '../../../src/db/entities/JourneyContributors.entity';
|
|
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
|
import { JourneyEntryPhotos } from '../../../src/db/entities/JourneyEntryPhotos.entity';
|
|
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
|
|
import { JourneyTrips } from '../../../src/db/entities/JourneyTrips.entity';
|
|
import { Journeys } from '../../../src/db/entities/Journeys.entity';
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
|
|
import { Trips } from '../../../src/db/entities/Trips.entity';
|
|
|
|
const orm = () => sharedTestOrm(testDb);
|
|
|
|
/** The first matching row, or undefined when none matches, as a statement's .get() handed it back. */
|
|
async function one<T extends object>(entity: EntityClass<T>, where: FilterQuery<T>, orderBy?: FindOptions<T>['orderBy']) {
|
|
return (await findRows(await orm(), entity, where, orderBy))[0] as EntityDTO<T> | undefined;
|
|
}
|
|
|
|
/** A row from parallel lists of property names and values. */
|
|
function columnsOf<T extends object>(keys: readonly string[], values: readonly unknown[]): RequiredEntityData<T> {
|
|
return Object.fromEntries(keys.map((k, i) => [k, values[i]])) as RequiredEntityData<T>;
|
|
}
|
|
|
|
function pick<T extends object, K extends keyof T>(row: T, keys: readonly K[]): Pick<T, K> {
|
|
return Object.fromEntries(keys.map((k) => [k, row[k]])) as Pick<T, K>;
|
|
}
|
|
|
|
/** The named columns of the first matching row, or undefined when none matches. */
|
|
async function pickOne<T extends object, K extends keyof EntityDTO<T>>(
|
|
entity: EntityClass<T>, where: FilterQuery<T>, keys: readonly K[], orderBy?: FindOptions<T>['orderBy'],
|
|
): Promise<Pick<EntityDTO<T>, K> | undefined> {
|
|
const row = await one(entity, where, orderBy);
|
|
return row && pick(row, keys);
|
|
}
|
|
|
|
/** The named columns of every matching row. */
|
|
async function pickAll<T extends object, K extends keyof EntityDTO<T>>(
|
|
entity: EntityClass<T>, where: FilterQuery<T>, keys: readonly K[], orderBy?: FindOptions<T>['orderBy'],
|
|
): Promise<Pick<EntityDTO<T>, K>[]> {
|
|
return (await findRows(await orm(), entity, where, orderBy)).map((row) => pick(row, keys));
|
|
}
|
|
|
|
let svc: JourneyDomainService;
|
|
// Plan 3g Task 1's own additions (below, "repositories (R9's parity + mutation
|
|
// proofs)" describe block) reach the repositories directly — held here so
|
|
// that block doesn't need its own second construction.
|
|
let journeysRepoDirect: Awaited<ReturnType<typeof createTestJourneysRepo>>;
|
|
let contributorsRepoDirect: Awaited<ReturnType<typeof createTestJourneyContributorsRepo>>;
|
|
let journeyTripsRepoDirect: Awaited<ReturnType<typeof createTestJourneyTripsRepo>>;
|
|
let entriesRepoDirect: Awaited<ReturnType<typeof createTestJourneyEntriesRepo>>;
|
|
let tripsRepoDirect: Awaited<ReturnType<typeof createTestTripsRepo>>;
|
|
// Plan 3g Task 2's own additions — the two repositories Task 1 left as
|
|
// empty stubs, plus `PlacesRepository` (JG44's `findRaw`), held here for
|
|
// the same reason as the five above.
|
|
let photosRepoDirect: Awaited<ReturnType<typeof createTestJourneyPhotosRepo>>;
|
|
let entryPhotosRepoDirect: Awaited<ReturnType<typeof createTestJourneyEntryPhotosRepo>>;
|
|
let placesRepoDirect: Awaited<ReturnType<typeof createTestPlacesRepo>>;
|
|
|
|
// Plan 3g Task 1 (Part A, R9's construction/wrapping pattern): the SAME
|
|
// direct-construction shape every converted service in this program uses for
|
|
// its own hand-built unit test (`atlas.service.test.ts`/`vacay.service.test.ts`
|
|
// precedent) — real repositories resolved off `sharedTestOrm(testDb)`
|
|
// (`allowGlobalContext: true` by default, `test-orm.ts`'s own docstring), no
|
|
// `withRequestContext` wrapper needed or added. `db` (the raw `DatabaseService`)
|
|
// stays a constructor param and is still exercised directly: Part B's methods
|
|
// (journey stats, entries CRUD, the photos surface, contributors CRUD,
|
|
// suggestions — Task 2's own, unconverted by this task) still issue raw
|
|
// raw `this.db` statements the ~40 `describe` blocks below covering them
|
|
// exercise unchanged. Every test in this file calls `svc.<method>()` directly
|
|
// with no wrapper — this single `beforeAll` construction is the whole of
|
|
// R9's "fix the constructor call for the entire file in one pass" edit; nothing
|
|
// below this block changes.
|
|
beforeAll(async () => {
|
|
const t = await sharedTestOrm(testDb);
|
|
journeysRepoDirect = await createTestJourneysRepo(testDb);
|
|
contributorsRepoDirect = await createTestJourneyContributorsRepo(testDb);
|
|
journeyTripsRepoDirect = await createTestJourneyTripsRepo(testDb);
|
|
entriesRepoDirect = await createTestJourneyEntriesRepo(testDb);
|
|
tripsRepoDirect = await createTestTripsRepo(testDb);
|
|
photosRepoDirect = await createTestJourneyPhotosRepo(testDb);
|
|
entryPhotosRepoDirect = await createTestJourneyEntryPhotosRepo(testDb);
|
|
placesRepoDirect = await createTestPlacesRepo(testDb);
|
|
svc = new JourneyDomainService(
|
|
new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), photosRepoDirect), await createTestUnitOfWork(testDb),
|
|
journeysRepoDirect, contributorsRepoDirect, journeyTripsRepoDirect, entriesRepoDirect, tripsRepoDirect,
|
|
// Plan 3g Task 2's own append to this SAME construction call — the two
|
|
// repositories this task builds, plus `PlacesRepository` (JG44) — per
|
|
// R9, Task 2 only appends to what Task 1's construction already
|
|
// established, never re-touches the wrapping mechanism itself.
|
|
photosRepoDirect, entryPhotosRepoDirect, placesRepoDirect,
|
|
);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// -- Access control -----------------------------------------------------------
|
|
|
|
describe('canAccessJourney', () => {
|
|
it('JOURNEY-SVC-001: returns journey for owner', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'My Journey' });
|
|
|
|
const result = await svc.canAccessJourney(journey.id, user.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.id).toBe(journey.id);
|
|
expect(result!.title).toBe('My Journey');
|
|
});
|
|
|
|
it('JOURNEY-SVC-002: returns journey for contributor', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'editor');
|
|
|
|
const result = await svc.canAccessJourney(journey.id, contrib.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.id).toBe(journey.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-003: returns null for stranger', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = await svc.canAccessJourney(journey.id, stranger.id);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isOwner', () => {
|
|
it('JOURNEY-SVC-004: returns true for owner', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
expect(await svc.isOwner(journey.id, user.id)).toBe(true);
|
|
});
|
|
|
|
it('JOURNEY-SVC-005: returns false for contributor', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'editor');
|
|
|
|
expect(await svc.isOwner(journey.id, contrib.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-006: returns false for stranger', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
expect(await svc.isOwner(journey.id, stranger.id)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('canEdit', () => {
|
|
it('JOURNEY-SVC-007: owner can edit', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
expect(await svc.canEdit(journey.id, user.id)).toBe(true);
|
|
});
|
|
|
|
it('JOURNEY-SVC-008: editor contributor can edit', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
expect(await svc.canEdit(journey.id, editor.id)).toBe(true);
|
|
});
|
|
|
|
it('JOURNEY-SVC-009: viewer contributor cannot edit', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
|
|
expect(await svc.canEdit(journey.id, viewer.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-010: stranger cannot edit', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
expect(await svc.canEdit(journey.id, stranger.id)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// -- Journey CRUD -------------------------------------------------------------
|
|
|
|
describe('listJourneys', () => {
|
|
it('JOURNEY-SVC-011: returns owned journeys with counts', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Road Trip' });
|
|
createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', location_name: 'Paris' });
|
|
createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02', location_name: 'Lyon' });
|
|
|
|
const result = await svc.listJourneys(user.id);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].title).toBe('Road Trip');
|
|
expect(result[0].entry_count).toBe(2);
|
|
expect(result[0].place_count).toBe(2);
|
|
});
|
|
|
|
it('JOURNEY-SVC-012: includes journeys where user is contributor', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id, { title: 'Shared Trip' });
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'editor');
|
|
|
|
const result = await svc.listJourneys(contrib.id);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].title).toBe('Shared Trip');
|
|
});
|
|
|
|
it('JOURNEY-SVC-013: does not include other users journeys', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
createJourney(testDb, owner.id, { title: 'Private' });
|
|
|
|
const result = await svc.listJourneys(other.id);
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Multi Trip' });
|
|
const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' });
|
|
const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' });
|
|
await svc.addTripToJourney(journey.id, trip1.id, user.id);
|
|
await svc.addTripToJourney(journey.id, trip2.id, user.id);
|
|
|
|
const result = await svc.listJourneys(user.id);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].trip_date_min).toBe('2025-06-01');
|
|
expect(result[0].trip_date_max).toBe('2026-03-20');
|
|
});
|
|
});
|
|
|
|
describe('createJourney (service)', () => {
|
|
it('JOURNEY-SVC-014: creates journey with contributor record', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const journey = await svc.createJourney(user.id, { title: 'New Journey', subtitle: 'Subtitle' });
|
|
|
|
expect(journey.title).toBe('New Journey');
|
|
expect(journey.subtitle).toBe('Subtitle');
|
|
expect(journey.user_id).toBe(user.id);
|
|
expect(journey.status).toBe('active');
|
|
|
|
// owner should be added as contributor
|
|
const contrib = await one(JourneyContributors, { journey: journey.id, user: user.id }) as { role: string } | undefined;
|
|
expect(contrib).toBeDefined();
|
|
expect(contrib!.role).toBe('owner');
|
|
});
|
|
|
|
it('JOURNEY-SVC-015: links trips when trip_ids provided', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Paris 2026' });
|
|
|
|
const journey = await svc.createJourney(user.id, { title: 'Euro Trip', trip_ids: [trip.id] });
|
|
|
|
const link = await one(JourneyTrips, { journey: journey.id, trip: trip.id });
|
|
expect(link).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('getJourneyFull', () => {
|
|
it('JOURNEY-SVC-016: returns full journey with entries, trips, contributors', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Full Journey' });
|
|
createJourneyEntry(testDb, journey.id, user.id, {
|
|
title: 'Day 1',
|
|
entry_date: '2026-03-01',
|
|
story: 'Arrived!',
|
|
});
|
|
|
|
const result = await svc.getJourneyFull(journey.id, user.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.title).toBe('Full Journey');
|
|
expect(result!.entries).toHaveLength(1);
|
|
expect(result!.entries[0].title).toBe('Day 1');
|
|
expect(result!.contributors).toHaveLength(1);
|
|
expect(result!.stats.entries).toBe(1);
|
|
});
|
|
|
|
it('JOURNEY-SVC-017: returns null for unauthorized user', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = await svc.getJourneyFull(journey.id, stranger.id);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('placeEntriesFromPhotos (#1003)', () => {
|
|
/** A geotagged (or not) photo on an entry, wired the way the upload wires it. */
|
|
async function photoOnEntry(journeyId: number, entryId: number, ownerId: number, coords: [number, number] | null, sort = 0) {
|
|
const tp = await insertRow(await orm(), TrekPhotos, { provider: 'local', owner: ownerId, file_path: 'journey/x.jpg', lat: coords?.[0] ?? null, lng: coords?.[1] ?? null });
|
|
const gp = await insertRow(await orm(), JourneyPhotos, { journey: journeyId, photo: tp, shared: 1, sort_order: 0, created_at: Date.now() });
|
|
await insertRow(await orm(), JourneyEntryPhotos, { entry: entryId, journeyPhoto: gp, sort_order: sort, created_at: Date.now() });
|
|
return tp;
|
|
}
|
|
|
|
it('JOURNEY-SVC-1003-1: with the setting on, a placeless entry takes the position of its first geotagged photo, once', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
await updateRows(await orm(), Journeys, { id: journey.id }, { photo_location: 1 });
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
|
const blank = await photoOnEntry(journey.id, entry.id, user.id, null, 0);
|
|
const first = await photoOnEntry(journey.id, entry.id, user.id, [48.137, 11.575], 1);
|
|
const second = await photoOnEntry(journey.id, entry.id, user.id, [40.4, -3.7], 2);
|
|
|
|
expect(await svc.placeEntriesFromPhotos([blank, first, second])).toEqual([{ entryId: entry.id, journeyId: journey.id, lat: 48.137, lng: 11.575 }]);
|
|
const row = await pickOne(JourneyEntries, { id: entry.id }, ['location_lat', 'location_lng', 'country_code']) as any;
|
|
expect(row).toMatchObject({ location_lat: 48.137, location_lng: 11.575 });
|
|
|
|
// Placed now: a later photo does not move it, and a name only lands while there is none.
|
|
expect(await svc.placeEntriesFromPhotos([second])).toEqual([]);
|
|
await svc.nameEntryLocation(entry.id, 'Marienplatz');
|
|
await svc.nameEntryLocation(entry.id, 'Somewhere else');
|
|
expect((await pickOne(JourneyEntries, { id: entry.id }, ['location_name']) as any).location_name).toBe('Marienplatz');
|
|
});
|
|
|
|
it('JOURNEY-SVC-1003-2: off by default, and it leaves entries with a place alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
|
const photo = await photoOnEntry(journey.id, entry.id, user.id, [1, 2]);
|
|
expect(await svc.placeEntriesFromPhotos([photo])).toEqual([]);
|
|
|
|
await updateRows(await orm(), Journeys, { id: journey.id }, { photo_location: 1 });
|
|
await updateRows(await orm(), JourneyEntries, { id: entry.id }, { location_lat: 5, location_lng: 6 });
|
|
expect(await svc.placeEntriesFromPhotos([photo])).toEqual([]);
|
|
expect(await svc.placeEntriesFromPhotos([])).toEqual([]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-1003-3: the owner turns it on through updateJourney', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
expect(((await svc.updateJourney(journey.id, user.id, { photo_location: true })) as any).photo_location).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('updateJourney', () => {
|
|
it('JOURNEY-SVC-018: owner can update title and subtitle', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Old Title' });
|
|
|
|
const updated = await svc.updateJourney(journey.id, user.id, { title: 'New Title', subtitle: 'New Sub' });
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.title).toBe('New Title');
|
|
expect(updated!.subtitle).toBe('New Sub');
|
|
});
|
|
|
|
it('JOURNEY-SVC-019: editor contributor cannot update journey settings (#732)', async () => {
|
|
// Post-#732: journey-level settings (title/cover/status) are owner-only.
|
|
// Editors keep access to entries and photos, but not the journey shell.
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id, { title: 'Original' });
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
const updated = await svc.updateJourney(journey.id, editor.id, { title: 'Edited' });
|
|
|
|
expect(updated).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-020: viewer cannot update', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
|
|
const result = await svc.updateJourney(journey.id, viewer.id, { title: 'Hacked' });
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-021: returns journey unchanged when no valid fields provided', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Same' });
|
|
|
|
const result = await svc.updateJourney(journey.id, user.id, {});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.title).toBe('Same');
|
|
});
|
|
|
|
it('JOURNEY-SVC-762: the owner sets the shown state by hand, null hands it back, anything else is ignored', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
expect((await svc.updateJourney(journey.id, user.id, { status_override: 'live' }))!.status_override).toBe('live');
|
|
expect((await svc.updateJourney(journey.id, user.id, { status_override: 'upcoming' }))!.status_override).toBe('live');
|
|
expect((await svc.updateJourney(journey.id, user.id, { status_override: null }))!.status_override).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-021b: accepts archived status', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'To Archive' });
|
|
|
|
const result = await svc.updateJourney(journey.id, user.id, { status: 'archived' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.status).toBe('archived');
|
|
});
|
|
|
|
it('JOURNEY-SVC-021c: ignores invalid status value', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Stay Active' });
|
|
|
|
const result = await svc.updateJourney(journey.id, user.id, { status: 'bogus' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.status).toBe('active');
|
|
});
|
|
|
|
it('JOURNEY-SVC-021d: trip GPX tracks are off until the owner switches them on (#2194)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Norway' });
|
|
|
|
// #1260 drew the linked trips' tracks unconditionally; they are opt-in now.
|
|
expect((await svc.updateJourney(journey.id, user.id, {}))!.show_trip_tracks).toBe(0);
|
|
|
|
// A JS boolean has to survive the trip into an INTEGER column —
|
|
// better-sqlite3 refuses to bind one, so this would throw uncoerced.
|
|
expect((await svc.updateJourney(journey.id, user.id, { show_trip_tracks: true }))!.show_trip_tracks).toBe(1);
|
|
expect((await svc.updateJourney(journey.id, user.id, { show_trip_tracks: false }))!.show_trip_tracks).toBe(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-021e: an editor cannot switch the trip tracks on (#2194)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb, { username: 'tracks-editor' });
|
|
const journey = createJourney(testDb, user.id, { title: 'Norway' });
|
|
await insertRow(await orm(), JourneyContributors, { journey: journey.id, user: editor.id, role: 'editor', added_at: 0 });
|
|
|
|
// Journey-level settings stay owner-only, same as title and status.
|
|
expect(await svc.updateJourney(journey.id, editor.id, { show_trip_tracks: true })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('deleteJourney', () => {
|
|
it('JOURNEY-SVC-022: owner can delete', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const result = await svc.deleteJourney(journey.id, user.id);
|
|
|
|
expect(result).toBe(true);
|
|
const row = await one(Journeys, { id: journey.id });
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-023: non-owner cannot delete', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
const result = await svc.deleteJourney(journey.id, editor.id);
|
|
|
|
expect(result).toBe(false);
|
|
const row = await one(Journeys, { id: journey.id });
|
|
expect(row).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// -- Trip management ----------------------------------------------------------
|
|
|
|
describe('addTripToJourney / removeTripFromJourney', () => {
|
|
it('JOURNEY-SVC-024: links a trip to a journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Linked Trip' });
|
|
|
|
const result = await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
expect(result).toBe(true);
|
|
const link = await one(JourneyTrips, { journey: journey.id, trip: trip.id });
|
|
expect(link).toBeDefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-024b: refuses to link a trip the caller cannot access (IDOR guard)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
// A trip owned by someone else, that `user` is not a member of.
|
|
const foreignTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip" });
|
|
|
|
const result = await svc.addTripToJourney(journey.id, foreignTrip.id, user.id);
|
|
|
|
expect(result).toBe(false);
|
|
const link = await one(JourneyTrips, { journey: journey.id, trip: foreignTrip.id });
|
|
expect(link).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-025: syncs places as skeleton entries when linking a trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Trip with Places',
|
|
start_date: '2026-03-01',
|
|
end_date: '2026-03-03',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
|
const day025 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day025.id, place.id);
|
|
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const skeletons = await findRows(await orm(), JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' });
|
|
expect(skeletons.length).toBe(1);
|
|
});
|
|
|
|
it('JOURNEY-SVC-026: owner can remove a trip from journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Remove Me' });
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const result = await svc.removeTripFromJourney(journey.id, trip.id, user.id);
|
|
|
|
expect(result).toBe(true);
|
|
const link = await one(JourneyTrips, { journey: journey.id, trip: trip.id });
|
|
expect(link).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-027: non-owner cannot remove a trip', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
const trip = createTrip(testDb, owner.id, { title: 'Stay Linked' });
|
|
await svc.addTripToJourney(journey.id, trip.id, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
const result = await svc.removeTripFromJourney(journey.id, trip.id, editor.id);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
// -- Entries ------------------------------------------------------------------
|
|
|
|
describe('listEntries', () => {
|
|
it('JOURNEY-SVC-028: returns entries with photos for authorized user', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
|
title: 'Morning Walk',
|
|
entry_date: '2026-03-01',
|
|
});
|
|
|
|
const result = await svc.listEntries(journey.id, user.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result).toHaveLength(1);
|
|
expect(result![0].title).toBe('Morning Walk');
|
|
expect(result![0].photos).toEqual([]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-029: returns null for unauthorized user', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = await svc.listEntries(journey.id, stranger.id);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
/*
|
|
* The stop switch reads back as a boolean on every path (discussion #2064).
|
|
* The column holds 0 or 1; a client handed the integer on one read and the
|
|
* boolean on another could compare against neither.
|
|
*/
|
|
it('answers stats_excluded as a boolean, on the list and on the full journey alike', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const off = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', stats_excluded: 1 });
|
|
const on = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02' });
|
|
|
|
const listed = (await svc.listEntries(journey.id, user.id))!;
|
|
expect(listed.find(e => e.id === off.id)!.stats_excluded).toBe(true);
|
|
expect(listed.find(e => e.id === on.id)!.stats_excluded).toBe(false);
|
|
|
|
const full = (await svc.getJourneyFull(journey.id, user.id))!;
|
|
expect(full.entries.map(e => e.stats_excluded)).toEqual([true, false]);
|
|
});
|
|
});
|
|
|
|
describe('createEntry', () => {
|
|
it('JOURNEY-SVC-030: creates entry for editor', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const entry = await svc.createEntry(journey.id, user.id, {
|
|
title: 'Beach Day',
|
|
entry_date: '2026-03-10',
|
|
story: 'Beautiful sunset',
|
|
mood: 'happy',
|
|
weather: 'sunny',
|
|
tags: ['beach', 'sunset'],
|
|
});
|
|
|
|
expect(entry).not.toBeNull();
|
|
expect(entry!.title).toBe('Beach Day');
|
|
expect(entry!.story).toBe('Beautiful sunset');
|
|
expect(entry!.mood).toBe('happy');
|
|
expect(entry!.type).toBe('entry');
|
|
expect(entry!.author_id).toBe(user.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-030b: entries added to one day at the same time take one position each', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
// Without the transaction every one read the same MAX for the date and landed on 0.
|
|
const entries = await Promise.all(['A', 'B', 'C'].map((title) => svc.createEntry(journey.id, user.id, { title, entry_date: '2026-03-10' })));
|
|
expect(entries.map((e) => e!.sort_order).sort((x, y) => x - y)).toEqual([0, 1, 2]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-031: viewer cannot create entry', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
|
|
const entry = await svc.createEntry(journey.id, viewer.id, {
|
|
title: 'Should Fail',
|
|
entry_date: '2026-03-10',
|
|
});
|
|
|
|
expect(entry).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('updateEntry', () => {
|
|
it('JOURNEY-SVC-032: updates entry fields', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
|
title: 'Old',
|
|
entry_date: '2026-03-01',
|
|
});
|
|
|
|
const updated = await svc.updateEntry(entry.id, user.id, { title: 'Updated', mood: 'excited' });
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.title).toBe('Updated');
|
|
expect(updated!.mood).toBe('excited');
|
|
});
|
|
|
|
it('JOURNEY-SVC-033: promotes skeleton to entry when story is added', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
|
type: 'skeleton',
|
|
title: 'Placeholder',
|
|
entry_date: '2026-03-01',
|
|
});
|
|
|
|
const updated = await svc.updateEntry(entry.id, user.id, { story: 'Now I have a story!' });
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.type).toBe('entry');
|
|
expect(updated!.story).toBe('Now I have a story!');
|
|
});
|
|
|
|
/*
|
|
* The JSON columns come back decoded, on every path (#2085 follow-up).
|
|
*
|
|
* `tags` and `pros_cons` are JSON in TEXT columns. The read paths decoded
|
|
* them and create/update did not, so an edit answered with `tags` as a
|
|
* string, the client spread it into an entry it had typed as `string[]`, and
|
|
* the journey page threw `tags.map is not a function`.
|
|
*/
|
|
it('answers with tags decoded, not as the JSON string the column holds', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = await svc.createEntry(journey.id, user.id, {
|
|
title: 'Beach Day', entry_date: '2026-03-10', tags: ['beach', 'sunset'],
|
|
});
|
|
|
|
expect(entry!.tags).toEqual(['beach', 'sunset']);
|
|
|
|
const updated = await svc.updateEntry(entry!.id, user.id, { title: 'Renamed' });
|
|
expect(updated!.tags).toEqual(['beach', 'sunset']);
|
|
});
|
|
|
|
it('decodes an emptied tag list rather than answering the string "[]"', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
// The update path writes JSON.stringify([]) with no length check, so this
|
|
// is the ordinary way an entry ends up with a non-null tags column.
|
|
const updated = await svc.updateEntry(entry.id, user.id, { tags: [] });
|
|
expect(updated!.tags).toEqual([]);
|
|
});
|
|
|
|
it('decodes pros_cons the same way, on create and on update', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const prosCons = { pros: ['warm'], cons: ['crowded'] };
|
|
const entry = await svc.createEntry(journey.id, user.id, {
|
|
title: 'Verdict', entry_date: '2026-03-10', pros_cons: prosCons,
|
|
});
|
|
|
|
expect(entry!.pros_cons).toEqual(prosCons);
|
|
expect((await svc.updateEntry(entry!.id, user.id, { title: 'Renamed' }))!.pros_cons).toEqual(prosCons);
|
|
});
|
|
|
|
// L3 (task-5-review.md, user decision: KEEP HEAD's behaviour, deliberate
|
|
// deviation FOR THE USER) — base stored the raw non-array `tags`/non-object
|
|
// `pros_cons` value as-is and then 500ed on every later read that tried to
|
|
// JSON.parse it, leaving the entry permanently unreadable; base also 500s
|
|
// outright on a `false` bind. HEAD writes SQL NULL instead and answers 200,
|
|
// which is the narrowing that fixes that legacy defect. Pinned here so a
|
|
// future change to this branch is a deliberate decision, not an accident.
|
|
it('L3: a non-array tags / non-object pros_cons patch writes NULL and returns 200, rather than the raw value HEAD used to store (a legacy defect, not reproduced)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const updated = await svc.updateEntry(entry.id, user.id, {
|
|
tags: 'not-an-array' as unknown as string[],
|
|
pros_cons: false as unknown as { pros: string[]; cons: string[] },
|
|
});
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.tags).toEqual([]);
|
|
expect(updated!.pros_cons).toBeNull();
|
|
|
|
const row = await pickOne(JourneyEntries, { id: entry.id }, ['tags', 'pros_cons']) as { tags: string | null; pros_cons: string | null };
|
|
expect(row.tags).toBeNull();
|
|
expect(row.pros_cons).toBeNull();
|
|
|
|
// And the entry stays readable afterwards — no 500 on the next read,
|
|
// which is exactly the defect this deviation fixes.
|
|
expect(await svc.listEntries(journey.id, user.id)).not.toBeNull();
|
|
});
|
|
|
|
/* An update that changes nothing takes its own early return out of the method. */
|
|
it('decodes on the no-op update too', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = await svc.createEntry(journey.id, user.id, {
|
|
title: 'Beach Day', entry_date: '2026-03-10', tags: ['beach'],
|
|
});
|
|
|
|
expect((await svc.updateEntry(entry!.id, user.id, {}))!.tags).toEqual(['beach']);
|
|
});
|
|
|
|
/*
|
|
* The broadcast carries the same shape as the answer. Nothing reads it today
|
|
* — the journey page reloads on any event — so this is about not leaving a
|
|
* payload that contradicts every read path for a listener to trust later.
|
|
*/
|
|
it('broadcasts the decoded entry, not the row', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = await svc.createEntry(journey.id, user.id, {
|
|
title: 'Beach Day', entry_date: '2026-03-10', tags: ['beach', 'sunset'],
|
|
});
|
|
|
|
const spy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {});
|
|
try {
|
|
await svc.updateEntry(entry!.id, user.id, { title: 'Renamed' });
|
|
const payload = spy.mock.calls.at(-1)?.[1] as { type: string; entry: { tags: unknown } };
|
|
expect(payload.type).toBe('journey:entry:updated');
|
|
expect(payload.entry.tags).toEqual(['beach', 'sunset']);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
/*
|
|
* The stop switch (discussion #2064). The column is an INTEGER, the wire is
|
|
* a boolean and the client compares with `=== true`, so the answer has to
|
|
* be the boolean on the way out as well as the integer on the way in, and
|
|
* the broadcast has to say the same thing the answer does.
|
|
*/
|
|
it('creates and toggles a draft, answering the flag as a boolean (#696)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const created = await svc.createEntry(journey.id, user.id, { entry_date: '2026-03-01', title: 'Rough', is_draft: true });
|
|
expect(created!.is_draft).toBe(true);
|
|
const published = await svc.updateEntry(created!.id, user.id, { is_draft: false });
|
|
expect(published!.is_draft).toBe(false);
|
|
expect((await pickOne(JourneyEntries, { id: created!.id }, ['is_draft']) as { is_draft: number }).is_draft).toBe(0);
|
|
});
|
|
|
|
it('switches a stop off and back on, and answers with the flag as a boolean', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const flagOf = async (id: number) =>
|
|
(await pickOne(JourneyEntries, { id: id }, ['stats_excluded']) as { stats_excluded: number }).stats_excluded;
|
|
|
|
const spy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {});
|
|
try {
|
|
const off = await svc.updateEntry(entry.id, user.id, { stats_excluded: true });
|
|
expect(off!.stats_excluded).toBe(true);
|
|
expect(await flagOf(entry.id)).toBe(1);
|
|
const payload = spy.mock.calls.at(-1)?.[1] as { type: string; entry: { stats_excluded: unknown } };
|
|
expect(payload.entry.stats_excluded).toBe(true);
|
|
|
|
const on = await svc.updateEntry(entry.id, user.id, { stats_excluded: false });
|
|
expect(on!.stats_excluded).toBe(false);
|
|
expect(await flagOf(entry.id)).toBe(0);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('a viewer cannot switch a stop off', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
|
|
expect(await svc.updateEntry(entry.id, viewer.id, { stats_excluded: true })).toBeNull();
|
|
expect((await svc.listEntries(journey.id, owner.id))![0].stats_excluded).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-034: returns null for non-existent entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const result = await svc.updateEntry(99999, user.id, { title: 'No Such Entry' });
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-034b: ignores injection column keys and mass-assignment attempts', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
|
title: 'Safe',
|
|
story: 'original',
|
|
entry_date: '2026-03-01',
|
|
});
|
|
|
|
// The keys come straight from the request body. A crafted key was previously
|
|
// interpolated as a raw SQL column name (`${key} = ?`), enabling subquery
|
|
// injection (full DB read) and mass-assignment of protected columns.
|
|
const malicious: Record<string, unknown> = {
|
|
title: 'Updated',
|
|
[`story = (SELECT password_hash FROM users WHERE id = ${user.id}), updated_at`]: 'x',
|
|
author_id: 999999,
|
|
};
|
|
|
|
const updated = await svc.updateEntry(entry.id, user.id, malicious as Parameters<typeof svc.updateEntry>[2]);
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.title).toBe('Updated'); // legit field still applied
|
|
expect(updated!.story).toBe('original'); // injection key dropped — no hash leaked into story
|
|
expect(updated!.author_id).toBe(user.id); // mass-assignment blocked
|
|
});
|
|
|
|
// M3 (task-5-review.md) — a patch carrying ONLY non-allow-listed keys (no
|
|
// legit field at all, unlike JOURNEY-SVC-034b above which always mixes in
|
|
// a real `title`) used to be treated as "something was provided" because
|
|
// the old no-op check read `Object.values(data).some(v => v !== undefined)`
|
|
// off the RAW request body, not off the allow-listed `patch` presenceSet
|
|
// actually builds. Base: no write, no `updated_at` bump on either row, no
|
|
// broadcast. HEAD (before this fix): stamped both `updated_at`s and
|
|
// broadcast anyway.
|
|
it('JOURNEY-SVC-M3-01: a patch with only non-allow-listed keys is a no-op — no write, no updated_at bump, no broadcast', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { title: 'Original', entry_date: '2026-03-01' });
|
|
|
|
const updateFieldsSpy = vi.spyOn(entriesRepoDirect, 'updateFields');
|
|
const journeyUpdateSpy = vi.spyOn(journeysRepoDirect, 'updateFields');
|
|
const broadcastSpy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {});
|
|
try {
|
|
const updated = await svc.updateEntry(entry.id, user.id, { foo: 1 } as unknown as Parameters<typeof svc.updateEntry>[2]);
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.title).toBe('Original');
|
|
expect(updateFieldsSpy).not.toHaveBeenCalled();
|
|
expect(journeyUpdateSpy).not.toHaveBeenCalled();
|
|
expect(broadcastSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
updateFieldsSpy.mockRestore();
|
|
journeyUpdateSpy.mockRestore();
|
|
broadcastSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('deleteEntry', () => {
|
|
it('JOURNEY-SVC-035: deletes entry for editor', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const result = await svc.deleteEntry(entry.id, user.id);
|
|
|
|
expect(result).toBe(true);
|
|
const row = await one(JourneyEntries, { id: entry.id });
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-036: returns false for non-existent entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
expect(await svc.deleteEntry(99999, user.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-037: viewer cannot delete entry', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
|
|
expect(await svc.deleteEntry(entry.id, viewer.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower' });
|
|
|
|
// Create a filled entry that originated from a trip skeleton
|
|
const now = Date.now();
|
|
await insertRow(await orm(), JourneyEntries, { journey: journey.id, sourceTrip: trip.id, sourcePlace: place.id, author: user.id, type: 'entry', title: 'Tokyo Tower', story: 'Amazing view!', mood: 'amazing', entry_date: '2026-03-01', location_name: 'Tokyo', visibility: 'private', sort_order: 0, created_at: now, updated_at: now });
|
|
const entry = await one(JourneyEntries, { journey: journey.id, sourcePlace: place.id }) as any;
|
|
|
|
const result = await svc.deleteEntry(entry.id, user.id);
|
|
expect(result).toBe(true);
|
|
|
|
// Entry should still exist but reverted to skeleton
|
|
const reverted = await one(JourneyEntries, { id: entry.id }) as any;
|
|
expect(reverted).toBeDefined();
|
|
expect(reverted.type).toBe('skeleton');
|
|
expect(reverted.story).toBeNull();
|
|
expect(reverted.mood).toBeNull();
|
|
expect(reverted.source_trip_id).toBe(trip.id);
|
|
expect(reverted.source_place_id).toBe(place.id);
|
|
expect(reverted.title).toBe('Tokyo Tower');
|
|
});
|
|
|
|
it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' });
|
|
|
|
const result = await svc.deleteEntry(entry.id, user.id);
|
|
expect(result).toBe(true);
|
|
|
|
const row = await one(JourneyEntries, { id: entry.id });
|
|
expect(row).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// -- Photos -------------------------------------------------------------------
|
|
|
|
describe('addPhoto / addProviderPhoto / deletePhoto', () => {
|
|
it('JOURNEY-SVC-038: addPhoto creates a local photo on an entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const photo = await svc.addPhoto(entry.id, user.id, '/uploads/photo.jpg', '/uploads/thumb.jpg', 'Sunset');
|
|
|
|
expect(photo).not.toBeNull();
|
|
expect(photo!.file_path).toBe('/uploads/photo.jpg');
|
|
expect(photo!.thumbnail_path).toBe('/uploads/thumb.jpg');
|
|
expect(photo!.caption).toBe('Sunset');
|
|
expect(photo!.provider).toBe('local');
|
|
});
|
|
|
|
it('JOURNEY-SVC-039: addPhoto returns null for non-existent entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const result = await svc.addPhoto(99999, user.id, '/uploads/photo.jpg');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-040: addProviderPhoto creates a provider-backed photo', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const photo = await svc.addProviderPhoto(entry.id, user.id, 'immich', 'asset-123', 'My caption');
|
|
|
|
expect(photo).not.toBeNull();
|
|
expect(photo!.provider).toBe('immich');
|
|
expect(photo!.asset_id).toBe('asset-123');
|
|
expect(photo!.caption).toBe('My caption');
|
|
});
|
|
|
|
it('JOURNEY-SVC-041: addProviderPhoto skips duplicate asset', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
await svc.addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset');
|
|
const duplicate = await svc.addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset');
|
|
|
|
expect(duplicate).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-042: deletePhoto removes photo and returns it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, user.id, '/uploads/delete-me.jpg');
|
|
|
|
const deleted = await svc.deletePhoto(photo!.id, user.id);
|
|
|
|
expect(deleted).not.toBeNull();
|
|
expect(deleted!.id).toBe(photo!.id);
|
|
const row = await one(JourneyPhotos, { id: photo!.id });
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-043: deletePhoto returns null for non-existent photo', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
expect(await svc.deletePhoto(99999, user.id)).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-044: viewer cannot add photo', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
|
|
const result = await svc.addPhoto(entry.id, viewer.id, '/uploads/no.jpg');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- Contributors -------------------------------------------------------------
|
|
|
|
describe('addContributor / updateContributorRole / removeContributor', () => {
|
|
it('JOURNEY-SVC-045: owner can add contributor', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: newContrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = await svc.addContributor(journey.id, owner.id, newContrib.id, 'editor');
|
|
|
|
expect(result).toBe(true);
|
|
const row = await one(JourneyContributors, { journey: journey.id, user: newContrib.id }) as { role: string } | undefined;
|
|
expect(row).toBeDefined();
|
|
expect(row!.role).toBe('editor');
|
|
});
|
|
|
|
it('JOURNEY-SVC-046: non-owner cannot add contributor', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const { user: newUser } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
const result = await svc.addContributor(journey.id, editor.id, newUser.id, 'viewer');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-047: owner cannot add themselves as contributor', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = await svc.addContributor(journey.id, owner.id, owner.id, 'editor');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-048: owner can update contributor role', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'viewer');
|
|
|
|
const result = await svc.updateContributorRole(journey.id, owner.id, contrib.id, 'editor');
|
|
|
|
expect(result).toBe(true);
|
|
const row = await pickOne(JourneyContributors, { journey: journey.id, user: contrib.id }, ['role']) as { role: string };
|
|
expect(row.role).toBe('editor');
|
|
});
|
|
|
|
it('JOURNEY-SVC-049: non-owner cannot update contributor role', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const { user: target } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
addJourneyContributor(testDb, journey.id, target.id, 'viewer');
|
|
|
|
const result = await svc.updateContributorRole(journey.id, editor.id, target.id, 'editor');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
// JG118 (task-5-review.md, user decision: pin, do NOT fix) — a PRE-EXISTING
|
|
// hole preserved on purpose (also present at legacy base): nothing here or
|
|
// in SQL stops the owner from writing `role = 'owner'` through this path —
|
|
// only the TypeScript parameter type discourages it, and that boundary is
|
|
// bypassable from a raw HTTP/MCP body. The hardening line (refuse unless
|
|
// `role === 'editor' || role === 'viewer'`) goes in the report FOR THE
|
|
// USER; this test pins today's behaviour so a future fix is a deliberate
|
|
// change, not a silent one.
|
|
it('JOURNEY-SVC-JG118-PIN: updateContributorRole still accepts role "owner" through the typed path (pre-existing, not fixed by this wave)', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: target } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, target.id, 'editor');
|
|
|
|
const result = await svc.updateContributorRole(journey.id, owner.id, target.id, 'owner' as unknown as 'editor' | 'viewer');
|
|
|
|
expect(result).toBe(true);
|
|
const row = await pickOne(JourneyContributors, { journey: journey.id, user: target.id }, ['role']) as { role: string };
|
|
expect(row.role).toBe('owner');
|
|
});
|
|
|
|
it('JOURNEY-SVC-050: owner can remove contributor', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'editor');
|
|
|
|
const result = await svc.removeContributor(journey.id, owner.id, contrib.id);
|
|
|
|
expect(result).toBe(true);
|
|
const row = await one(JourneyContributors, { journey: journey.id, user: contrib.id });
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-051: removeContributor does not remove owner contributor record', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
// attempting to remove the owner's own contributor record should not work
|
|
// (the SQL filters role != 'owner')
|
|
await svc.removeContributor(journey.id, owner.id, owner.id);
|
|
|
|
const row = await one(JourneyContributors, { journey: journey.id, user: owner.id });
|
|
expect(row).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// M5c (task-5-review.md) — R3's reset-column trap on `journey_contributors`'s
|
|
// composite PK: the legacy `INSERT OR REPLACE` deletes and re-inserts on a
|
|
// conflict, so `hide_skeletons` (not in the column list `upsertContributor`
|
|
// writes on purpose) resets to its table default (0) — verified live by W2's
|
|
// probe, but until now pinned only as report text (the ledger's "upsert SQL
|
|
// pinned = the reference" claim did not hold). Mutation M10 (drop the
|
|
// explicit `hide_skeletons: 0`) stayed green with no test to catch it.
|
|
describe('R3 composite-PK writes — reset-on-conflict behaviour and rendered SQL (M5c)', () => {
|
|
it('JOURNEY-SVC-M5C-01: re-adding a contributor (upsertContributor merge branch) resets hide_skeletons to 0', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
await svc.addContributor(journey.id, owner.id, contrib.id, 'editor');
|
|
await svc.updateJourneyPreferences(journey.id, contrib.id, { hide_skeletons: true });
|
|
expect(
|
|
(await pickOne(JourneyContributors, { journey: journey.id, user: contrib.id }, ['hide_skeletons']) as { hide_skeletons: number }).hide_skeletons,
|
|
).toBe(1);
|
|
|
|
// Re-add (same target, a possibly different role) — the ON CONFLICT
|
|
// merge branch of upsertContributor, exercised through the real service.
|
|
await svc.addContributor(journey.id, owner.id, contrib.id, 'viewer');
|
|
|
|
const row = await pickOne(JourneyContributors, { journey: journey.id, user: contrib.id }, ['role', 'hide_skeletons']) as { role: string; hide_skeletons: number };
|
|
expect(row.role).toBe('viewer');
|
|
expect(row.hide_skeletons).toBe(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-M5C-02: upsertContributor (merge-shaped) renders an ON CONFLICT over the (journey_id, user_id) composite PK', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const connection = contributorsRepoDirect.getEntityManager().getConnection();
|
|
const spy = vi.spyOn(connection, 'execute');
|
|
try {
|
|
await contributorsRepoDirect.upsertContributor(journey.id, contrib.id, 'editor', Date.now());
|
|
const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
|
|
expect(insertSql).toBeDefined();
|
|
expect(insertSql).toMatch(/on conflict \(`journey_id`, ?`user_id`\)/i);
|
|
expect(insertSql).toMatch(/do update set/i);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('JOURNEY-SVC-M5C-03: JourneyTripsRepository.insertIgnore (ignore-shaped) renders an ON CONFLICT ... DO NOTHING over (journey_id, trip_id)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const connection = journeyTripsRepoDirect.getEntityManager().getConnection();
|
|
const spy = vi.spyOn(connection, 'execute');
|
|
try {
|
|
await journeyTripsRepoDirect.insertIgnore(journey.id, trip.id, Date.now());
|
|
const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
|
|
expect(insertSql).toBeDefined();
|
|
expect(insertSql).toMatch(/on conflict \(`journey_id`, ?`trip_id`\)/i);
|
|
expect(insertSql).toMatch(/do nothing/i);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('JOURNEY-SVC-M5C-04: JourneyEntryPhotosRepository.insertIgnore (ignore-shaped) renders an ON CONFLICT ... DO NOTHING over (entry_id, journey_photo_id)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
|
// The epoch-ms string lands as the integer the old statement bound: the column has numeric affinity.
|
|
const trekId = await insertRow(await orm(), TrekPhotos, { provider: 'local', file_path: '/photos/m5c.jpg', owner: user.id, created_at: String(Date.now()) });
|
|
const journeyPhotoId = await insertRow(await orm(), JourneyPhotos, { journey: journey.id, photo: trekId, sort_order: 0, created_at: Date.now() });
|
|
|
|
const connection = entryPhotosRepoDirect.getEntityManager().getConnection();
|
|
const spy = vi.spyOn(connection, 'execute');
|
|
try {
|
|
await entryPhotosRepoDirect.insertIgnore(entry.id, journeyPhotoId, 0, Date.now());
|
|
const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
|
|
expect(insertSql).toBeDefined();
|
|
expect(insertSql).toMatch(/on conflict \(`entry_id`, ?`journey_photo_id`\)/i);
|
|
expect(insertSql).toMatch(/do nothing/i);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
// M4 (task-5-review.md) — `JourneysRepository.listRecipientUserIds`'s
|
|
// `if (ownerId !== undefined) ids.add(ownerId)` null arm had no test where
|
|
// `findOwnerId` actually returns undefined (a missing journey), so the
|
|
// branch went uncovered.
|
|
describe('JourneysRepository.listRecipientUserIds — missing journey (M4)', () => {
|
|
it('returns an empty array for a journey id that does not exist (findOwnerId undefined arm)', async () => {
|
|
expect(await journeysRepoDirect.listRecipientUserIds(999_999)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// -- Suggestions --------------------------------------------------------------
|
|
|
|
describe('getSuggestions', () => {
|
|
it('JOURNEY-SVC-052: returns recently ended trips not yet in a journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
// Trip that ended 5 days ago (within 30-day window)
|
|
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
createTrip(testDb, user.id, {
|
|
title: 'Recent Trip',
|
|
start_date: tenDaysAgo,
|
|
end_date: fiveDaysAgo,
|
|
});
|
|
|
|
const suggestions = await svc.getSuggestions(user.id);
|
|
|
|
expect(suggestions.length).toBe(1);
|
|
expect((suggestions[0] as any).title).toBe('Recent Trip');
|
|
});
|
|
|
|
it('JOURNEY-SVC-053: excludes trips already linked to a journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Already Linked',
|
|
start_date: tenDaysAgo,
|
|
end_date: fiveDaysAgo,
|
|
});
|
|
const journey = createJourney(testDb, user.id);
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const suggestions = await svc.getSuggestions(user.id);
|
|
|
|
expect(suggestions.length).toBe(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-054: excludes trips ending in the future', async () => {
|
|
const { user } = createUser(testDb);
|
|
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
createTrip(testDb, user.id, {
|
|
title: 'Future Trip',
|
|
start_date: '2026-04-01',
|
|
end_date: tomorrow,
|
|
});
|
|
|
|
const suggestions = await svc.getSuggestions(user.id);
|
|
|
|
expect(suggestions.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// -- syncTripPlaces ------------------------------------------------------------
|
|
|
|
describe('syncTripPlaces', () => {
|
|
it('JOURNEY-SVC-055: creates skeleton entries for each trip place', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Sync Trip',
|
|
start_date: '2026-05-01',
|
|
end_date: '2026-05-03',
|
|
});
|
|
const place1 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
|
const place2 = createPlace(testDb, trip.id, { name: 'Louvre' });
|
|
const days055 = (await pickAll(Days, { trip: trip.id }, ['id'], { date: 'asc' })).slice(0, 2) as { id: number }[];
|
|
createDayAssignment(testDb, days055[0].id, place1.id);
|
|
createDayAssignment(testDb, days055[1].id, place2.id);
|
|
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
const skeletons = await findRows(await orm(), JourneyEntries, { journey: journey.id, type: 'skeleton' }) as any[];
|
|
expect(skeletons.length).toBe(2);
|
|
const names = skeletons.map((s: any) => s.title).sort();
|
|
expect(names).toEqual(['Eiffel Tower', 'Louvre']);
|
|
});
|
|
|
|
it('JOURNEY-SVC-056: skips places that already have skeleton entries', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Idempotent Trip',
|
|
start_date: '2026-05-01',
|
|
end_date: '2026-05-02',
|
|
});
|
|
const place056 = createPlace(testDb, trip.id, { name: 'Notre Dame' });
|
|
const day056 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day056.id, place056.id);
|
|
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id); // second call
|
|
|
|
const skeletons = await findRows(await orm(), JourneyEntries, { journey: journey.id, type: 'skeleton' });
|
|
expect(skeletons.length).toBe(1);
|
|
});
|
|
|
|
it('JOURNEY-SVC-057: uses day date for skeleton entry_date when available', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
// Trip with dates auto-creates days; grab an existing day to assign the place
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Dated Trip',
|
|
start_date: '2026-06-10',
|
|
end_date: '2026-06-12',
|
|
});
|
|
const day = await one(Days, { trip: trip.id, date: '2026-06-11' }) as { id: number };
|
|
const place = createPlace(testDb, trip.id, { name: 'Colosseum' });
|
|
createDayAssignment(testDb, day.id, place.id);
|
|
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
const skeleton = await one(JourneyEntries, { journey: journey.id, sourcePlace: place.id }) as any;
|
|
expect(skeleton).toBeDefined();
|
|
expect(skeleton.entry_date).toBe('2026-06-11');
|
|
});
|
|
});
|
|
|
|
// -- onPlaceCreated / onPlaceUpdated / onPlaceDeleted -------------------------
|
|
|
|
describe('onPlaceCreated', () => {
|
|
it('JOURNEY-SVC-058: creates skeleton entry in linked journeys', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Webhook Trip',
|
|
start_date: '2026-07-01',
|
|
end_date: '2026-07-03',
|
|
});
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Create a new place after trip is linked
|
|
const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' });
|
|
const day058 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day058.id, place.id);
|
|
await svc.onPlaceCreated(trip.id, place.id);
|
|
|
|
const skeleton = await one(JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' });
|
|
expect(skeleton).toBeDefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-059: does nothing if trip is not linked to any journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Unlinked Trip' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Remote Place' });
|
|
|
|
await svc.onPlaceCreated(trip.id, place.id);
|
|
|
|
const entries = await findRows(await orm(), JourneyEntries, { sourcePlace: place.id });
|
|
expect(entries.length).toBe(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-060: does not duplicate if skeleton already exists', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Dup Trip',
|
|
start_date: '2026-07-01',
|
|
end_date: '2026-07-02',
|
|
});
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
|
|
const day060 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day060.id, place.id);
|
|
await svc.onPlaceCreated(trip.id, place.id);
|
|
await svc.onPlaceCreated(trip.id, place.id); // second call
|
|
|
|
const entries = await findRows(await orm(), JourneyEntries, { journey: journey.id, sourcePlace: place.id });
|
|
expect(entries.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('onPlaceUpdated', () => {
|
|
it('JOURNEY-SVC-061: updates skeleton entry fields when place changes', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Update Place Trip',
|
|
start_date: '2026-08-01',
|
|
end_date: '2026-08-03',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
|
|
const day061 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day061.id, place.id);
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Update the place name directly in DB
|
|
await updateRows(await orm(), Places, { id: place.id }, { name: 'New Name', address: 'New Address' });
|
|
await svc.onPlaceUpdated(place.id);
|
|
|
|
const entry = await one(JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' }) as any;
|
|
expect(entry).toBeDefined();
|
|
expect(entry.title).toBe('New Name');
|
|
expect(entry.location_name).toBe('New Address');
|
|
});
|
|
|
|
it('JOURNEY-SVC-062: only updates location on filled entries, not title', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Filled Entry Trip',
|
|
start_date: '2026-08-01',
|
|
end_date: '2026-08-02',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'Original Place' });
|
|
const day062 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day062.id, place.id);
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Promote the skeleton to a full entry
|
|
const skeleton = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id }, ['id']) as { id: number };
|
|
await svc.updateEntry(skeleton.id, user.id, { story: 'My story', title: 'Custom Title' });
|
|
|
|
// Now update the place
|
|
await updateRows(await orm(), Places, { id: place.id }, { name: 'Changed Place', address: 'Changed Addr' });
|
|
await svc.onPlaceUpdated(place.id);
|
|
|
|
const entry = await one(JourneyEntries, { id: skeleton.id }) as any;
|
|
expect(entry.title).toBe('Custom Title'); // title unchanged
|
|
expect(entry.location_name).toBe('Changed Addr'); // location updated
|
|
});
|
|
|
|
it('JOURNEY-SVC-063: does nothing if place has no linked entries', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Orphan Trip' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Orphan Place' });
|
|
|
|
// Should not throw
|
|
await svc.onPlaceUpdated(place.id);
|
|
|
|
const entries = await findRows(await orm(), JourneyEntries, { sourcePlace: place.id });
|
|
expect(entries.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('onPlaceDeleted', () => {
|
|
it('JOURNEY-SVC-064: deletes empty skeleton entries', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Delete Place Trip',
|
|
start_date: '2026-09-01',
|
|
end_date: '2026-09-02',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'To Be Deleted' });
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
await svc.onPlaceDeleted(place.id);
|
|
|
|
const entry = await one(JourneyEntries, { sourcePlace: place.id });
|
|
expect(entry).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-065: detaches filled entries and adds note instead of deleting', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Detach Trip',
|
|
start_date: '2026-09-01',
|
|
end_date: '2026-09-02',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'Detach Place' });
|
|
const day065 = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day065.id, place.id);
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Promote the skeleton to a filled entry
|
|
const skeleton = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id }, ['id']) as { id: number };
|
|
await svc.updateEntry(skeleton.id, user.id, { story: 'I really enjoyed this place' });
|
|
|
|
await svc.onPlaceDeleted(place.id);
|
|
|
|
const entry = await one(JourneyEntries, { id: skeleton.id }) as any;
|
|
expect(entry).toBeDefined();
|
|
expect(entry.source_place_id).toBeNull();
|
|
expect(entry.source_trip_id).toBeNull();
|
|
expect(entry.story).toContain('original trip place was removed');
|
|
});
|
|
|
|
it('JOURNEY-SVC-066: does nothing for unlinked places', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Unlinked' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Nowhere' });
|
|
|
|
await expect(svc.onPlaceDeleted(place.id)).resolves.toBeUndefined();
|
|
|
|
const orphaned = { n: await countRows(await orm(), JourneyEntries, { sourcePlace: place.id }) } as { n: number };
|
|
expect(orphaned.n).toBe(0);
|
|
});
|
|
});
|
|
|
|
// -- linkPhotoToEntry ----------------------------------------------------------
|
|
|
|
describe('linkPhotoToEntry', () => {
|
|
it('JOURNEY-SVC-067: moves photo from one entry to another', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry1 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const entry2 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02' });
|
|
|
|
const photo = await svc.addPhoto(entry1.id, user.id, '/uploads/link-test.jpg');
|
|
expect(photo).not.toBeNull();
|
|
|
|
const result = await svc.linkPhotoToEntry(entry2.id, photo!.id, user.id);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.entry_id).toBe(entry2.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-068: returns same photo if already on target entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, user.id, '/uploads/same-entry.jpg');
|
|
|
|
const result = await svc.linkPhotoToEntry(entry.id, photo!.id, user.id);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.id).toBe(photo!.id);
|
|
expect(result!.entry_id).toBe(entry.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-069: returns null for non-existent entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const result = await svc.linkPhotoToEntry(99999, 1, user.id);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-070: returns null for non-existent photo', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const result = await svc.linkPhotoToEntry(entry.id, 99999, user.id);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-071: viewer cannot link photo', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, owner.id, '/uploads/owner-photo.jpg');
|
|
|
|
const result = await svc.linkPhotoToEntry(entry.id, photo!.id, viewer.id);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- setPhotoProvider ----------------------------------------------------------
|
|
|
|
describe('setPhotoProvider', () => {
|
|
it('JOURNEY-SVC-072: sets provider info on an existing photo', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, user.id, '/uploads/provider-test.jpg');
|
|
|
|
await svc.setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id);
|
|
|
|
const journeyPhoto = await one(JourneyPhotos, { id: photo!.id });
|
|
const updated = await one(TrekPhotos, { id: journeyPhoto!.photo_id });
|
|
expect(updated!.provider).toBe('immich');
|
|
expect(updated!.asset_id).toBe('immich-asset-789');
|
|
expect(updated!.owner_id).toBe(user.id);
|
|
});
|
|
});
|
|
|
|
// -- updatePhoto ---------------------------------------------------------------
|
|
|
|
describe('updatePhoto', () => {
|
|
it('JOURNEY-SVC-073: updates caption on photo', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, user.id, '/uploads/caption-test.jpg', undefined, 'Old caption');
|
|
|
|
const result = await svc.updatePhoto(photo!.id, user.id, { caption: 'New caption' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.caption).toBe('New caption');
|
|
});
|
|
|
|
it('JOURNEY-SVC-074: updates sort_order on photo', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, user.id, '/uploads/sort-test.jpg');
|
|
|
|
const result = await svc.updatePhoto(photo!.id, user.id, { sort_order: 10 });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.sort_order).toBe(10);
|
|
});
|
|
|
|
it('JOURNEY-SVC-075: returns null for non-existent photo', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const result = await svc.updatePhoto(99999, user.id, { caption: 'Nope' });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-076: returns photo unchanged when no fields provided', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, user.id, '/uploads/noop-test.jpg', undefined, 'Stay');
|
|
|
|
const result = await svc.updatePhoto(photo!.id, user.id, {});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.caption).toBe('Stay');
|
|
});
|
|
|
|
it('JOURNEY-SVC-077: viewer cannot update photo', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, owner.id, '/uploads/viewer-update.jpg');
|
|
|
|
const result = await svc.updatePhoto(photo!.id, viewer.id, { caption: 'Hacked' });
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- listUserTrips -------------------------------------------------------------
|
|
|
|
describe('listUserTrips', () => {
|
|
it('JOURNEY-SVC-078: returns all user trips', async () => {
|
|
const { user } = createUser(testDb);
|
|
createTrip(testDb, user.id, { title: 'Trip A', start_date: '2026-01-01', end_date: '2026-01-03' });
|
|
createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-02-01', end_date: '2026-02-03' });
|
|
|
|
const trips = await svc.listUserTrips(user.id);
|
|
|
|
expect(trips.length).toBe(2);
|
|
// ordered by start_date DESC
|
|
expect((trips[0] as any).title).toBe('Trip B');
|
|
expect((trips[1] as any).title).toBe('Trip A');
|
|
});
|
|
|
|
it('JOURNEY-SVC-079: returns empty for user with no trips', async () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const trips = await svc.listUserTrips(user.id);
|
|
|
|
expect(trips.length).toBe(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-080: does not return other users trips', async () => {
|
|
const { user: user1 } = createUser(testDb);
|
|
const { user: user2 } = createUser(testDb);
|
|
createTrip(testDb, user1.id, { title: 'User1 Trip' });
|
|
|
|
const trips = await svc.listUserTrips(user2.id);
|
|
|
|
expect(trips.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// -- Edge cases ----------------------------------------------------------------
|
|
|
|
describe('Edge cases', () => {
|
|
it('JOURNEY-SVC-081: deleteEntry deletes photos along with the entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = await svc.addPhoto(entry.id, user.id, '/uploads/gallery-move.jpg');
|
|
|
|
const result = await svc.deleteEntry(entry.id, user.id);
|
|
expect(result).toBe(true);
|
|
|
|
// Junction row must be gone (ON DELETE CASCADE from journey_entries).
|
|
// Gallery row (journey_photos) is preserved — photo may belong to other entries.
|
|
const junctionRow = await one(JourneyEntryPhotos, { entry: entry.id }) as any;
|
|
expect(junctionRow).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const result = await svc.updateJourney(journey.id, user.id, { cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect((result as any).cover_gradient).toBe('linear-gradient(to right, #ff0000, #0000ff)');
|
|
});
|
|
|
|
it('JOURNEY-SVC-083: updateJourney ignores unknown fields', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Original' });
|
|
|
|
const result = await svc.updateJourney(journey.id, user.id, { bogus: 'field' } as any);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.title).toBe('Original');
|
|
});
|
|
|
|
it('JOURNEY-SVC-084: createEntry stores tags and pros_cons as JSON', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const entry = await svc.createEntry(journey.id, user.id, {
|
|
entry_date: '2026-03-10',
|
|
tags: ['food', 'culture'],
|
|
pros_cons: { pros: ['Great view'], cons: ['Expensive'] },
|
|
});
|
|
|
|
expect(entry).not.toBeNull();
|
|
// Read raw from DB
|
|
const raw = await pickOne(JourneyEntries, { id: entry!.id }, ['tags', 'pros_cons']) as any;
|
|
expect(JSON.parse(raw.tags)).toEqual(['food', 'culture']);
|
|
expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Great view'], cons: ['Expensive'] });
|
|
});
|
|
|
|
it('JOURNEY-SVC-085: updateEntry handles tags and pros_cons update', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const result = await svc.updateEntry(entry.id, user.id, {
|
|
tags: ['beach', 'adventure'],
|
|
pros_cons: { pros: ['Fun'], cons: [] },
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
const raw = await pickOne(JourneyEntries, { id: entry.id }, ['tags', 'pros_cons']) as any;
|
|
expect(JSON.parse(raw.tags)).toEqual(['beach', 'adventure']);
|
|
expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Fun'], cons: [] });
|
|
});
|
|
|
|
// #1614 — photos live in journeys now. Linking a trip used to copy its
|
|
// trip_photos into the gallery; that surface lost its UI in 3.1.0, nothing
|
|
// writes to it on a newer install, and the copy was how a photo one member had
|
|
// chosen not to share could reach a journey at all.
|
|
it('JOURNEY-SVC-086: addTripToJourney no longer pulls trip photos into the gallery', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Photo Trip',
|
|
start_date: '2026-04-01',
|
|
end_date: '2026-04-03',
|
|
});
|
|
addTripPhoto(testDb, trip.id, user.id, 'immich-photo-1', 'immich', { shared: true });
|
|
|
|
expect(await svc.addTripToJourney(journey.id, trip.id, user.id)).toBe(true);
|
|
|
|
const photos = await findRows(await orm(), JourneyPhotos, { journey: journey.id });
|
|
expect(photos).toHaveLength(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-087: removeTripFromJourney detaches filled entries, deletes skeletons', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Mixed Trip',
|
|
start_date: '2026-04-01',
|
|
end_date: '2026-04-03',
|
|
});
|
|
const place1 = createPlace(testDb, trip.id, { name: 'Skeleton Place' });
|
|
const place2 = createPlace(testDb, trip.id, { name: 'Filled Place' });
|
|
const days087 = (await pickAll(Days, { trip: trip.id }, ['id'], { date: 'asc' })).slice(0, 2) as { id: number }[];
|
|
createDayAssignment(testDb, days087[0].id, place1.id);
|
|
createDayAssignment(testDb, days087[1].id, place2.id);
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Promote one skeleton to a filled entry
|
|
const filled = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place2.id, type: 'skeleton' }, ['id']) as { id: number };
|
|
await svc.updateEntry(filled.id, user.id, { story: 'Now filled!' });
|
|
|
|
await svc.removeTripFromJourney(journey.id, trip.id, user.id);
|
|
|
|
// skeleton for place1 should be deleted
|
|
const skeletonRow = await one(JourneyEntries, { journey: journey.id, sourcePlace: place1.id });
|
|
expect(skeletonRow).toBeUndefined();
|
|
|
|
// filled entry for place2 should be detached but still present
|
|
const filledRow = await one(JourneyEntries, { id: filled.id }) as any;
|
|
expect(filledRow).toBeDefined();
|
|
expect(filledRow.source_trip_id).toBeNull();
|
|
expect(filledRow.source_place_id).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- Passphrase on addProviderPhoto -------------------------------------------
|
|
|
|
describe('addProviderPhoto — passphrase', () => {
|
|
it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' });
|
|
|
|
const photo = await svc.addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp');
|
|
|
|
expect(photo).not.toBeNull();
|
|
|
|
const row = await pickOne(TrekPhotos, { provider: 'synologyphotos', asset_id: 'pp-asset-1', owner: user.id }, ['passphrase']) as { passphrase: string | null } | undefined;
|
|
expect(row?.passphrase).not.toBeNull();
|
|
expect(typeof row?.passphrase).toBe('string');
|
|
// stored value must be encrypted (not plaintext)
|
|
expect(row?.passphrase).not.toBe('secret-pp');
|
|
});
|
|
});
|
|
|
|
// -- reorderEntries (#846) ----------------------------------------------------
|
|
|
|
async function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): Promise<{ id: number }> {
|
|
const now = Date.now();
|
|
const id = await insertRow(await orm(), JourneyEntries, { journey: journeyId, author: authorId, type: 'entry', entry_date: opts.entry_date, entry_time: opts.entry_time ?? null, sort_order: opts.sort_order ?? 0, visibility: 'private', created_at: now, updated_at: now });
|
|
return { id };
|
|
}
|
|
|
|
describe('reorderEntryPhotos (#824)', () => {
|
|
async function photoOn(journeyId: number, entryIds: number[], order = 0): Promise<number> {
|
|
// The epoch-ms string lands as the integer the old statement bound: the column has numeric affinity.
|
|
const trek = await insertRow(await orm(), TrekPhotos, { provider: 'local', file_path: '/p.jpg', created_at: String(Date.now()) });
|
|
const gp = await insertRow(await orm(), JourneyPhotos, { journey: journeyId, photo: trek, sort_order: 0, created_at: Date.now() });
|
|
for (const entryId of entryIds) await insertRow(await orm(), JourneyEntryPhotos, { entry: entryId, journeyPhoto: gp, sort_order: order, created_at: Date.now() });
|
|
return gp;
|
|
}
|
|
const orderOf = async (entryId: number) =>
|
|
(await pickAll(JourneyEntryPhotos, { entry: entryId }, ['journey_photo_id'], { sort_order: 'asc' }) as { journey_photo_id: number }[]).map(r => r.journey_photo_id);
|
|
|
|
it('JOURNEY-SVC-089b: orders the photos of one entry and leaves the same photo elsewhere alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const a = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01' });
|
|
const b = await insertEntry(journey.id, user.id, { entry_date: '2026-08-02' });
|
|
const p1 = await photoOn(journey.id, [a.id, b.id], 0);
|
|
const p2 = await photoOn(journey.id, [a.id], 1);
|
|
const p3 = await photoOn(journey.id, [a.id], 2);
|
|
expect(await svc.reorderEntryPhotos(a.id, user.id, [p3, p1, p2])).toBe(true);
|
|
expect(await orderOf(a.id)).toEqual([p3, p1, p2]);
|
|
expect(await pickOne(JourneyEntryPhotos, { entry: b.id, journeyPhoto: p1 }, ['sort_order'])).toEqual({ sort_order: 0 });
|
|
});
|
|
|
|
it('JOURNEY-SVC-089c: refuses a list that is not exactly the photos of the entry, and a stranger', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const a = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01' });
|
|
const p1 = await photoOn(journey.id, [a.id]);
|
|
const p2 = await photoOn(journey.id, [a.id]);
|
|
const other = await photoOn(journey.id, []);
|
|
expect(await svc.reorderEntryPhotos(a.id, user.id, [p1])).toBe(false);
|
|
expect(await svc.reorderEntryPhotos(a.id, user.id, [p1, other])).toBe(false);
|
|
expect(await svc.reorderEntryPhotos(a.id, user.id, [p1, p1])).toBe(false);
|
|
expect(await svc.reorderEntryPhotos(a.id, stranger.id, [p2, p1])).toBe(false);
|
|
expect(await svc.reorderEntryPhotos(999999, user.id, [p2, p1])).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('reorderEntries', () => {
|
|
it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const e1 = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 });
|
|
const e2 = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 });
|
|
|
|
const ok = await svc.reorderEntries(journey.id, user.id, [e2.id, e1.id]);
|
|
expect(ok).toBe(true);
|
|
|
|
const entries = (await svc.listEntries(journey.id, user.id))!;
|
|
const dayEntries = entries.filter(e => e.entry_date === '2026-08-01');
|
|
expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const j1 = createJourney(testDb, user.id);
|
|
const j2 = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' });
|
|
|
|
const ok = await svc.reorderEntries(j1.id, user.id, [entry.id]);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const day1a = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 });
|
|
const day1b = await insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 });
|
|
const day2 = await insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 });
|
|
|
|
await svc.reorderEntries(journey.id, user.id, [day1b.id, day1a.id]);
|
|
|
|
const entries = (await svc.listEntries(journey.id, user.id))!;
|
|
const day2Entry = entries.find(e => e.id === day2.id)!;
|
|
expect(day2Entry.sort_order).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('syncTripPlaces sort_order', () => {
|
|
it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Order Trip',
|
|
start_date: '2026-09-01',
|
|
end_date: '2026-09-02',
|
|
});
|
|
const day = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
const p1 = createPlace(testDb, trip.id, { name: 'Place A' });
|
|
const p2 = createPlace(testDb, trip.id, { name: 'Place B' });
|
|
const p3 = createPlace(testDb, trip.id, { name: 'Place C' });
|
|
createDayAssignment(testDb, day.id, p1.id);
|
|
createDayAssignment(testDb, day.id, p2.id);
|
|
createDayAssignment(testDb, day.id, p3.id);
|
|
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
const rows = await pickAll(JourneyEntries, { journey: journey.id }, ['sort_order'], { sort_order: 'asc' }) as { sort_order: number }[];
|
|
const orders = rows.map(r => r.sort_order);
|
|
expect(new Set(orders).size).toBe(orders.length);
|
|
expect(orders).toEqual([0, 1, 2]);
|
|
});
|
|
});
|
|
|
|
describe('onPlaceCreated sort_order', () => {
|
|
it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Append Trip',
|
|
start_date: '2026-10-01',
|
|
end_date: '2026-10-02',
|
|
});
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const day = await pickOne(Days, { trip: trip.id }, ['id', 'date'], { date: 'asc' }) as { id: number; date: string };
|
|
await insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 });
|
|
|
|
const place = createPlace(testDb, trip.id, { name: 'Late Addition' });
|
|
createDayAssignment(testDb, day.id, place.id);
|
|
await svc.onPlaceCreated(trip.id, place.id);
|
|
|
|
const newEntry = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id }, ['sort_order']) as { sort_order: number } | undefined;
|
|
expect(newEntry).toBeDefined();
|
|
expect(newEntry!.sort_order).toBe(6);
|
|
});
|
|
});
|
|
|
|
// -- reconcileTripSkeletons ---------------------------------------------------
|
|
|
|
describe('reconcileTripSkeletons', () => {
|
|
/** Link a fresh journey to a trip and return both. */
|
|
async function linkedJourneyTrip() {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Reconcile Trip',
|
|
start_date: '2026-05-01',
|
|
end_date: '2026-05-03',
|
|
});
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
return { user, journey, trip };
|
|
}
|
|
|
|
async function daysOf(tripId: number) {
|
|
return await pickAll(Days, { trip: tripId }, ['id', 'date'], { date: 'asc' }) as {
|
|
id: number;
|
|
date: string;
|
|
}[];
|
|
}
|
|
|
|
async function skeletonFor(journeyId: number, placeId: number) {
|
|
return await one(JourneyEntries, { journey: journeyId, sourcePlace: placeId }) as any;
|
|
}
|
|
|
|
it('JOURNEY-SVC-094: adds a skeleton for a newly assigned place', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'New Museum' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
const skeleton = await skeletonFor(journey.id, place.id);
|
|
expect(skeleton).toBeDefined();
|
|
expect(skeleton.type).toBe('skeleton');
|
|
expect(skeleton.title).toBe('New Museum');
|
|
expect(skeleton.entry_date).toBe(days[0].date);
|
|
});
|
|
|
|
it('JOURNEY-SVC-095: removes a pure skeleton when its place is unassigned', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'To Remove' });
|
|
const assignment = createDayAssignment(testDb, days[0].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
expect(await skeletonFor(journey.id, place.id)).toBeDefined();
|
|
|
|
await deleteRows(await orm(), DayAssignments, { id: assignment.id });
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
expect(await skeletonFor(journey.id, place.id)).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-096: preserves a filled entry on unassign (detaches + notes it)', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Filled Place' });
|
|
const assignment = createDayAssignment(testDb, days[0].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
const skeleton = await skeletonFor(journey.id, place.id);
|
|
// Promote to a filled entry with content.
|
|
await updateRows(await orm(), JourneyEntries, { id: skeleton.id }, { type: 'entry', story: 'A wonderful visit' });
|
|
|
|
await deleteRows(await orm(), DayAssignments, { id: assignment.id });
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
const kept = await one(JourneyEntries, { id: skeleton.id }) as any;
|
|
expect(kept).toBeDefined();
|
|
expect(kept.type).toBe('entry');
|
|
expect(kept.source_place_id).toBeNull();
|
|
expect(kept.source_trip_id).toBeNull();
|
|
expect(kept.story).toContain('A wonderful visit');
|
|
expect(kept.story).toContain('was removed from the trip plan');
|
|
});
|
|
|
|
it('JOURNEY-SVC-097: refreshes skeleton entry_date when a place is moved to another day', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Moving Place' });
|
|
const assignment = createDayAssignment(testDb, days[0].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
expect((await skeletonFor(journey.id, place.id)).entry_date).toBe(days[0].date);
|
|
|
|
await updateRows(await orm(), DayAssignments, { id: assignment.id }, { day: days[1].id });
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
expect((await skeletonFor(journey.id, place.id)).entry_date).toBe(days[1].date);
|
|
});
|
|
|
|
it('JOURNEY-SVC-098: is idempotent — a second call makes no changes', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Stable Place' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
const before = await pickAll(JourneyEntries, { journey: journey.id }, ['id', 'updated_at'], { id: 'asc' }) as { id: number; updated_at: number }[];
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
const after = await pickAll(JourneyEntries, { journey: journey.id }, ['id', 'updated_at'], { id: 'asc' }) as { id: number; updated_at: number }[];
|
|
|
|
expect(after).toEqual(before);
|
|
});
|
|
|
|
it('JOURNEY-SVC-099: no-ops when the trip is linked to no journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Unlinked', start_date: '2026-05-01', end_date: '2026-05-02' });
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Orphan' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
|
|
await expect(svc.reconcileTripSkeletons(trip.id)).resolves.toBeUndefined();
|
|
const anyEntry = { n: await countRows(await orm(), JourneyEntries, {}) } as { n: number };
|
|
expect(anyEntry.n).toBe(0);
|
|
});
|
|
|
|
// -- M2 (task-5-review.md) — the sync engine's existence-check-then-insert
|
|
// used to be two separate awaits, so two concurrent callers could both see
|
|
// "not there yet" and both insert a skeleton for the same (place,
|
|
// assignment). Races two REAL `await Promise.all([...])` callers through
|
|
// the actual service on the shared connection, then asserts the base's
|
|
// outcome: exactly one skeleton per (place, assignment), never two.
|
|
describe('concurrency (M2) — exactly one skeleton per (place, assignment) under a race', () => {
|
|
it('two concurrent reconcileTripSkeletons(trip) calls after two new assignments produce exactly one skeleton per place, not two', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const placeA = createPlace(testDb, trip.id, { name: 'Race Place A' });
|
|
const placeB = createPlace(testDb, trip.id, { name: 'Race Place B' });
|
|
createDayAssignment(testDb, days[0].id, placeA.id);
|
|
createDayAssignment(testDb, days[1].id, placeB.id);
|
|
|
|
await Promise.all([
|
|
svc.reconcileTripSkeletons(trip.id),
|
|
svc.reconcileTripSkeletons(trip.id),
|
|
]);
|
|
|
|
const countFor = async (placeId: number) => countRows(await orm(), JourneyEntries, { journey: journey.id, sourcePlace: placeId });
|
|
expect(await countFor(placeA.id)).toBe(1);
|
|
expect(await countFor(placeB.id)).toBe(1);
|
|
});
|
|
|
|
it('onPlaceCreated racing reconcileTripSkeletons for the same new assignment produces exactly one skeleton, not two', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Race Place C' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
|
|
await Promise.all([
|
|
svc.onPlaceCreated(trip.id, place.id),
|
|
svc.reconcileTripSkeletons(trip.id),
|
|
]);
|
|
|
|
const count = ({ n: await countRows(await orm(), JourneyEntries, { journey: journey.id, sourcePlace: place.id }) } as { n: number }).n;
|
|
expect(count).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
// -- the same place on two days (#2329) ---------------------------------------
|
|
|
|
describe('a place standing on more than one day', () => {
|
|
async function linkedJourneyTrip() {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Repeat Trip',
|
|
start_date: '2026-05-01',
|
|
end_date: '2026-05-03',
|
|
});
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
return { user, journey, trip };
|
|
}
|
|
|
|
async function daysOf(tripId: number) {
|
|
return await pickAll(Days, { trip: tripId }, ['id', 'date'], { date: 'asc' }) as {
|
|
id: number;
|
|
date: string;
|
|
}[];
|
|
}
|
|
|
|
async function skeletonsFor(journeyId: number, placeId: number) {
|
|
return await findRows(await orm(), JourneyEntries, { journey: journeyId, sourcePlace: placeId }, { entry_date: 'asc' }) as any[];
|
|
}
|
|
|
|
it('JOURNEY-SVC-REPEAT-001: syncTripPlaces writes one skeleton per day, not one per place', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Two Nights', start_date: '2026-05-01', end_date: '2026-05-03' });
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Reykjavík' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
createDayAssignment(testDb, days[1].id, place.id);
|
|
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
expect((await skeletonsFor(journey.id, place.id)).map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-REPEAT-002: a second call adds nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Idempotent', start_date: '2026-05-01', end_date: '2026-05-03' });
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Vík' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
createDayAssignment(testDb, days[1].id, place.id);
|
|
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
expect(await skeletonsFor(journey.id, place.id)).toHaveLength(2);
|
|
});
|
|
|
|
it('JOURNEY-SVC-REPEAT-003: onPlaceCreated fires once per day the place already stands on', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Höfn' });
|
|
createDayAssignment(testDb, days[1].id, place.id);
|
|
createDayAssignment(testDb, days[2].id, place.id);
|
|
|
|
await svc.onPlaceCreated(trip.id, place.id);
|
|
|
|
expect((await skeletonsFor(journey.id, place.id)).map((e) => e.entry_date)).toEqual([days[1].date, days[2].date]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-REPEAT-004: assigning the place to a second day adds a second entry and keeps the first', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Akureyri' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
const first = (await skeletonsFor(journey.id, place.id))[0];
|
|
await updateRows(await orm(), JourneyEntries, { id: first.id }, { type: 'entry', story: 'Sunset' });
|
|
|
|
createDayAssignment(testDb, days[1].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
const both = await skeletonsFor(journey.id, place.id);
|
|
expect(both.map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]);
|
|
expect(both[0].id).toBe(first.id);
|
|
expect(both[0].story).toBe('Sunset');
|
|
expect(both[1].type).toBe('skeleton');
|
|
});
|
|
|
|
it('JOURNEY-SVC-REPEAT-005: unassigning one day drops only that day', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Selfoss' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
const second = createDayAssignment(testDb, days[1].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
expect(await skeletonsFor(journey.id, place.id)).toHaveLength(2);
|
|
|
|
await deleteRows(await orm(), DayAssignments, { id: second.id });
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
expect((await skeletonsFor(journey.id, place.id)).map((e) => e.entry_date)).toEqual([days[0].date]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-REPEAT-006: moving one of the two assignments moves its entry rather than replacing it', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Geysir' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
const second = createDayAssignment(testDb, days[1].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
const movedId = (await skeletonsFor(journey.id, place.id))[1].id;
|
|
|
|
await updateRows(await orm(), DayAssignments, { id: second.id }, { day: days[2].id });
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
const after = await skeletonsFor(journey.id, place.id);
|
|
expect(after.map((e) => e.entry_date)).toEqual([days[0].date, days[2].date]);
|
|
expect(after[1].id).toBe(movedId);
|
|
});
|
|
|
|
it('JOURNEY-SVC-REPEAT-007: editing the place leaves each entry on its own day', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
createDayAssignment(testDb, days[1].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
await updateRows(await orm(), Places, { id: place.id }, { name: 'New Name' });
|
|
await svc.onPlaceUpdated(place.id);
|
|
|
|
const after = await skeletonsFor(journey.id, place.id);
|
|
expect(after.map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]);
|
|
expect(after.map((e) => e.title)).toEqual(['New Name', 'New Name']);
|
|
});
|
|
|
|
it('JOURNEY-SVC-REPEAT-008: an entry with no assignment link is claimed, not annotated out', async () => {
|
|
const { journey, trip } = await linkedJourneyTrip();
|
|
const days = await daysOf(trip.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Legacy Stop' });
|
|
createDayAssignment(testDb, days[0].id, place.id);
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
const legacy = (await skeletonsFor(journey.id, place.id))[0];
|
|
// What an install upgraded from before the column existed looks like.
|
|
await updateRows(await orm(), JourneyEntries, { id: legacy.id }, { source_assignment_id: null, type: 'entry', story: 'Kept' });
|
|
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
const after = await skeletonsFor(journey.id, place.id);
|
|
expect(after).toHaveLength(1);
|
|
expect(after[0].id).toBe(legacy.id);
|
|
expect(after[0].source_assignment_id).not.toBeNull();
|
|
expect(after[0].story).toBe('Kept');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skeleton lifecycle, the photo gallery and the per-user preference row. These
|
|
// paths were reachable only through the REST controller before the fold, so the
|
|
// service-level branches below had no direct case.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** A trip with one day and one place assigned to it — the shape skeleton sync reads. */
|
|
function tripWithPlace(userId: number, opts: { name?: string; date?: string } = {}) {
|
|
const trip = createTrip(testDb, userId);
|
|
const day = createDay(testDb, trip.id, { date: opts.date ?? '2026-05-01' });
|
|
const place = createPlace(testDb, trip.id, { name: opts.name ?? 'Fushimi Inari' });
|
|
createDayAssignment(testDb, day.id, place.id);
|
|
return { trip, day, place };
|
|
}
|
|
|
|
async function skeletonsOf(journeyId: number) {
|
|
return await findRows(await orm(), JourneyEntries, { journey: journeyId, type: 'skeleton' }, { id: 'asc' }) as any[];
|
|
}
|
|
|
|
describe('skeleton sync', () => {
|
|
it('JOURNEY-SVC-SKEL-001: linking a trip materialises one skeleton per assigned place', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
const skeletons = await skeletonsOf(journey.id);
|
|
expect(skeletons).toHaveLength(1);
|
|
expect(skeletons[0].title).toBe('Fushimi Inari');
|
|
expect(skeletons[0].source_place_id).toBe(place.id);
|
|
expect(skeletons[0].source_trip_id).toBe(trip.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-002: syncing the same trip twice does not duplicate skeletons', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip } = tripWithPlace(user.id);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
expect(await skeletonsOf(journey.id)).toHaveLength(1);
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-003: onPlaceCreated adds a skeleton to every journey the trip is linked to', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, day } = tripWithPlace(user.id);
|
|
const a = await svc.createJourney(user.id, { title: 'A', trip_ids: [trip.id] });
|
|
const b = await svc.createJourney(user.id, { title: 'B', trip_ids: [trip.id] });
|
|
|
|
const extra = createPlace(testDb, trip.id, { name: 'Nishiki Market' });
|
|
createDayAssignment(testDb, day.id, extra.id);
|
|
await svc.onPlaceCreated(trip.id, extra.id);
|
|
|
|
for (const j of [a, b]) {
|
|
expect((await skeletonsOf(j.id)).map((s) => s.title)).toContain('Nishiki Market');
|
|
}
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-004: onPlaceCreated is a no-op for a trip in no journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
await expect(svc.onPlaceCreated(trip.id, place.id)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-005: onPlaceUpdated carries the rename and the new address onto the skeleton', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
await updateRows(await orm(), Places, { id: place.id }, { name: 'Kinkaku-ji', address: '1 Kinkakujicho' });
|
|
await svc.onPlaceUpdated(place.id);
|
|
|
|
const [skeleton] = await skeletonsOf(journey.id);
|
|
expect(skeleton.title).toBe('Kinkaku-ji');
|
|
expect(skeleton.location_name).toBe('1 Kinkakujicho');
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-006: onPlaceUpdated is a no-op when no skeleton points at the place', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { place } = tripWithPlace(user.id);
|
|
await expect(svc.onPlaceUpdated(place.id)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-007: onPlaceDeleted drops an empty skeleton outright', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
expect(await skeletonsOf(journey.id)).toHaveLength(1);
|
|
|
|
await svc.onPlaceDeleted(place.id);
|
|
expect(await skeletonsOf(journey.id)).toHaveLength(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-008: onPlaceDeleted keeps a skeleton that has a story, detaches it and appends the note', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
const [skeleton] = await skeletonsOf(journey.id);
|
|
await updateRows(await orm(), JourneyEntries, { id: skeleton.id }, { story: 'We queued for an hour.' });
|
|
|
|
await svc.onPlaceDeleted(place.id);
|
|
|
|
const kept = await one(JourneyEntries, { id: skeleton.id }) as any;
|
|
expect(kept).toBeDefined();
|
|
expect(kept.source_place_id).toBeNull();
|
|
expect(kept.source_trip_id).toBeNull();
|
|
// A skeleton that survives is promoted to a real entry.
|
|
expect(kept.type).toBe('entry');
|
|
expect(kept.story).toContain('removed from the trip plan');
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-009: reconcileTripSkeletons adds what is missing and removes what is gone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, day, place } = tripWithPlace(user.id);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
// A second place lands without firing the hook, and the first is unassigned.
|
|
const second = createPlace(testDb, trip.id, { name: 'Gion' });
|
|
createDayAssignment(testDb, day.id, second.id);
|
|
await deleteRows(await orm(), DayAssignments, { place: place.id });
|
|
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
const titles = (await skeletonsOf(journey.id)).map((s) => s.title);
|
|
expect(titles).toContain('Gion');
|
|
expect(titles).not.toContain('Fushimi Inari');
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-010: reconcileTripSkeletons is a no-op for a trip in no journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip } = tripWithPlace(user.id);
|
|
await expect(svc.reconcileTripSkeletons(trip.id)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-SKEL-011: createJourney takes its cover from the first linked trip and strips the /uploads prefix', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await updateRows(await orm(), Trips, { id: trip.id }, { cover_image: '/uploads/covers/kyoto.jpg' });
|
|
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
const row = await pickOne(Journeys, { id: journey.id }, ['cover_image']) as any;
|
|
expect(row.cover_image).toBe('covers/kyoto.jpg');
|
|
});
|
|
});
|
|
|
|
describe('journey gallery', () => {
|
|
async function ownedEntry() {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const entry = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Day 1' }))!;
|
|
return { user, journey, entry };
|
|
}
|
|
|
|
it('JOURNEY-SVC-PHOTO-001: addPhoto puts the file in the gallery and links it to the entry', async () => {
|
|
const { user, journey, entry } = await ownedEntry();
|
|
const photo = await svc.addPhoto(entry.id, user.id, 'journey/a.jpg', 'journey/a-thumb.jpg', 'Torii');
|
|
expect(photo).toBeTruthy();
|
|
|
|
const entries = (await svc.listEntries(journey.id, user.id))!;
|
|
expect(entries.find((e) => e.id === entry.id)!.photos).toHaveLength(1);
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-002: addPhoto refuses an unknown entry and a non-editor', async () => {
|
|
const { entry } = await ownedEntry();
|
|
const { user: stranger } = createUser(testDb);
|
|
expect(await svc.addPhoto(999999, 1, 'journey/a.jpg')).toBeNull();
|
|
expect(await svc.addPhoto(entry.id, stranger.id, 'journey/a.jpg')).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-003: uploadGalleryPhotos appends in order and refuses a non-editor', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
|
|
const first = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/1.jpg' }]);
|
|
const second = await svc.uploadGalleryPhotos(journey.id, user.id, [
|
|
{ path: 'journey/2.jpg', thumbnail: 'journey/2-t.jpg' },
|
|
{ path: 'journey/3.mp4', mediaType: 'video', durationMs: 4200 },
|
|
]);
|
|
expect(first).toHaveLength(1);
|
|
expect(second).toHaveLength(2);
|
|
|
|
const orders = await pickAll(JourneyPhotos, { journey: journey.id }, ['sort_order'], { sort_order: 'asc' }) as any[];
|
|
expect(orders.map((o) => o.sort_order)).toEqual([0, 1, 2]);
|
|
|
|
expect(await svc.uploadGalleryPhotos(journey.id, stranger.id, [{ path: 'journey/x.jpg' }])).toEqual([]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-003b: gallery uploads at the same time take one position each', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
|
|
// Each upload reads MAX(sort_order) inside the transaction that inserts. Read
|
|
// outside it, every upload saw the same MAX and the batches overlapped.
|
|
const batches = await Promise.all([1, 2, 3].map((n) => svc.uploadGalleryPhotos(journey.id, user.id, [
|
|
{ path: `journey/c${n}-a.jpg` },
|
|
{ path: `journey/c${n}-b.jpg` },
|
|
])));
|
|
expect(batches.map((b) => b.length)).toEqual([2, 2, 2]);
|
|
|
|
const orders = await pickAll(JourneyPhotos, { journey: journey.id }, ['sort_order'], { sort_order: 'asc' });
|
|
expect(orders.map((o) => o.sort_order)).toEqual([0, 1, 2, 3, 4, 5]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-003c: photos added to one entry at the same time take one gallery and one entry position each', async () => {
|
|
const { user, journey, entry } = await ownedEntry();
|
|
|
|
const photos = await Promise.all(['a', 'b', 'c'].map((n) => svc.addPhoto(entry.id, user.id, `journey/e-${n}.jpg`)));
|
|
expect(photos.every(Boolean)).toBe(true);
|
|
|
|
const gallery = await pickAll(JourneyPhotos, { journey: journey.id }, ['sort_order'], { sort_order: 'asc' });
|
|
expect(gallery.map((o) => o.sort_order)).toEqual([0, 1, 2]);
|
|
const linked = await pickAll(JourneyEntryPhotos, { entry: entry.id }, ['sort_order'], { sort_order: 'asc' });
|
|
expect(linked.map((o) => o.sort_order)).toEqual([0, 1, 2]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-004: linkPhotoToEntry attaches a gallery row, unlinkPhotoFromEntry detaches it', async () => {
|
|
const { user, journey, entry } = await ownedEntry();
|
|
const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]);
|
|
|
|
expect(await svc.linkPhotoToEntry(entry.id, gallery.id, user.id)).toBeTruthy();
|
|
expect((await svc.listEntries(journey.id, user.id))!.find((e) => e.id === entry.id)!.photos).toHaveLength(1);
|
|
|
|
expect(await svc.unlinkPhotoFromEntry(entry.id, gallery.id, user.id)).toBe(true);
|
|
expect((await svc.listEntries(journey.id, user.id))!.find((e) => e.id === entry.id)!.photos).toHaveLength(0);
|
|
// The gallery row survives the unlink — that is the whole point of the split.
|
|
expect(await one(JourneyPhotos, { id: gallery.id })).toBeDefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-005: link/unlink refuse an unknown entry and a non-editor', async () => {
|
|
const { user, journey, entry } = await ownedEntry();
|
|
const { user: stranger } = createUser(testDb);
|
|
const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]);
|
|
|
|
expect(await svc.linkPhotoToEntry(999999, gallery.id, user.id)).toBeNull();
|
|
expect(await svc.linkPhotoToEntry(entry.id, gallery.id, stranger.id)).toBeNull();
|
|
expect(await svc.unlinkPhotoFromEntry(999999, gallery.id, user.id)).toBe(false);
|
|
expect(await svc.unlinkPhotoFromEntry(entry.id, gallery.id, stranger.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-006: deleteGalleryPhoto removes the row and refuses an unknown id', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]);
|
|
|
|
expect(await svc.deleteGalleryPhoto(gallery.id, user.id)).toBeTruthy();
|
|
expect(await one(JourneyPhotos, { id: gallery.id })).toBeUndefined();
|
|
expect(await svc.deleteGalleryPhoto(999999, user.id)).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-007: deleteGalleryPhoto refuses a non-editor', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]);
|
|
expect(await svc.deleteGalleryPhoto(gallery.id, stranger.id)).toBeNull();
|
|
});
|
|
|
|
// #2200: the gallery used to come out in upload order, so pictures caught up
|
|
// with after the trip landed behind the ones added while it was running.
|
|
it('JOURNEY-SVC-PHOTO-008: the gallery reads in capture order, not upload order', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
|
|
const shot = async (path: string, takenAt: string) => {
|
|
const [row] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path }]);
|
|
await updateRows(await orm(), TrekPhotos, { id: row.photo_id }, { taken_at: takenAt });
|
|
};
|
|
|
|
await shot('journey/day3.jpg', '2026-05-03T18:00:00.000Z');
|
|
await shot('journey/day1.jpg', '2026-05-01T08:30:00.000Z');
|
|
await shot('journey/day2.jpg', '2026-05-02T12:00:00.000Z');
|
|
|
|
const gallery = (await svc.getJourneyFull(journey.id, user.id))!.gallery as { file_path: string }[];
|
|
expect(gallery.map((p) => p.file_path)).toEqual([
|
|
'journey/day1.jpg',
|
|
'journey/day2.jpg',
|
|
'journey/day3.jpg',
|
|
]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-PHOTO-009: a photo with no capture time rides on the date of the stop it hangs on', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const day1 = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Day 1' }))!;
|
|
const day2 = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-05-02', title: 'Day 2' }))!;
|
|
|
|
// Day two was sorted out first and day one caught up afterwards, with the
|
|
// EXIF gone (an iPhone upload is converted before it leaves the browser).
|
|
const [dayTwoPhoto] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/day2.jpg' }]);
|
|
const [dayOnePhoto] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/day1.jpg' }]);
|
|
const [loose] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/loose.jpg' }]);
|
|
await svc.linkPhotoToEntry(day2.id, dayTwoPhoto.id, user.id);
|
|
await svc.linkPhotoToEntry(day1.id, dayOnePhoto.id, user.id);
|
|
// Pin the loose photo's upload time so the run does not depend on today's date.
|
|
await updateRows(await orm(), JourneyPhotos, { id: loose.id }, { created_at: Date.parse('2026-06-01T00:00:00Z') });
|
|
|
|
const gallery = (await svc.getJourneyFull(journey.id, user.id))!.gallery as { file_path: string }[];
|
|
expect(gallery.map((p) => p.file_path)).toEqual([
|
|
'journey/day1.jpg',
|
|
'journey/day2.jpg',
|
|
'journey/loose.jpg',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('per-user journey preferences', () => {
|
|
it('JOURNEY-SVC-PREF-001: hide_skeletons round-trips for the owner', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
expect(await svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: true })).toEqual({ hide_skeletons: true });
|
|
expect(await svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: false })).toEqual({ hide_skeletons: false });
|
|
});
|
|
|
|
it('JOURNEY-SVC-PREF-002: an empty patch is accepted and changes nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
await svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: true });
|
|
expect(await svc.updateJourneyPreferences(journey.id, user.id, {})).toEqual({ hide_skeletons: true });
|
|
});
|
|
|
|
it('JOURNEY-SVC-PREF-003: a stranger gets null, not a row', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
expect(await svc.updateJourneyPreferences(journey.id, stranger.id, { hide_skeletons: true })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('entry enrichment', () => {
|
|
it('JOURNEY-SVC-ENRICH-001: tags and pros_cons come back parsed, and source_trip_name is resolved', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip } = tripWithPlace(user.id);
|
|
await updateRows(await orm(), Trips, { id: trip.id }, { title: 'Japan 2026' });
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
const [skeleton] = await skeletonsOf(journey.id);
|
|
await updateRows(await orm(), JourneyEntries, { id: skeleton.id }, { tags: JSON.stringify(['shrine']), pros_cons: JSON.stringify({ pros: ['quiet'], cons: [] }) });
|
|
|
|
const entry = (await svc.listEntries(journey.id, user.id))!.find((e) => e.id === skeleton.id)!;
|
|
expect(entry.tags).toEqual(['shrine']);
|
|
expect((entry as any).pros_cons).toEqual({ pros: ['quiet'], cons: [] });
|
|
expect((entry as any).source_trip_name).toBe('Japan 2026');
|
|
});
|
|
|
|
it('JOURNEY-SVC-ENRICH-002: an entry with no trip and no tags gets [] and null, not undefined', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const created = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Solo' }))!;
|
|
|
|
const entry = (await svc.listEntries(journey.id, user.id))!.find((e) => e.id === created.id)!;
|
|
expect(entry.tags).toEqual([]);
|
|
expect((entry as any).pros_cons).toBeNull();
|
|
expect((entry as any).source_trip_name).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── GPX tracks on the journey map (#1260) ─────────────────────────────────────
|
|
describe('journeyTracks', () => {
|
|
/** A GPX import stores the geometry on the place, as JSON [lat, lng] pairs. */
|
|
const withGeometry = async (placeId: number, geometry: unknown, color: string | null = null) =>
|
|
updateRows(await orm(), Places, { id: placeId }, { route_geometry: typeof geometry === 'string' ? geometry : JSON.stringify(geometry), route_color: color });
|
|
|
|
it('JOURNEY-SVC-TRACKS-001: returns the tracks of the trips the entries came from', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
await withGeometry(place.id, [[35.1, 135.7], [35.2, 135.8]], '#ff0000');
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
const tracks = (await svc.journeyTracks(journey.id, user.id))!;
|
|
expect(tracks).toHaveLength(1);
|
|
expect(tracks[0]).toMatchObject({ place_id: place.id, trip_id: trip.id, color: '#ff0000' });
|
|
expect(tracks[0].points).toEqual([[35.1, 135.7], [35.2, 135.8]]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-TRACKS-002: keeps the elevation out and the pair in', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
// The importer keeps elevation as a third value where the file had it.
|
|
await withGeometry(place.id, [[47.1, 11.2, 1830], [47.2, 11.3, 1902]]);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
expect((await svc.journeyTracks(journey.id, user.id))![0].points).toEqual([[47.1, 11.2], [47.2, 11.3]]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-TRACKS-003: a place without geometry contributes nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip } = tripWithPlace(user.id);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
expect(await svc.journeyTracks(journey.id, user.id)).toEqual([]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-TRACKS-004: unusable geometry is skipped, not fatal', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
const second = createPlace(testDb, trip.id, { name: 'Good one' });
|
|
await withGeometry(place.id, 'not json at all');
|
|
await withGeometry(second.id, [[1, 2], [3, 4]]);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
const tracks = (await svc.journeyTracks(journey.id, user.id))!;
|
|
expect(tracks.map(t => t.place_id)).toEqual([second.id]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-TRACKS-005: a single point is a pin, not a line', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
await withGeometry(place.id, [[35.1, 135.7]]);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
expect(await svc.journeyTracks(journey.id, user.id)).toEqual([]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-TRACKS-006: a stranger gets null, not another user route', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const { trip, place } = tripWithPlace(user.id);
|
|
await withGeometry(place.id, [[1, 2], [3, 4]]);
|
|
const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
|
|
|
expect(await svc.journeyTracks(journey.id, stranger.id)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── Trip linking: whose journey, and whose photos (#1614 review) ─────────────
|
|
|
|
describe('addTripToJourney guards', () => {
|
|
it('JOURNEY-SVC-100: refuses to link into a journey the caller cannot reach', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id, { title: "Owner's journey" });
|
|
const trip = createTrip(testDb, stranger.id, { title: 'Stranger trip' });
|
|
|
|
// The stranger owns the trip, so the trip gate passes — only the journey gate stops this.
|
|
expect(await svc.addTripToJourney(journey.id, trip.id, stranger.id)).toBe(false);
|
|
const links = await findRows(await orm(), JourneyTrips, { journey: journey.id });
|
|
expect(links).toHaveLength(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-101: a contributor may still link, the owner obviously too', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: helper } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id, { title: 'Shared journey' });
|
|
await insertRow(await orm(), JourneyContributors, { journey: journey.id, user: helper.id, role: 'editor', added_at: Date.now() });
|
|
const trip = createTrip(testDb, helper.id, { title: 'Helper trip' });
|
|
|
|
expect(await svc.addTripToJourney(journey.id, trip.id, helper.id)).toBe(true);
|
|
});
|
|
|
|
it('JOURNEY-SVC-102: not even a shared trip photo is copied any more', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Photo journey' });
|
|
const trip = createTrip(testDb, user.id, { title: 'Photo trip' });
|
|
|
|
const photoId = await insertRow(await orm(), TrekPhotos, { provider: 'immich', asset_id: 'shared-asset', owner: user.id, media_type: 'image' });
|
|
await insertRow(await orm(), TripPhotos, { trip: trip.id, user: user.id, photo: photoId, shared: 1 });
|
|
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
expect(await findRows(await orm(), JourneyPhotos, { journey: journey.id })).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// -- Dismissing a suggestion (discussion #2299) --------------------------------
|
|
|
|
describe('dismissed suggestions', () => {
|
|
it('JOURNEY-SVC-103: a dismissed suggestion leaves every read but keeps its row', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const keep = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Museum' });
|
|
const drop = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Aquarium' });
|
|
|
|
await svc.updateEntry(drop.id, user.id, { dismissed: true });
|
|
|
|
const full = (await svc.getJourneyFull(journey.id, user.id))!;
|
|
expect(full.entries.map((e: { id: number }) => e.id)).toEqual([keep.id]);
|
|
expect((await svc.listEntries(journey.id, user.id))!.map((e) => e.id)).toEqual([keep.id]);
|
|
// The row has to survive, or syncTripPlaces offers the same place again.
|
|
expect(await pickOne(JourneyEntries, { id: drop.id }, ['dismissed'])).toEqual({
|
|
dismissed: 1,
|
|
});
|
|
});
|
|
|
|
it('JOURNEY-SVC-104: the journey reports how many were dismissed', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
|
const b = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
|
createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
|
|
|
await svc.updateEntry(a.id, user.id, { dismissed: true });
|
|
await svc.updateEntry(b.id, user.id, { dismissed: true });
|
|
|
|
expect((await svc.getJourneyFull(journey.id, user.id))!.dismissed_count).toBe(2);
|
|
});
|
|
|
|
it('JOURNEY-SVC-105: a dismissed place is not offered again by the trip sync', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id, { day_number: 1, date: '2026-01-15' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Aquarium' });
|
|
createDayAssignment(testDb, day.id, place.id);
|
|
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
const skeleton = (await svc.listEntries(journey.id, user.id))!.find((e) => e.type === 'skeleton')!;
|
|
await svc.updateEntry(skeleton.id, user.id, { dismissed: true });
|
|
|
|
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
expect((await svc.listEntries(journey.id, user.id))!.filter((e) => e.type === 'skeleton')).toHaveLength(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-106: restoring brings them all back and says how many', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
|
const b = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' });
|
|
await svc.updateEntry(a.id, user.id, { dismissed: true });
|
|
await svc.updateEntry(b.id, user.id, { dismissed: true });
|
|
|
|
expect(await svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 2 });
|
|
expect(await svc.listEntries(journey.id, user.id)).toHaveLength(2);
|
|
});
|
|
|
|
it('JOURNEY-SVC-107: restoring nothing is not an error', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
expect(await svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 0 });
|
|
});
|
|
|
|
it('JOURNEY-SVC-108: a viewer may not restore', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
|
|
expect(await svc.restoreDismissedSuggestions(journey.id, viewer.id)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- The country behind an entry's coordinates ---------------------------------
|
|
|
|
describe('country_code', () => {
|
|
it('JOURNEY-SVC-109: a created entry resolves its country from its coordinates', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const entry = (await svc.createEntry(journey.id, user.id, {
|
|
entry_date: '2026-01-15',
|
|
location_lat: 52.52,
|
|
location_lng: 13.405,
|
|
}))!;
|
|
|
|
expect(entry.country_code).toBe('DE');
|
|
});
|
|
|
|
it('JOURNEY-SVC-110: an entry with no coordinates has no country', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const entry = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15' }))!;
|
|
|
|
expect(entry.country_code).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-111: moving the pin moves the country with it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = (await svc.createEntry(journey.id, user.id, {
|
|
entry_date: '2026-01-15',
|
|
location_lat: 52.52,
|
|
location_lng: 13.405,
|
|
}))!;
|
|
|
|
const moved = (await svc.updateEntry(entry.id, user.id, { location_lat: 48.8584, location_lng: 2.2945 }))!;
|
|
|
|
expect(moved.country_code).toBe('FR');
|
|
});
|
|
|
|
it('JOURNEY-SVC-112: taking the pin off clears the country', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = (await svc.createEntry(journey.id, user.id, {
|
|
entry_date: '2026-01-15',
|
|
location_lat: 52.52,
|
|
location_lng: 13.405,
|
|
}))!;
|
|
|
|
const cleared = (await svc.updateEntry(entry.id, user.id, {
|
|
location_lat: null as unknown as number,
|
|
location_lng: null as unknown as number,
|
|
}))!;
|
|
|
|
expect(cleared.country_code).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-114: a place that moves across a border moves its skeleton entry country too', async () => {
|
|
// The pin can also move without anybody editing the entry: the place it came
|
|
// from is dragged, and the trip sync writes the new coordinates onto the
|
|
// skeleton. The flag has to follow that write like it follows a manual one.
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Border', start_date: '2026-08-01', end_date: '2026-08-03' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Grenzstein', lat: 48.8584, lng: 2.2945 });
|
|
const day = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day.id, place.id);
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const before = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' }, ['country_code']) as { country_code: string | null };
|
|
expect(before.country_code).toBe('FR');
|
|
|
|
await updateRows(await orm(), Places, { id: place.id }, { lat: 52.52, lng: 13.405 });
|
|
await svc.onPlaceUpdated(place.id);
|
|
|
|
const after = await pickOne(JourneyEntries, { journey: journey.id, sourcePlace: place.id, type: 'skeleton' }, ['country_code']) as { country_code: string | null };
|
|
expect(after.country_code).toBe('DE');
|
|
});
|
|
|
|
it('JOURNEY-SVC-115: and so does a filled entry, whose location follows the place silently', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Border 2', start_date: '2026-08-01', end_date: '2026-08-03' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Grenzstein', lat: 48.8584, lng: 2.2945 });
|
|
const day = await pickOne(Days, { trip: trip.id }, ['id'], { date: 'asc' }) as { id: number };
|
|
createDayAssignment(testDb, day.id, place.id);
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
// Writing a story turns the skeleton into a filled entry.
|
|
await updateRows(await orm(), JourneyEntries, { sourcePlace: place.id }, { type: 'entry' });
|
|
|
|
await updateRows(await orm(), Places, { id: place.id }, { lat: 52.52, lng: 13.405 });
|
|
await svc.onPlaceUpdated(place.id);
|
|
|
|
const after = await pickOne(JourneyEntries, { sourcePlace: place.id }, ['country_code', 'location_lat']) as { country_code: string | null; location_lat: number };
|
|
expect(after).toMatchObject({ country_code: 'DE', location_lat: 52.52 });
|
|
});
|
|
|
|
it('JOURNEY-SVC-113: an edit that does not touch the pin leaves the country alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = (await svc.createEntry(journey.id, user.id, {
|
|
entry_date: '2026-01-15',
|
|
location_lat: 52.52,
|
|
location_lng: 13.405,
|
|
}))!;
|
|
|
|
const renamed = (await svc.updateEntry(entry.id, user.id, { title: 'Berlin' }))!;
|
|
|
|
expect(renamed.country_code).toBe('DE');
|
|
});
|
|
});
|
|
|
|
// -- Which optional fields a journey keeps -------------------------------------
|
|
|
|
describe('entry field switches', () => {
|
|
it('JOURNEY-SVC-114: a fresh journey keeps all three', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const row = (await svc.getJourneyFull(journey.id, user.id))! as unknown as Record<string, number>;
|
|
|
|
expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 1, 1]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-115: the owner can put one away, and booleans reach the INTEGER column', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
await svc.updateJourney(journey.id, user.id, { show_mood: false, show_weather: false });
|
|
|
|
const row = (await svc.getJourneyFull(journey.id, user.id))! as unknown as Record<string, number>;
|
|
expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 0, 0]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-116: an editor may not reshape the journey', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
expect(await svc.updateJourney(journey.id, editor.id, { show_mood: false })).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plan 3g Task 1 — repository parity + mutation proofs (R9's brief: "this is
|
|
// the bulk of this task's actual work"). Everything above this line is the
|
|
// pre-existing suite, exercised unchanged against the converted service
|
|
// (only the construction block at the very top of this file changed). These
|
|
// new cases go straight at the four repositories built this task
|
|
// (`journeysRepoDirect`/`contributorsRepoDirect`/`journeyTripsRepoDirect`/
|
|
// `entriesRepoDirect`, captured in the shared `beforeAll` above) rather than
|
|
// only through `svc`, so each converted read model gets its own byte-level
|
|
// proof against the exact legacy statement text (plan3g-sql-inventory.md
|
|
// §1a-§1d), not just an outcome-level assertion through the service.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Plan 3g Task 1 — repository parity (full-key toEqual against the legacy statement)', () => {
|
|
/**
|
|
* A fully-seeded journey: an owner plus BOTH contributor roles (editor and
|
|
* viewer), one linked trip with places on two days, one place standing on
|
|
* BOTH days (#2329's multi-day skeleton case), and one place whose skeleton
|
|
* was filled in with a story and THEN removed from the trip (the
|
|
* detach-and-annotate path, exercised via the real `onPlaceDeleted`).
|
|
*/
|
|
async function seedFullJourney() {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id, { title: 'Parity Journey' });
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
|
|
const trip = createTrip(testDb, owner.id, {
|
|
title: 'Parity Trip', start_date: '2026-05-01', end_date: '2026-05-03',
|
|
});
|
|
const day1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
|
const day2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
|
const multiDayPlace = createPlace(testDb, trip.id, { name: 'Hotel Nordic', lat: 60.39, lng: 5.32 });
|
|
createDayAssignment(testDb, day1.id, multiDayPlace.id);
|
|
createDayAssignment(testDb, day2.id, multiDayPlace.id);
|
|
const singleDayPlace = createPlace(testDb, trip.id, { name: 'Fish Market', lat: 60.4, lng: 5.31 });
|
|
createDayAssignment(testDb, day1.id, singleDayPlace.id);
|
|
|
|
await svc.addTripToJourney(journey.id, trip.id, owner.id);
|
|
|
|
// Fill in one of the skeletons, then run the `onPlaceDeleted` hook for its
|
|
// place — the entry must survive, detached and annotated, per that
|
|
// method's has-content branch. Matches JOURNEY-SVC-SKEL-008's own
|
|
// ordering precedent (the hook runs against the still-existing place
|
|
// row — the places domain's own row delete is a separate statement the
|
|
// hook does not itself issue or depend on).
|
|
const filledSkeleton = await pickOne(JourneyEntries, { sourcePlace: singleDayPlace.id }, ['id']) as { id: number };
|
|
await updateRows(await orm(), JourneyEntries, { id: filledSkeleton.id }, { type: 'entry', story: 'Great fish' });
|
|
await svc.onPlaceDeleted(singleDayPlace.id);
|
|
// The place is now actually removed from the trip (the hook already
|
|
// detached the entry, so this cascade touches nothing left referencing it).
|
|
await deleteRows(await orm(), DayAssignments, { place: singleDayPlace.id });
|
|
await deleteRows(await orm(), Places, { id: singleDayPlace.id });
|
|
|
|
return { owner, editor, viewer, journey, trip, day1, day2, multiDayPlace, singleDayPlace };
|
|
}
|
|
|
|
it('the detached, annotated entry survives the place removal (fixture sanity check)', async () => {
|
|
const { journey } = await seedFullJourney();
|
|
|
|
const entries = await entriesRepoDirect.listForJourney(journey.id);
|
|
const detached = entries.find((e) => e.location_name === 'Fish Market');
|
|
|
|
expect(detached).toBeDefined();
|
|
expect(detached!.source_place_id).toBeNull();
|
|
expect(detached!.source_trip_id).toBeNull();
|
|
expect(detached!.type).toBe('entry');
|
|
expect(detached!.story).toContain('Great fish');
|
|
expect(detached!.story).toContain('removed from the trip plan');
|
|
});
|
|
|
|
it('JourneysRepository.listForUser (JG8) matches the legacy statement, full row and order', async () => {
|
|
const { owner } = await seedFullJourney();
|
|
// A second, unrelated journey so the WHERE/ORDER BY have something to discriminate.
|
|
createJourney(testDb, owner.id, { title: 'Second Journey' });
|
|
|
|
const legacy = testDb
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
.prepare(
|
|
`
|
|
SELECT DISTINCT j.*,
|
|
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
|
|
(SELECT COUNT(*) FROM journey_photos jp WHERE jp.journey_id = j.id) as photo_count,
|
|
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
|
|
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
|
|
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
|
|
FROM journeys j
|
|
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
|
|
WHERE j.user_id = ? OR jc.user_id = ?
|
|
ORDER BY j.updated_at DESC
|
|
`,
|
|
)
|
|
.all(owner.id, owner.id, owner.id);
|
|
|
|
const converted = await journeysRepoDirect.listForUser(owner.id);
|
|
expect(converted).toEqual(legacy);
|
|
});
|
|
|
|
it("JourneyContributorsRepository.listForJourney (JG18) matches the legacy statement — owner, editor AND viewer all present", async () => {
|
|
const { journey } = await seedFullJourney();
|
|
|
|
const legacy = testDb
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
.prepare(
|
|
`
|
|
SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar
|
|
FROM journey_contributors jc JOIN users u ON jc.user_id = u.id
|
|
WHERE jc.journey_id = ? ORDER BY jc.added_at
|
|
`,
|
|
)
|
|
.all(journey.id);
|
|
|
|
const converted = await contributorsRepoDirect.listForJourney(journey.id);
|
|
expect(converted).toEqual(legacy);
|
|
expect(converted.map((c) => c.role).sort()).toEqual(['editor', 'owner', 'viewer']);
|
|
});
|
|
|
|
it('JourneyTripsRepository.listForJourney (JG17) matches the legacy statement', async () => {
|
|
const { journey } = await seedFullJourney();
|
|
|
|
const legacy = testDb
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
.prepare(
|
|
`
|
|
SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency,
|
|
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
|
|
FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id
|
|
WHERE jt.journey_id = ? ORDER BY t.start_date ASC
|
|
`,
|
|
)
|
|
.all(journey.id);
|
|
|
|
const converted = await journeyTripsRepoDirect.listForJourney(journey.id);
|
|
expect(converted).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyEntriesRepository.listForJourney (JG14) matches the legacy statement', async () => {
|
|
const { journey } = await seedFullJourney();
|
|
|
|
const legacy = testDb
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC')
|
|
.all(journey.id);
|
|
|
|
const converted = await entriesRepoDirect.listForJourney(journey.id);
|
|
expect(converted).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyTripsRepository.listAssignedPlacesForTrip (JG34) matches the legacy projection, including a place standing on two days (#2329)', async () => {
|
|
const { trip, multiDayPlace } = await seedFullJourney();
|
|
|
|
const legacy = (
|
|
testDb
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
.prepare(
|
|
`
|
|
SELECT p.*, da.id AS assignment_id, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number
|
|
FROM places p
|
|
INNER JOIN day_assignments da ON da.place_id = p.id
|
|
INNER JOIN days d ON da.day_id = d.id
|
|
WHERE p.trip_id = ?
|
|
ORDER BY d.day_number ASC, da.order_index ASC
|
|
`,
|
|
)
|
|
.all(trip.id) as { id: number; name: string; address: string | null; lat: number | null; lng: number | null; place_time: string | null; assignment_id: number; day_date: string | null; assignment_time: string | null }[]
|
|
).map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
address: r.address,
|
|
lat: r.lat,
|
|
lng: r.lng,
|
|
place_time: r.place_time,
|
|
assignment_id: r.assignment_id,
|
|
day_date: r.day_date,
|
|
assignment_time: r.assignment_time,
|
|
}));
|
|
|
|
const converted = await journeyTripsRepoDirect.listAssignedPlacesForTrip(trip.id);
|
|
expect(converted).toEqual(legacy);
|
|
// The multi-day place appears twice — once per assignment — proving #2329's
|
|
// one-skeleton-per-assignment shape survives the conversion at the read layer.
|
|
expect(converted.filter((r) => r.id === multiDayPlace.id)).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// M5d (task-5-review.md) — Task 2's read models had no full-key parity
|
|
// tests: mutations dropping `lng` from JG15 (M19) and from JG19's gallery
|
|
// read (M20) stayed green. Ported from the reviewer's own probe
|
|
// (`scratchpad/r3g/probe/zz-r3g-parity-probe.test.ts`, which the review
|
|
// names as the template), turned from console.log(OK/DIFF) reports into
|
|
// real `toEqual` assertions against the legacy statement run raw on the
|
|
// SAME seeded rows. One journey seeded with every tier this plan's read
|
|
// models touch: dated + undated trips, a multi-day place, an unattached
|
|
// gallery photo, ties on `sort_order`/`taken_at`, a video (excluded from
|
|
// the "photograph per stop" read), a dismissed entry, and a second,
|
|
// unrelated journey's photo (must never leak in).
|
|
describe('Plan 3g Task 2 — repository parity (full-key toEqual against the legacy statement, M5d)', () => {
|
|
async function seedParityJourney() {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: member } = createUser(testDb);
|
|
const d = (n: number) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
|
|
const t1 = createTrip(testDb, owner.id, { title: 'T1', start_date: '2026-01-01', end_date: '2026-01-03' });
|
|
const t2 = createTrip(testDb, owner.id, { title: 'T2 undated' });
|
|
const t3 = createTrip(testDb, member.id, { title: 'T3 recent', start_date: d(10), end_date: d(5) });
|
|
const t4 = createTrip(testDb, owner.id, { title: 'T4 recent', start_date: d(12), end_date: d(2) });
|
|
await insertRow(await orm(), TripMembers, { trip: t3.id, user: owner.id });
|
|
const days = await pickAll(Days, { trip: t1.id }, ['id', 'date'], { date: 'asc' }) as { id: number }[];
|
|
const pA = createPlace(testDb, t1.id, { name: 'A' });
|
|
const pB = createPlace(testDb, t1.id, { name: 'B', lat: 1, lng: 2 });
|
|
createPlace(testDb, t1.id, { name: 'C unassigned' });
|
|
const pD = createPlace(testDb, t2.id, { name: 'D' });
|
|
await updateRows(await orm(), Places, { id: { $in: [pA.id, pD.id] } }, { route_geometry: '[[1,2],[3,4]]', route_color: '#f00' });
|
|
createDayAssignment(testDb, days[1].id, pA.id);
|
|
createDayAssignment(testDb, days[0].id, pA.id);
|
|
createDayAssignment(testDb, days[0].id, pB.id);
|
|
createDayAssignment(testDb, days[2].id, pB.id, { order_index: 0 });
|
|
const place4 = createPlace(testDb, t4.id, { name: 'X' });
|
|
const d4 = createDay(testDb, t4.id, { date: d(3) });
|
|
createDayAssignment(testDb, d4.id, place4.id);
|
|
const journey = createJourney(testDb, owner.id);
|
|
for (const tid of [t1.id, t2.id]) {
|
|
await insertRow(await orm(), JourneyTrips, { journey: journey.id, trip: tid, added_at: 1 });
|
|
await svc.syncTripPlaces(journey.id, tid, owner.id);
|
|
}
|
|
const now = Date.now();
|
|
// Each writer takes its values in the column order the old statements bound them.
|
|
const ins = async (...values: unknown[]) =>
|
|
insertRow(await orm(), JourneyEntries, columnsOf<JourneyEntries>(
|
|
['journey', 'author', 'type', 'title', 'story', 'entry_date', 'entry_time', 'location_name', 'location_lat', 'location_lng', 'sort_order', 'created_at', 'updated_at', 'stats_excluded', 'dismissed'],
|
|
values,
|
|
));
|
|
const e1 = await ins(journey.id, owner.id, 'entry', 'E1', 's', '2026-01-02', '10:00', 'Paris', 48.8, 2.3, 0, now, now, 0, 0);
|
|
const e2 = await ins(journey.id, owner.id, 'entry', 'E2', null, '2026-01-02', '', null, null, null, 0, now, now, 1, 0);
|
|
const e3 = await ins(journey.id, owner.id, 'entry', 'E3 dismissed', null, '2026-01-01', null, 'X', null, null, 1, now, now, 0, 1);
|
|
const e4 = await ins(journey.id, owner.id, 'entry', 'E4', null, '2025-12-31', null, 'Paris', 1, 1, 5, now, now, 0, 0);
|
|
const tp = async (...values: unknown[]) =>
|
|
insertRow(await orm(), TrekPhotos, columnsOf<TrekPhotos>(
|
|
['provider', 'asset_id', 'owner', 'file_path', 'thumbnail_path', 'width', 'height', 'media_type', 'taken_at', 'lat', 'lng'],
|
|
values,
|
|
));
|
|
const gp = async (...values: unknown[]) =>
|
|
insertRow(await orm(), JourneyPhotos, columnsOf<JourneyPhotos>(
|
|
['journey', 'photo', 'caption', 'shared', 'sort_order', 'provider', 'asset_id', 'owner_id', 'created_at'],
|
|
values,
|
|
));
|
|
const jep = async (...values: unknown[]) =>
|
|
insertRow(await orm(), JourneyEntryPhotos, columnsOf<JourneyEntryPhotos>(['entry', 'journeyPhoto', 'sort_order', 'created_at'], values));
|
|
// taken_at/media_type combinations: null, empty string, two ties at the
|
|
// same taken_at (one video — excluded from listFirstPhotoPerEntry), and
|
|
// a null media_type (treated as 'image').
|
|
const specs: [string | null, string | null][] = [
|
|
[null, 'image'], ['', 'image'], ['2026-01-02T08:00:00', 'image'], ['2026-01-02T08:00:00', 'video'],
|
|
[null, 'image'], ['', null], [null, 'image'],
|
|
];
|
|
const gids: number[] = [];
|
|
for (const [i, [taken, mt]] of specs.entries()) {
|
|
const pid = await tp('local', null, owner.id, `f${i}.jpg`, i % 2 ? null : `t${i}.jpg`, 100, i % 3 ? null : 50, mt ?? 'image', taken, i % 2 ? 1.5 : null, i % 2 ? 2.5 : null);
|
|
gids.push(await gp(journey.id, pid, i % 2 ? `cap${i}` : null, i % 2, i % 3, null, null, null, 1700000000000 + (i % 4) * 1000));
|
|
}
|
|
await jep(e1, gids[0], 0, now); await jep(e1, gids[1], 0, now); await jep(e1, gids[2], 0, now);
|
|
await jep(e2, gids[0], 1, now); await jep(e2, gids[3], 0, now);
|
|
await jep(e4, gids[5], 2, now); await jep(e4, gids[4], 2, now);
|
|
await jep(e3, gids[6], 0, now);
|
|
// An unrelated journey's photo — must never leak into this journey's reads.
|
|
const other = createJourney(testDb, member.id);
|
|
const opid = await tp('local', null, member.id, 'o.jpg', null, null, null, 'image', null, null, null);
|
|
await gp(other.id, opid, null, 0, 0, null, null, null, 1);
|
|
|
|
return { owner, member, journey };
|
|
}
|
|
|
|
const LEG = {
|
|
JG15: `SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
|
|
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
|
|
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng FROM journey_entry_photos jep
|
|
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
|
|
JOIN trek_photos tp ON tp.id = gp.photo_id WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`,
|
|
JG19: `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,
|
|
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng FROM journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id WHERE gp.journey_id = ? ${GALLERY_CHRONOLOGICAL_ORDER}`,
|
|
STATS_ENTRIES: `SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id, source_place_id, stats_excluded FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC`,
|
|
STATS_TRIPS: `SELECT t.id, t.title, t.start_date AS start, t.end_date AS end FROM journey_trips jt JOIN trips t ON t.id = jt.trip_id WHERE jt.journey_id = ? ORDER BY t.start_date IS NULL, t.start_date ASC, t.id ASC`,
|
|
STATS_PLACES: `SELECT p.id, p.name, p.lat, p.lng, p.trip_id AS tripId, MIN(d.date) AS day, MIN(da.order_index) AS ord FROM journey_trips jt JOIN places p ON p.trip_id = jt.trip_id LEFT JOIN day_assignments da ON da.place_id = p.id LEFT JOIN days d ON d.id = da.day_id WHERE jt.journey_id = ? GROUP BY p.id ORDER BY day IS NULL, day ASC, ord ASC, p.id ASC`,
|
|
STATS_PLACECOUNT: `SELECT COUNT(*) AS n FROM journey_trips jt JOIN places p ON p.trip_id = jt.trip_id WHERE jt.journey_id = ?`,
|
|
STATS_PHOTOCOUNT: `SELECT COUNT(*) AS n FROM journey_photos WHERE journey_id = ?`,
|
|
STATS_EPHOTOS: `SELECT jep.entry_id AS entryId, gp.photo_id AS photoId FROM journey_entry_photos jep JOIN journey_photos gp ON gp.id = jep.journey_photo_id JOIN trek_photos tp ON tp.id = gp.photo_id WHERE gp.journey_id = ? AND (tp.media_type IS NULL OR tp.media_type = 'image') ORDER BY jep.entry_id ASC, jep.sort_order ASC, gp.sort_order ASC, gp.id ASC`,
|
|
TRACKS: `SELECT DISTINCT p.id AS place_id, p.trip_id, p.name, p.route_color, p.route_geometry FROM journey_entries je JOIN places p ON p.trip_id = je.source_trip_id WHERE je.journey_id = ? AND je.source_trip_id IS NOT NULL AND p.route_geometry IS NOT NULL ORDER BY p.trip_id, p.id`,
|
|
SUGG: `SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image, (SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count FROM trips t LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ? WHERE (t.user_id = ? OR tm.user_id = ?) AND t.end_date IS NOT NULL AND t.end_date >= ? AND t.end_date <= date('now') AND t.id NOT IN (SELECT trip_id FROM journey_trips) ORDER BY t.end_date DESC`,
|
|
PICKER: `SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image, (SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count FROM trips t LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ? WHERE t.user_id = ? OR tm.user_id = ? ORDER BY t.start_date DESC`,
|
|
};
|
|
|
|
/** L8 — JG15's flat row order can differ from legacy on `sort_order` ties across entries (join order vs. the IN subquery); the only consumer groups by entry, so the parity contract is the GROUPED shape, not the flat row order. */
|
|
function groupedByEntry(rows: { entry_id: number; id: number }[]) {
|
|
const out: Record<number, number[]> = {};
|
|
for (const r of rows) (out[r.entry_id] ||= []).push(r.id);
|
|
return out;
|
|
}
|
|
|
|
it('JourneyEntryPhotosRepository.listForJourney (JG15) matches the legacy statement, grouped by entry (L8: flat order may tie-break differently)', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = testDb.prepare(LEG.JG15).all(journey.id) as { entry_id: number; id: number }[];
|
|
const converted = (await entryPhotosRepoDirect.listForJourney(journey.id)) as unknown as { entry_id: number; id: number }[];
|
|
|
|
expect(groupedByEntry(converted)).toEqual(groupedByEntry(legacy));
|
|
// Full-key equality still holds per row (only ORDER may tie-differ) — a
|
|
// sorted-by-id comparison catches a dropped/renamed column.
|
|
const sortById = (rows: { id: number }[]) => [...rows].sort((a, b) => a.id - b.id);
|
|
expect(sortById(converted)).toEqual(sortById(legacy));
|
|
});
|
|
|
|
it('JourneyPhotosRepository.galleryRead (JG19, GALLERY_CHRONOLOGICAL_ORDER) matches the legacy statement exactly, including row order', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = testDb.prepare(LEG.JG19).all(journey.id);
|
|
const converted = await photosRepoDirect.galleryRead(journey.id);
|
|
expect(converted).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyEntriesRepository.listStatsRows (JG64) matches the legacy statement', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = testDb.prepare(LEG.STATS_ENTRIES).all(journey.id);
|
|
expect(await entriesRepoDirect.listStatsRows(journey.id)).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyEntriesRepository.listStatsTrips (JG65) matches the legacy statement, undated trips sorted last', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = testDb.prepare(LEG.STATS_TRIPS).all(journey.id);
|
|
expect(await entriesRepoDirect.listStatsTrips(journey.id)).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyEntriesRepository.listStatsPlaces (JG66) matches the legacy statement, one row per place at its earliest day', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = testDb.prepare(LEG.STATS_PLACES).all(journey.id);
|
|
expect(await entriesRepoDirect.listStatsPlaces(journey.id)).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyEntriesRepository.countStatsPlaces (JG67) matches the legacy count', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = (testDb.prepare(LEG.STATS_PLACECOUNT).get(journey.id) as { n: number }).n;
|
|
expect(await entriesRepoDirect.countStatsPlaces(journey.id)).toBe(legacy);
|
|
});
|
|
|
|
it('JourneyPhotosRepository.countForJourney (JG68) matches the legacy count', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = (testDb.prepare(LEG.STATS_PHOTOCOUNT).get(journey.id) as { n: number }).n;
|
|
expect(await photosRepoDirect.countForJourney(journey.id)).toBe(legacy);
|
|
});
|
|
|
|
it('JourneyEntryPhotosRepository.listFirstPhotoPerEntry (JG69) matches the legacy statement, videos excluded', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = testDb.prepare(LEG.STATS_EPHOTOS).all(journey.id);
|
|
expect(await entryPhotosRepoDirect.listFirstPhotoPerEntry(journey.id)).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyEntriesRepository.listTracksSource (JG63) matches the legacy statement', async () => {
|
|
const { journey } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = testDb.prepare(LEG.TRACKS).all(journey.id);
|
|
expect(await entriesRepoDirect.listTracksSource(journey.id)).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyEntriesRepository.listSuggestedTrips (JG120) matches the legacy statement', async () => {
|
|
const { owner } = await seedParityJourney();
|
|
const since = new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0];
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacy = testDb.prepare(LEG.SUGG).all(owner.id, owner.id, owner.id, since);
|
|
expect(await entriesRepoDirect.listSuggestedTrips(owner.id, since, todayUtc())).toEqual(legacy);
|
|
});
|
|
|
|
it('JourneyEntriesRepository.listUserTripsPicker (JG121) matches the legacy statement — owner and a member', async () => {
|
|
const { owner, member } = await seedParityJourney();
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacyOwner = testDb.prepare(LEG.PICKER).all(owner.id, owner.id, owner.id);
|
|
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
|
const legacyMember = testDb.prepare(LEG.PICKER).all(member.id, member.id, member.id);
|
|
expect(await entriesRepoDirect.listUserTripsPicker(owner.id)).toEqual(legacyOwner);
|
|
expect(await entriesRepoDirect.listUserTripsPicker(member.id)).toEqual(legacyMember);
|
|
});
|
|
});
|
|
|
|
describe('Plan 3g Task 1 — mutation proofs (R5/R7)', () => {
|
|
it("JG119: JourneyContributorsRepository.deleteNonOwner never removes the owner row, even targeted at the owner's own id", async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const changed = await contributorsRepoDirect.deleteNonOwner(journey.id, owner.id);
|
|
|
|
expect(changed).toBe(0);
|
|
const row = await pickOne(JourneyContributors, { journey: journey.id, user: owner.id }, ['role']) as { role: string } | undefined;
|
|
expect(row?.role).toBe('owner');
|
|
});
|
|
|
|
it("JG119 mutation check: the statement WITHOUT the role != 'owner' guard WOULD have removed the same row — proves the test above is not vacuous", async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
// The exact statement JG119's guard replaces (no `role != 'owner'` clause).
|
|
// test-sql-allow: the legacy statement without the guard is the oracle the guarded delete is measured against.
|
|
const res = testDb.prepare('DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ?').run(journey.id, owner.id);
|
|
expect(res.changes).toBe(1);
|
|
});
|
|
|
|
it('AP1: a non-member caller is refused (no insert, no broadcast) — and stubbing findAccessible to always succeed removes that refusal', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const foreignTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip" });
|
|
const broadcastSpy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {});
|
|
|
|
try {
|
|
const refused = await svc.addTripToJourney(journey.id, foreignTrip.id, user.id);
|
|
|
|
expect(refused).toBe(false);
|
|
expect(broadcastSpy).not.toHaveBeenCalled();
|
|
const link = await one(JourneyTrips, { journey: journey.id, trip: foreignTrip.id });
|
|
expect(link).toBeUndefined();
|
|
|
|
// Mutation check: stub AP1's own primitive to always report access — the
|
|
// exact guard-bypass AP1 exists to close (plan3g-inputs.md §0's
|
|
// "the REST route never did" history). Restored in `finally`.
|
|
const findAccessibleSpy = vi
|
|
.spyOn(tripsRepoDirect, 'findAccessible')
|
|
.mockResolvedValue({ id: foreignTrip.id, user_id: stranger.id, currency: null });
|
|
try {
|
|
const bypassed = await svc.addTripToJourney(journey.id, foreignTrip.id, user.id);
|
|
|
|
// With the guard's own primitive stubbed truthy, the SAME call now
|
|
// succeeds — proving the refusal above genuinely depends on AP1's
|
|
// real `findAccessible` check, not on some other guard.
|
|
expect(bypassed).toBe(true);
|
|
const linkAfterBypass = await one(JourneyTrips, { journey: journey.id, trip: foreignTrip.id });
|
|
expect(linkAfterBypass).toBeDefined();
|
|
} finally {
|
|
findAccessibleSpy.mockRestore();
|
|
}
|
|
} finally {
|
|
broadcastSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Plan 3g Task 1 — reconcileTripSkeletons: all three branches in one call', () => {
|
|
it('inserts a newly-assigned place, claims an unclaimed pre-assignment-link row, and drops a gone assignment, simultaneously', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Three Branches', start_date: '2026-06-01', end_date: '2026-06-03' });
|
|
const day1 = createDay(testDb, trip.id, { date: '2026-06-01' });
|
|
const day2 = createDay(testDb, trip.id, { date: '2026-06-02' });
|
|
|
|
const keepPlace = createPlace(testDb, trip.id, { name: 'Keep' });
|
|
const dropPlace = createPlace(testDb, trip.id, { name: 'Drop' });
|
|
const claimPlace = createPlace(testDb, trip.id, { name: 'Claim' });
|
|
|
|
createDayAssignment(testDb, day1.id, keepPlace.id);
|
|
const dropAssignment = createDayAssignment(testDb, day1.id, dropPlace.id);
|
|
await svc.addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// An "unclaimed" pre-assignment-link skeleton for claimPlace — the shape
|
|
// a backfill (or a row from before the assignment link existed) leaves
|
|
// behind: `source_place_id` set, `source_assignment_id` NULL.
|
|
const claimAssignment = createDayAssignment(testDb, day2.id, claimPlace.id);
|
|
await insertRow(await orm(), JourneyEntries, { journey: journey.id, sourceTrip: trip.id, sourcePlace: claimPlace.id, source_assignment_id: null, author: user.id, type: 'skeleton', title: claimPlace.name, entry_date: '2026-06-02', sort_order: 0, created_at: Date.now(), updated_at: Date.now() });
|
|
|
|
// Change the plan three ways at once: drop dropPlace's assignment (branch
|
|
// 3), leave keepPlace untouched (a control — no branch should fire for
|
|
// it), and add a brand-new place to day 2 (branch 1). claimPlace's day-2
|
|
// assignment (already on the plan, above) is what branch 2 claims.
|
|
await deleteRows(await orm(), DayAssignments, { id: dropAssignment.id });
|
|
const newPlace = createPlace(testDb, trip.id, { name: 'New' });
|
|
createDayAssignment(testDb, day2.id, newPlace.id);
|
|
|
|
await svc.reconcileTripSkeletons(trip.id);
|
|
|
|
const entries = await findRows(await orm(), JourneyEntries, { journey: journey.id }) as {
|
|
source_place_id: number | null;
|
|
source_assignment_id: number | null;
|
|
type: string;
|
|
}[];
|
|
|
|
// Branch 1 — insert: the brand-new place got a skeleton.
|
|
expect(entries.some((e) => e.source_place_id === newPlace.id && e.type === 'skeleton')).toBe(true);
|
|
|
|
// Branch 2 — claim: the unclaimed row now carries claimAssignment's id,
|
|
// not a second, freshly-inserted row.
|
|
const claimed = entries.filter((e) => e.source_place_id === claimPlace.id);
|
|
expect(claimed).toHaveLength(1);
|
|
expect(claimed[0].source_assignment_id).toBe(claimAssignment.id);
|
|
|
|
// Branch 3 — drop: dropPlace's empty skeleton is gone outright (no story, no photos).
|
|
expect(entries.some((e) => e.source_place_id === dropPlace.id)).toBe(false);
|
|
|
|
// Control: keepPlace's already-synced skeleton is untouched — the fixture
|
|
// doesn't over-fire a branch that shouldn't apply to it.
|
|
expect(entries.some((e) => e.source_place_id === keepPlace.id)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// Plan 3g Task 2 — the four `uow.transactional` blocks (JG-TX1..4) and
|
|
// JG112's mutation proof. Appended per R9 (Task 1 owns the construction/
|
|
// wrapping pattern above; this only adds new coverage).
|
|
describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('JG-TX1 (addPhoto): a failure after ensureInGallery\'s insert rolls the insert back — no orphan journey_photos row', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const entry = await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-01' });
|
|
|
|
vi.spyOn(photosRepoDirect, 'findIdByJourneyAndPhoto').mockRejectedValueOnce(new Error('boom'));
|
|
|
|
await expect(svc.addPhoto(entry!.id, user.id, 'journey/tx1.jpg')).rejects.toThrow('boom');
|
|
|
|
const rows = await findRows(await orm(), JourneyPhotos, { journey: journey.id });
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it('JG-TX2 (addProviderPhoto): a failure after ensureInGallery\'s insert rolls the insert back — no orphan journey_photos row', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const entry = await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-01' });
|
|
|
|
vi.spyOn(photosRepoDirect, 'findIdByJourneyAndPhoto').mockRejectedValueOnce(new Error('boom'));
|
|
|
|
await expect(svc.addProviderPhoto(entry!.id, user.id, 'immich', 'asset-tx2')).rejects.toThrow('boom');
|
|
|
|
const rows = await findRows(await orm(), JourneyPhotos, { journey: journey.id });
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it('JG-TX3 (addProviderPhotoToGallery): a failure after ensureInGallery\'s insert rolls the insert back — no orphan journey_photos row', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
|
|
vi.spyOn(photosRepoDirect, 'findIdByJourneyAndPhoto').mockRejectedValueOnce(new Error('boom'));
|
|
|
|
await expect(svc.addProviderPhotoToGallery(journey.id, user.id, 'immich', 'asset-tx3')).rejects.toThrow('boom');
|
|
|
|
const rows = await findRows(await orm(), JourneyPhotos, { journey: journey.id });
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it('JG-TX4 (reorderEntries): a failure touching the journey after the sort_order loop rolls EVERY sort_order write back, not just the last one', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const e1 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' });
|
|
const e2 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' });
|
|
const e3 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' });
|
|
await updateRows(await orm(), JourneyEntries, { id: { $in: [e1.id, e2.id, e3.id] } }, { sort_order: 0 });
|
|
|
|
vi.spyOn(journeysRepoDirect, 'updateFields').mockRejectedValueOnce(new Error('boom'));
|
|
|
|
await expect(svc.reorderEntries(journey.id, user.id, [e3.id, e1.id, e2.id])).rejects.toThrow('boom');
|
|
|
|
// None of the three sort_order writes the loop made before the journey
|
|
// touch failed should have survived the rollback — every entry is still
|
|
// at its pre-call sort_order (0), not just the last one in the loop.
|
|
const rows = await pickAll(JourneyEntries, { journey: journey.id }, ['id', 'sort_order'], { id: 'asc' }) as {
|
|
id: number;
|
|
sort_order: number;
|
|
}[];
|
|
for (const r of rows) expect(r.sort_order).toBe(0);
|
|
});
|
|
|
|
it('JG-TX5 (createJourney): a failing owner row rolls the journey back, so no journey is left without an owner', async () => {
|
|
const { user } = createUser(testDb);
|
|
vi.spyOn(contributorsRepoDirect, 'insertOwner').mockRejectedValueOnce(new Error('boom'));
|
|
|
|
await expect(svc.createJourney(user.id, { title: 'Orphan' })).rejects.toThrow('boom');
|
|
|
|
expect(await pickAll(Journeys, { title: 'Orphan' }, ['id'])).toEqual([]);
|
|
});
|
|
|
|
it('JG-TX6 (createJourney): a failure after the trip is linked rolls back the journey and its owner, and nothing is broadcast', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-03-15', end_date: '2026-03-16' });
|
|
vi.spyOn(journeysRepoDirect, 'updateCoverImage').mockRejectedValueOnce(new Error('boom'));
|
|
await updateRows(await orm(), Trips, { id: trip.id }, { cover_image: '/uploads/covers/x.jpg' });
|
|
const broadcast = vi.spyOn(svc, 'broadcastJourneyEvent');
|
|
|
|
await expect(svc.createJourney(user.id, { title: 'Half', trip_ids: [trip.id] })).rejects.toThrow('boom');
|
|
|
|
expect(await pickAll(Journeys, { title: 'Half' }, ['id'])).toEqual([]);
|
|
expect(await pickAll(JourneyContributors, { user: user.id }, ['journey_id'])).toEqual([]);
|
|
expect(broadcast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('JG-TX7 (addPhoto): a failing entry link rolls back the photo and its gallery row as well', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const entry = await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-01' });
|
|
|
|
vi.spyOn(entryPhotosRepoDirect, 'insertIgnore').mockRejectedValueOnce(new Error('boom'));
|
|
|
|
await expect(svc.addPhoto(entry!.id, user.id, 'journey/tx7.jpg')).rejects.toThrow('boom');
|
|
|
|
expect(await findRows(await orm(), JourneyPhotos, { journey: journey.id })).toEqual([]);
|
|
expect(await findRows(await orm(), TrekPhotos, { file_path: 'journey/tx7.jpg' })).toEqual([]);
|
|
expect(await pickOne(JourneyEntries, { id: entry!.id }, ['type'])).toEqual({ type: 'entry' });
|
|
});
|
|
});
|
|
|
|
describe('Plan 3g Task 2 — JG112 mutation proof (sort_order table targeting)', () => {
|
|
it('updatePhoto\'s sort_order write lands on journey_entry_photos (entry-scoped), never journey_photos (the gallery row\'s own, unrelated sort_order)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = await svc.createJourney(user.id, { title: 'J' });
|
|
const entry = await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-01' });
|
|
const [photo] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/jg112.jpg' }]);
|
|
await svc.linkPhotoToEntry(entry!.id, photo.id, user.id);
|
|
|
|
const galleryBefore = (
|
|
await pickOne(JourneyPhotos, { id: photo.id }, ['sort_order']) as { sort_order: number }
|
|
).sort_order;
|
|
|
|
const updated = await svc.updatePhoto(photo.id, user.id, { sort_order: 7 });
|
|
|
|
// The junction row (this entry's own view of the photo's position) moved.
|
|
const junctionRow = await pickOne(JourneyEntryPhotos, { journeyPhoto: photo.id }, ['sort_order']) as { sort_order: number };
|
|
expect(junctionRow.sort_order).toBe(7);
|
|
expect(updated!.sort_order).toBe(7);
|
|
|
|
// A copy-paste bug swapping JG111/JG112's target tables would leave the
|
|
// junction row untouched and instead bump `journey_photos.sort_order`
|
|
// (the gallery's OWN, unrelated ordering column, same name, different
|
|
// table) to 7 — asserting it is STILL the pre-call value is what a
|
|
// table-swap regression would fail.
|
|
const galleryAfter = (
|
|
await pickOne(JourneyPhotos, { id: photo.id }, ['sort_order']) as { sort_order: number }
|
|
).sort_order;
|
|
expect(galleryAfter).toBe(galleryBefore);
|
|
});
|
|
});
|
|
|
|
describe('journeyIdOfEntry', () => {
|
|
it('JOURNEY-SVC-ENTRY-JOURNEY-001: names the journey an entry sits in, and null for an entry that does not exist', async () => {
|
|
// The capture refresh (#1587) only knows the entry the photos went to and has
|
|
// to tell the journey they belong to.
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
|
|
|
expect(await svc.journeyIdOfEntry(entry.id)).toBe(journey.id);
|
|
expect(await svc.journeyIdOfEntry(999999)).toBeNull();
|
|
});
|
|
});
|