Merge remote-tracking branch 'origin/feat/storage-driver' into feat/storage-driver

This commit is contained in:
jubnl
2026-08-20 09:14:25 +02:00
146 changed files with 31120 additions and 852 deletions
+109
View File
@@ -0,0 +1,109 @@
/**
* Turn hand-built spreads into auto-layout templates.
*
* Reads the book from a journey and writes the spreads out as data: every
* measurement divided by the page it was drawn on, so the same template lays
* out on an A4 landscape book as it does on the 210mm square it was designed
* for. Run from server/, output goes to the client.
*/
const Database = require('better-sqlite3')
const fs = require('fs')
const JOURNEY = Number(process.argv[2] || 7)
const OUT = process.argv[3] || '../client/src/components/Studio/bookTemplates.data.ts'
const db = new Database('data/travel.db', { readonly: true })
const row = db.prepare('SELECT document FROM journey_books WHERE journey_id = ?').get(JOURNEY)
db.close()
if (!row) throw new Error(`no book on journey ${JOURNEY}`)
/*
* Through the contract on the way in, so a template carries every field the
* document has — including any the schema gained since the spread was drawn.
* Without it a template written today is missing tomorrow's defaults and does
* not typecheck as a BookElement.
*/
const { normalizeBookDocument } = require('@trek/shared')
const doc = normalizeBookDocument(JSON.parse(row.document))
const PW = doc.page.pageWidth
const PH = doc.page.pageHeight
const r4 = n => Math.round(n * 10000) / 10000
/** A frame as fractions of one page's width and of the page height. */
function relFrame(f) {
return { x: r4(f.x / PW), y: r4(f.y / PH), w: r4(f.w / PW), h: r4(f.h / PH) }
}
/** Type size as a fraction of the page height, so it scales with the sheet. */
const relSize = n => r4(n / PH)
function element(e) {
// The id travels with the template and is replaced when it is applied —
// dropping it here would leave the element off the BookElement union.
const out = { ...e, frame: relFrame(e.frame) }
if (typeof out.size === 'number') out.size = relSize(out.size)
if (typeof out.radius === 'number') out.radius = r4(out.radius / PW)
if (typeof out.strokeWidth === 'number') out.strokeWidth = r4(out.strokeWidth / PW)
return out
}
const spreads = doc.spreads
.map((s, i) => ({ index: i, role: s.role, background: s.background, elements: s.elements.map(element) }))
.filter(s => s.role === 'inner')
const body = spreads.map((s, i) => ` {
id: 'ref-${i + 1}',
background: ${JSON.stringify(s.background)},
elements: ${JSON.stringify(s.elements, null, 6).replace(/\n/g, '\n ')},
},`).join('\n')
const file = `import type { BookElement } from '@trek/shared'
/**
* The spread templates, as data.
*
* ── Where these come from ────────────────────────────────────────────────
*
* Not from this file's author. They were built by hand in Studio and read back
* out of the document — which is the point: what a page should look like is a
* matter of taste, and taste is not something a layout function arrives at by
* reasoning. The editor is the design tool, and this is its output.
*
* To add one: build the spread in Studio, then run
*
* node scripts/extract-templates.cjs <journeyId>
*
* from server/ and commit what it writes here.
*
* ── Why the numbers are fractions ────────────────────────────────────────
*
* Every frame is divided by the page it was drawn on — x and w by one page's
* width, y and h by its height — and type sizes by the page height. A template
* built on a 210mm square therefore lays out on an A4 landscape book without
* being redrawn, which it would otherwise have to be for every trim size the
* picker offers.
*
* ── What gets filled in ──────────────────────────────────────────────────
*
* Elements carrying a \`binding\` take their text from the entry; empty photo
* frames take its photographs in order; the day, coordinate and country marks
* take what the entry knows about its stop. Everything else — the panels, the
* rules, the shapes that run off the page — is the design, and is kept as it
* was drawn.
*/
export interface SpreadTemplate {
id: string
background: string | null
/** Frames and sizes are fractions — see the note above. */
elements: BookElement[]
}
export const SPREAD_TEMPLATES: SpreadTemplate[] = [
${body}
]
`
fs.writeFileSync(OUT, file)
console.log(`wrote ${spreads.length} templates to ${OUT}`)
+38 -6
View File
@@ -3981,17 +3981,49 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0');
}
},
/*
* TREK Studio books (#1973).
*
* One row per book, the document itself stored as JSON. A book is a
* document rather than a graph of records: the editor loads it whole, the
* renderer prints it whole, and nothing ever queries "which books contain a
* heart-shaped frame". Normalising spreads and elements into tables would
* buy queries nobody makes and cost a join on every open plus a schema
* migration for every new element kind.
*
* `updated_at` and `version` are what make concurrent editing possible:
* the version increments on every write, and a client that saves against a
* version it did not read gets told rather than silently overwriting.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS journey_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
document TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_journey_books_journey ON journey_books(journey_id);
`);
},
// Storage slice 2 — collab note attachments historically stored 'files/<name>'
// in trip_files.filename while the file manager stored bare names in the same
// column; the storage layer addresses objects as category + bare name, so
// normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of
// 'files/'. The LIKE guard makes it a no-op on already-bare rows.
// Appended LAST (again): this slot moved below the three dev slots above in
// the dev merge — the array is index-addressed against schema_version, and
// the dev slots keep the indices they shipped with. A database that already
// ran this migration at its pre-merge index replays it harmlessly (the LIKE
// guard) but SKIPS the #1939 key promotion — acceptable only because this
// branch was never published; do not repeat this with a released slot.
//
// Appended LAST again, and for the same reason as the note this replaces:
// the array is index-addressed against schema_version, so a slot that has
// shipped in dev keeps the index it shipped with and anything from this
// branch goes after it. A database that already ran this migration at its
// pre-merge index replays it harmlessly (the LIKE guard) but skips whatever
// now occupies that index. Acceptable only because this branch has never
// been published; never do this with a released slot.
() => {
db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'");
},
@@ -0,0 +1,218 @@
import { Injectable } from '@nestjs/common';
import type { BookRecord, BookSummary } from '@trek/shared';
import { normalizeBookDocument } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { JourneyDomainService } from './journey-domain.service';
/**
* Storing TREK Studio books.
*
* ── Access ───────────────────────────────────────────────────────────────
*
* A book belongs to its journey, so it inherits the journey's access exactly —
* every contributor can open and edit it. There is no separate book permission
* and there should not be: a second access model over the same object is how
* two rules end up disagreeing about who may do what.
*
* ── Concurrency ──────────────────────────────────────────────────────────
*
* Optimistic, on a version column. Every save states the version it was made
* against; the update only lands if that is still the current one, and the
* whole thing is one statement so two saves arriving together cannot both
* decide they are first. The loser is told, and told *with* the current record,
* so it can show the other version rather than only announcing one exists.
*
* The alternative — last write wins — is not a simpler version of this. It is
* the same thing with the failure moved somewhere nobody sees it.
*/
@Injectable()
export class JourneyBookService {
constructor(
private readonly db: DatabaseService,
private readonly journey: JourneyDomainService,
) {}
/** Null when the journey does not exist or the user cannot reach it. */
private canAccess(journeyId: number, userId: number): boolean {
return !!this.journey.canAccessJourney(journeyId, userId);
}
/**
* Whether this user may open the journey's book at all.
*
* Public so the controller can tell "no book yet" from "no journey" without
* running a query built for something else.
*/
canOpen(journeyId: number, userId: number): boolean {
return this.canAccess(journeyId, userId);
}
private toRecord(row: BookRow): BookRecord {
return {
id: row.id,
journeyId: row.journey_id,
title: row.title,
version: row.version,
updatedAt: row.updated_at,
updatedBy: row.updated_by,
// Never thrown at the caller: a document that cannot be parsed still has
// to open, or a single drifted field would lock someone out of their own
// book. normalizeBookDocument drops what it cannot read.
document: normalizeBookDocument(safeParse(row.document)),
};
}
listBooks(journeyId: number, userId: number): BookSummary[] | null {
if (!this.canAccess(journeyId, userId)) return null;
const rows = this.db
.prepare(`
SELECT id, journey_id, title, version, updated_at, updated_by
FROM journey_books
WHERE journey_id = ?
ORDER BY updated_at DESC, id DESC
`)
.all(journeyId) as Omit<BookRow, 'document'>[];
return rows.map(r => ({
id: r.id,
journeyId: r.journey_id,
title: r.title,
version: r.version,
updatedAt: r.updated_at,
updatedBy: r.updated_by,
}));
}
/**
* The journey's book.
*
* One per journey for now — the table allows more, because a second book of
* the same trip is an obvious thing to want and adding a column later is
* harder than not needing to.
*/
getBook(journeyId: number, userId: number): BookRecord | null {
if (!this.canAccess(journeyId, userId)) return null;
const row = this.db
.prepare(`
SELECT id, journey_id, title, document, version, updated_at, updated_by
FROM journey_books
WHERE journey_id = ?
ORDER BY id ASC
LIMIT 1
`)
.get(journeyId) as BookRow | undefined;
return row ? this.toRecord(row) : null;
}
/**
* Create or update, against a version.
*
* Returns the saved record, or `{ conflict }` when the base version has
* moved. Throwing would be the obvious shape and the wrong one: a conflict is
* an ordinary outcome of two people working, not an exception.
*/
saveBook(
journeyId: number,
userId: number,
input: { title: string; document: unknown; baseVersion?: number },
): { record: BookRecord } | { conflict: BookRecord } | null {
if (!this.canAccess(journeyId, userId)) return null;
const document = JSON.stringify(normalizeBookDocument(input.document));
const existing = this.db
.prepare('SELECT id, version FROM journey_books WHERE journey_id = ? ORDER BY id ASC LIMIT 1')
.get(journeyId) as { id: number; version: number } | undefined;
if (!existing) {
const result = this.db
.prepare(`
INSERT INTO journey_books (journey_id, title, document, version, created_by, updated_by, updated_at)
VALUES (?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)
`)
.run(journeyId, input.title, document, userId, userId);
return { record: this.byId(Number(result.lastInsertRowid))! };
}
/*
* The version goes in the WHERE clause rather than being checked first.
* Read-then-write leaves a window between the two in which another save can
* land, and SQLite gives no guarantee across two statements — one UPDATE
* that matches on the version cannot lose that race with itself.
*
* A save with no base version is a first write from a client that has not
* loaded one; it is allowed to take the current version, since refusing it
* would break "open Studio and start editing" for the second person to
* arrive.
*/
const base = input.baseVersion ?? existing.version;
const result = this.db
.prepare(`
UPDATE journey_books
SET title = ?, document = ?, version = version + 1,
updated_by = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND version = ?
`)
.run(input.title, document, userId, existing.id, base);
if (result.changes === 0) {
return { conflict: this.byId(existing.id)! };
}
return { record: this.byId(existing.id)! };
}
deleteBook(journeyId: number, userId: number): boolean | null {
if (!this.canAccess(journeyId, userId)) return null;
const result = this.db
.prepare('DELETE FROM journey_books WHERE journey_id = ?')
.run(journeyId);
return result.changes > 0;
}
/**
* Tell the other editors the book moved on.
*
* The version travels, not the document. Everyone with it open needs to know
* theirs is behind — one integer — and can ask for the rest if they want it.
* Broadcasting a few hundred kilobytes of JSON on every autosave would make
* the notification the size of the thing being edited.
*
* The saver is excluded by socket id, the same way every other TREK mutation
* does it, so the client that just saved does not process its own change.
*/
broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) {
this.journey.broadcastJourneyEvent(
journeyId,
'journey:book:saved',
{ version: record.version, savedBy: userId },
socketId,
);
}
private byId(id: number): BookRecord | null {
const row = this.db
.prepare(`
SELECT id, journey_id, title, document, version, updated_at, updated_by
FROM journey_books WHERE id = ?
`)
.get(id) as BookRow | undefined;
return row ? this.toRecord(row) : null;
}
}
interface BookRow {
id: number;
journey_id: number;
title: string;
document: string;
version: number;
updated_at: string | null;
updated_by: number | null;
}
/** JSON that will not parse is an empty document, never an exception. */
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
@@ -3,8 +3,44 @@ import { avatarUrl } from '../common/avatarUrl';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import type { JourneyTrack, TrekWsUserEventName } from '@trek/shared';
import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { getCountryFromCoords } from '../atlas/atlas-geo';
import { computeJourneyStats, type StatsInputPoint } from './journey-stats';
/**
* English country names for whatever codes a journey turned up.
*
* `Intl.DisplayNames` rather than a bundled table or the admin-0 properties:
* Node has the CLDR data already, the names it gives are the ones every other
* piece of software shows, and parsing 30MB of boundary GeoJSON to read a
* `NAME` field would be an absurd way to learn that IS is Iceland.
*
* English on purpose — the client re-resolves these into the reader's language
* when it places the element, and a book needs the country's name in the
* language the book is written in, not the language of whoever's server it is.
*/
function regionNames(): Intl.DisplayNames | null {
try {
return new Intl.DisplayNames(['en'], { type: 'region' });
} catch {
// A Node built without full ICU has no region names. Codes are a poor
// label but a working one, and a book page is not worth a 500 over.
return null;
}
}
function countryNamesFor(points: { country: string | null }[]): Record<string, string> {
const out: Record<string, string> = {};
const display = regionNames();
for (const p of points) {
if (!p.country) continue;
const code = p.country.toUpperCase();
if (out[code]) continue;
out[code] = (display?.of(code) ?? code) || code;
}
return out;
}
@@ -50,7 +86,14 @@ export class JourneyDomainService {
private broadcastJourneyEvent(
/**
* Tell everyone on a journey that something changed.
*
* Public because the book service needs the same audience — who can see a
* journey is one question with one answer, and a second copy of this walk
* over contributors and owner would be a second answer waiting to disagree.
*/
broadcastJourneyEvent(
journeyId: number,
// Typed against the shared WS registry rather than left as `string`: the
// legacy broadcastToUser took anything, so a typo'd event name shipped as a
@@ -813,6 +856,138 @@ export class JourneyDomainService {
return tracks;
}
/**
* What this journey adds up to — the figures TREK Studio prints on a page.
*
* Derived, never stored: the trips added to the journey carry the places and
* the dates, the journey carries the entries and the photographs, and the
* numbers fall out of those. Nothing here is a column anyone has to keep in
* step.
*
* ── Where the route comes from ─────────────────────────────────────────
*
* The journey's own entries when they carry coordinates, because an entry is
* something that happened somewhere on a day and that is exactly what a route
* is made of. A journey assembled from trips that nobody has written up yet
* has no such entries, and falls back to the places on those trips — which is
* the same route, told by the itinerary instead of by the traveller.
*
* Mixing the two would double back on itself: an entry written about a place
* that is also on the trip would be two stops at one location, and the
* distance would count the leg twice.
*
* ── Where the countries come from ──────────────────────────────────────
*
* `place_regions` first: Atlas already resolves a place to its country and
* caches it there, and reading a cache beats repeating a point-in-polygon
* test against 4MB of boundaries. What is missing falls through to
* `getCountryFromCoords`, which is the same answer computed rather than
* remembered. That import is a plain function from a module built to have no
* DI and no Nest edges, so it costs this domain no coupling to Atlas.
*/
journeyStats(journeyId: number, userId: number): JourneyStats | null {
if (!this.canAccessJourney(journeyId, userId)) return null;
const entryRows = this.db.prepare(`
SELECT id, title, location_name, location_lat, location_lng, entry_date
FROM journey_entries
WHERE journey_id = ?
ORDER BY entry_date ASC, sort_order ASC, id ASC
`).all(journeyId) as {
id: number; title: string | null; location_name: string | null;
location_lat: number | null; location_lng: number | null; entry_date: string | null;
}[];
const tripDates = this.db.prepare(`
SELECT 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 = ?
`).all(journeyId) as { start: string | null; end: string | null }[];
// Ordered the way the trip is walked: by day, then by the order within it.
// A place can be assigned to more than one day (a hotel across three nights
// is one place, three assignments), so the join is aggregated back down to
// one row per place at its earliest day — otherwise the route would visit
// the hotel three times and the distance would count those legs.
const placeRows = this.db.prepare(`
SELECT p.id, p.name, p.lat, p.lng,
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
`).all(journeyId) as {
id: number; name: string | null; lat: number | null; lng: number | null;
day: string | null; ord: number | null;
}[];
const placeCount = this.db.prepare(`
SELECT COUNT(*) AS n FROM journey_trips jt
JOIN places p ON p.trip_id = jt.trip_id
WHERE jt.journey_id = ?
`).get(journeyId) as { n: number };
const photoCount = this.db
.prepare('SELECT COUNT(*) AS n FROM journey_photos WHERE journey_id = ?')
.get(journeyId) as { n: number };
// The cached country per place, for whichever of them Atlas has seen.
const cachedCountry = new Map<number, string>();
const placeIds = placeRows.map(p => p.id);
for (let i = 0; i < placeIds.length; i += 400) {
const chunk = placeIds.slice(i, i + 400);
if (!chunk.length) continue;
const rows = this.db
.prepare(`SELECT place_id, country_code FROM place_regions WHERE place_id IN (${chunk.map(() => '?').join(',')})`)
.all(...chunk) as { place_id: number; country_code: string }[];
for (const r of rows) if (r.country_code) cachedCountry.set(r.place_id, r.country_code.toUpperCase());
}
const countryAt = (lat: number, lng: number, placeId?: number): string | null => {
if (placeId != null) {
const cached = cachedCountry.get(placeId);
if (cached) return cached;
}
return getCountryFromCoords(lat, lng);
};
const fromEntries: StatsInputPoint[] = entryRows
.filter(e => Number.isFinite(e.location_lat) && Number.isFinite(e.location_lng))
.map(e => ({
lat: e.location_lat as number,
lng: e.location_lng as number,
label: e.title || e.location_name || '',
date: e.entry_date ?? null,
country: countryAt(e.location_lat as number, e.location_lng as number),
}));
const points: StatsInputPoint[] = fromEntries.length
? fromEntries
: placeRows
.filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))
.map(p => ({
lat: p.lat as number,
lng: p.lng as number,
label: p.name || '',
date: p.day ?? null,
country: countryAt(p.lat as number, p.lng as number, p.id),
}));
return computeJourneyStats({
journeyId,
points,
entries: entryRows.length,
photos: photoCount?.n ?? 0,
places: placeCount?.n ?? 0,
tripDates,
countryNames: countryNamesFor(points),
});
}
listEntries(journeyId: number, userId: number) {
if (!this.canAccessJourney(journeyId, userId)) return null;
@@ -65,6 +65,9 @@ export class JourneyPublicController {
throw new HttpException({ error: 'Not found' }, 404);
}
res.set('Cache-Control', 'public, max-age=86400');
// The storage layer serves it, whichever driver is configured. dev's fix
// for the absolute-path 404 under the Nest ExpressAdapter does not apply
// here any more: nothing on this path calls sendFile.
await this.storage.sendToResponse('journey', name, res);
return;
}
+205
View File
@@ -0,0 +1,205 @@
import type { JourneyStats, JourneyStatsCountry, JourneyStatsPoint } from '@trek/shared';
/**
* What a journey adds up to — the arithmetic, with no database and no Nest.
*
* Plain module on the same footing as `atlas-geo.ts` and `maps.helpers.ts`: the
* service fetches rows and hands them here, which keeps every rule in this file
* testable against a literal array instead of a fixture database. It is also
* the only place these definitions live, so "what counts as a day" has one
* answer rather than one per caller.
*/
/** Mean Earth radius in metres (IUGG). */
const EARTH_RADIUS = 6371008.8;
/** How many stops a printed route can usefully resolve. */
export const MAX_ROUTE_POINTS = 400;
export interface StatsInputPoint {
lat: number;
lng: number;
label: string;
/** ISO date, when the stop has one. */
date: string | null;
country: string | null;
}
const rad = (deg: number) => (deg * Math.PI) / 180;
/**
* Great-circle metres between two coordinates.
*
* Not a driving distance: there is no routing engine here, consecutive stops
* are not necessarily joined by a road, and a book that prints a road distance
* it never measured is worse than one that says "as the crow flies".
*/
export function haversine(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const dLat = rad(b.lat - a.lat);
const dLng = rad(b.lng - a.lng);
const s = Math.sin(dLat / 2) ** 2
+ Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.min(1, Math.sqrt(s)));
}
/** Metres along the route, in the order given. */
export function routeDistance(points: { lat: number; lng: number }[]): number {
let total = 0;
for (let i = 1; i < points.length; i++) total += haversine(points[i - 1], points[i]);
return total;
}
/**
* Metres from the first stop to whichever is furthest from it.
*
* Polarsteps calls this "furthest point from home" and measures from where the
* trip started, which is the only definition that needs no extra information —
* TREK does not know where anyone lives, and asking would be a worse book for
* the one fact it adds.
*/
export function furthestFromStart(points: { lat: number; lng: number }[]): number {
if (points.length < 2) return 0;
const start = points[0];
let max = 0;
for (let i = 1; i < points.length; i++) {
const d = haversine(start, points[i]);
if (d > max) max = d;
}
return max;
}
/** The `YYYY-MM-DD` part of an ISO date or datetime, or null if it is neither. */
export function isoDay(value: string | null | undefined): string | null {
if (!value) return null;
const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(value));
return m ? m[1] : null;
}
/**
* Calendar days spanned, inclusive at both ends.
*
* A trip that starts and ends the same day is one day, not zero — the figure a
* book prints under "DAYS" is how long you were away, and nobody describes a
* day trip as lasting no days.
*/
export function daysBetween(start: string | null, end: string | null): number {
const a = isoDay(start);
const b = isoDay(end);
if (!a || !b) return 0;
const from = Date.parse(`${a}T00:00:00Z`);
const to = Date.parse(`${b}T00:00:00Z`);
if (!Number.isFinite(from) || !Number.isFinite(to)) return 0;
return Math.max(1, Math.round((to - from) / 86_400_000) + 1);
}
/**
* Thin a route down to a point budget, keeping the ends.
*
* Evenly spaced rather than clustered: dropping a run from the middle would
* cut the route's shape out, while every n-th stop keeps it and only loses
* detail no printed line could show anyway.
*/
export function thinRoute<T>(points: T[], limit = MAX_ROUTE_POINTS): T[] {
if (points.length <= limit) return points;
const out: T[] = [];
const step = (points.length - 1) / (limit - 1);
for (let i = 0; i < limit; i++) out.push(points[Math.round(i * step)]);
return out;
}
/**
* Countries in the order they were first reached.
*
* Visit order, not alphabetical and not by size: the country page of a travel
* book retells the route, and "Mexico, Belize, Guatemala" is the trip while
* "Belize, Guatemala, Mexico" is a filing cabinet. Countries whose stops carry
* no date fall to the end, in the order they appeared.
*/
export function collectCountries(
points: StatsInputPoint[],
names: Record<string, string>,
): JourneyStatsCountry[] {
const seen = new Map<string, { code: string; places: number; firstVisit: string | null; order: number }>();
points.forEach((p, i) => {
if (!p.country) return;
const code = p.country.toUpperCase();
const day = isoDay(p.date);
const entry = seen.get(code);
if (!entry) {
seen.set(code, { code, places: 1, firstVisit: day, order: i });
return;
}
entry.places += 1;
if (day && (!entry.firstVisit || day < entry.firstVisit)) entry.firstVisit = day;
});
return [...seen.values()]
.sort((a, b) => {
if (a.firstVisit && b.firstVisit && a.firstVisit !== b.firstVisit) {
return a.firstVisit < b.firstVisit ? -1 : 1;
}
if (a.firstVisit && !b.firstVisit) return -1;
if (!a.firstVisit && b.firstVisit) return 1;
return a.order - b.order;
})
.map(c => ({
code: c.code,
name: names[c.code] || c.code,
places: c.places,
firstVisit: c.firstVisit,
}));
}
export interface StatsInput {
journeyId: number;
/** The route, already in the order it should be drawn. */
points: StatsInputPoint[];
entries: number;
photos: number;
places: number;
/** Dates from the trips, used when the entries carry none. */
tripDates: { start: string | null; end: string | null }[];
countryNames: Record<string, string>;
}
/**
* Fold everything into the figures a book puts on a page.
*
* Dates come from the entries when they have them and from the trips otherwise:
* an entry is a thing that happened on a day, so it is the better source, but a
* journey assembled from trips nobody has written entries for still has a
* length, and printing "0 DAYS" over a fortnight in Iceland would be worse than
* using the trip's own dates.
*/
export function computeJourneyStats(input: StatsInput): JourneyStats {
const points = thinRoute(input.points);
const dated = points.map(p => isoDay(p.date)).filter((d): d is string => !!d).sort();
const tripStarts = input.tripDates.map(t => isoDay(t.start)).filter((d): d is string => !!d).sort();
const tripEnds = input.tripDates.map(t => isoDay(t.end)).filter((d): d is string => !!d).sort();
const start = dated[0] ?? tripStarts[0] ?? null;
const end = dated[dated.length - 1] ?? tripEnds[tripEnds.length - 1] ?? null;
const geo = points.filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng));
return {
journeyId: input.journeyId,
distance: Math.round(routeDistance(geo)),
days: daysBetween(start, end),
steps: input.entries,
photos: input.photos,
places: input.places,
furthest: Math.round(furthestFromStart(geo)),
countries: collectCountries(points, input.countryNames),
points: points.map((p): JourneyStatsPoint => ({
lat: p.lat,
lng: p.lng,
label: p.label,
date: p.date,
country: p.country,
})),
start,
end,
};
}
@@ -25,6 +25,7 @@ import type { User } from '../../types';
import { StorageService } from '../storage/storage.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
import { JourneyBookService } from './journey-book.service';
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
@@ -36,6 +37,7 @@ import {
JourneyEntryCreateDto, JourneyEntryPhotoUploadDto, JourneyEntryUpdateDto, JourneyGalleryVideoDto,
JourneyLinkPhotoDto, JourneyPhotoUpdateDto, JourneyPreferencesDto, JourneyProviderPhotosDto,
JourneyReorderEntriesDto, JourneyShareLinkDto, JourneyUpdateDto,
BookSaveDto,
} from './journey.dto';
import { isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../files/files.constants';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
@@ -127,6 +129,7 @@ export class JourneyController {
constructor(
private readonly journey: JourneyService,
private readonly storage: StorageService,
private readonly books: JourneyBookService,
private readonly captureBackfill: PhotoCaptureBackfillService,
) {}
@@ -520,6 +523,87 @@ export class JourneyController {
return { tracks };
}
/**
* The figures TREK Studio prints on a page — distance, days, steps, photos,
* countries and the route itself (#1973).
*
* Read-only and derived, like /tracks above it: nothing here is stored, it is
* what the trips added to this journey add up to. Studio freezes the answer
* into the book document when an element is placed, so this is called while
* designing and never while printing.
*/
@Get(':id/stats')
stats(@CurrentUser() user: User, @Param('id') id: string) {
const stats = this.journey.journeyStats(Number(id), user.id);
if (!stats) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return stats;
}
/*
* ── The Studio book (#1973) ──────────────────────────────────────────
*
* A book belongs to its journey and inherits its access exactly: every
* contributor may open and edit it. No second permission model over the same
* object — that is how two rules end up disagreeing about who may do what.
*/
@Get(':id/book')
getBook(@CurrentUser() user: User, @Param('id') id: string) {
const book = this.books.getBook(Number(id), user.id);
if (book === null && !this.books.canOpen(Number(id), user.id)) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
// A journey with no book yet is not an error: Studio opens, lays one out
// and saves it. Answering 404 would make "no book" and "no journey"
// indistinguishable to the client.
return { book };
}
@Put(':id/book')
@HttpCode(200)
saveBook(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: BookSaveDto,
@Headers('x-socket-id') socketId?: string,
) {
const result = this.books.saveBook(Number(id), user.id, {
title: body.title ?? '',
document: body.document,
baseVersion: body.baseVersion,
});
if (result === null) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
if ('conflict' in result) {
/*
* 409, with the current record in the body.
*
* Two people editing is the normal case here, not an exception — the
* client needs to be able to show what the other version is, and a bare
* status code would make that a second round trip at exactly the moment
* someone is worried about losing work.
*/
throw new HttpException(
{ error: 'Book was changed by someone else', current: result.conflict },
409,
);
}
this.books.broadcastSaved(Number(id), user.id, result.record, socketId);
return result.record;
}
@Delete(':id/book')
@HttpCode(204)
deleteBook(@CurrentUser() user: User, @Param('id') id: string) {
const removed = this.books.deleteBook(Number(id), user.id);
if (removed === null) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
}
@Post(':id/entries')
createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) {
if (!body.entry_date) {
+4
View File
@@ -15,6 +15,7 @@ import {
journeyReorderEntriesRequestSchema,
journeyShareLinkRequestSchema,
journeyUpdateRequestSchema,
bookSaveRequestSchema,
} from '@trek/shared';
/**
@@ -49,3 +50,6 @@ export class JourneyLinkPhotoDto extends createZodDto(journeyLinkPhotoRequestSch
export class JourneyPhotoUpdateDto extends createZodDto(journeyPhotoUpdateRequestSchema) {}
export class JourneyEntryPhotoUploadDto extends createZodDto(journeyEntryPhotoUploadRequestSchema) {}
export class JourneyGalleryVideoDto extends createZodDto(journeyGalleryVideoRequestSchema) {}
/** A Studio book save — the whole document, plus the version it was made against. */
export class BookSaveDto extends createZodDto(bookSaveRequestSchema) {}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JourneyController } from './journey.controller';
import { JourneyPublicController } from './journey-public.controller';
import { JourneyService } from './journey.service';
import { JourneyBookService } from './journey-book.service';
import { AddonsModule } from '../addons/addons.module';
import { MemoriesModule } from '../memories/memories.module';
import { JourneyDomainModule } from './journey-domain.module';
@@ -34,6 +35,6 @@ import { journeyImageFileFilter, journeyUploadFilename } from './journey.control
StorageModule,
AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule],
controllers: [JourneyController, JourneyPublicController],
providers: [JourneyService, JourneyMcp],
providers: [JourneyService, JourneyBookService, JourneyMcp],
})
export class JourneyModule {}
@@ -52,6 +52,7 @@ export class JourneyService {
// Entries
listEntries(id: number, userId: number) { return this.journey.listEntries(id, userId); }
journeyTracks(id: number, userId: number) { return this.journey.journeyTracks(id, userId); }
journeyStats(id: number, userId: number) { return this.journey.journeyStats(id, userId); }
// Entry create/update bodies are free-form in the legacy route (req.body: any);
// the cast keeps that boundary here so callers needn't pre-shape the payload.
createEntry(id: number, userId: number, data: Record<string, unknown>, sid?: string) { return this.journey.createEntry(id, userId, data as Parameters<typeof this.journey.createEntry>[2], sid); }
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { RealtimeGateway } from './realtime.gateway';
import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
import { JourneyDomainModule } from '../journey/journey-domain.module';
/**
* The transport, kept out of RealtimeModule on purpose.
@@ -17,7 +18,9 @@ import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
* TrekWsAdapter already registered.
*/
@Module({
imports: [EphemeralTokenModule],
// JourneyDomainModule for the book rooms: who may open a journey is asked
// of the same service the REST routes ask.
imports: [EphemeralTokenModule, JourneyDomainModule],
providers: [RealtimeGateway],
})
export class RealtimeGatewayModule {}
@@ -14,13 +14,20 @@ import { DatabaseService } from '../database/database.service';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
import { User } from '../../types';
import {
bookPeers,
broadcastToBook,
joinBook,
joinRoom,
leaveAllBooks,
leaveAllRooms,
leaveBook,
leaveRoom,
registerSocket,
socketIdOf,
userOf,
type TrekWebSocket,
} from './ws-state';
import { JourneyDomainService } from '../journey/journey-domain.service';
const HEARTBEAT_INTERVAL = 30_000;
@@ -52,6 +59,12 @@ export class RealtimeGateway
constructor(
private readonly db: DatabaseService,
private readonly tokens: EphemeralTokenService,
/*
* For the book rooms, and injected rather than reimplemented: who may open
* a journey is one question with one answer, and a second copy of it here
* is a second thing to keep in step with the REST routes.
*/
private readonly journeys: JourneyDomainService,
) {}
afterInit(server: WebSocketServer): void {
@@ -131,6 +144,9 @@ export class RealtimeGateway
handleDisconnect(socket: TrekWebSocket): void {
leaveAllRooms(socket);
// Tell the books this socket was in, or its pointer stays on everyone
// else's page forever.
for (const journeyId of leaveAllBooks(socket)) this.announcePeers(journeyId);
}
@SubscribeMessage('join')
@@ -149,6 +165,94 @@ export class RealtimeGateway
return { type: 'joined', tripId };
}
/*
* ── Studio books (#1973) ────────────────────────────────────────────────
*
* Presence and pointers for people editing the same photo book. Both are
* ephemeral by design: nothing here is written down, and a socket that goes
* away takes its pointer with it.
*/
@SubscribeMessage('book:join')
handleBookJoin(
@MessageBody() message: { journeyId?: number | string },
@ConnectedSocket() socket: TrekWebSocket,
): { type: string; journeyId?: number; message?: string } | undefined {
const user = userOf(socket);
if (!user || !message?.journeyId) return undefined;
const journeyId = Number(message.journeyId);
if (!Number.isFinite(journeyId) || !this.journeys.canAccessJourney(journeyId, user.id)) {
return { type: 'error', message: 'Access denied' };
}
joinBook(socket, journeyId);
this.announcePeers(journeyId);
return { type: 'book:joined', journeyId };
}
@SubscribeMessage('book:leave')
handleBookLeave(
@MessageBody() message: { journeyId?: number | string },
@ConnectedSocket() socket: TrekWebSocket,
): { type: string; journeyId?: number } | undefined {
if (!message?.journeyId) return undefined;
const journeyId = Number(message.journeyId);
leaveBook(socket, journeyId);
this.announcePeers(journeyId);
return { type: 'book:left', journeyId };
}
/**
* Forward a pointer to the others in the book.
*
* Nothing is checked against the database on this path, and that is the
* point: it runs ten times a second per editor, and the socket already
* proved it may be here when it joined. What it cannot do is send to a book
* it never joined — the room is the authorisation, so a socket that is not
* in it broadcasts to nobody.
*
* The coordinates are millimetres on the spread, not pixels on a screen. Two
* people are at different zoom levels on different monitors; a pixel means
* nothing to the other one.
*/
@SubscribeMessage('book:cursor')
handleBookCursor(
@MessageBody() message: {
journeyId?: number | string;
spreadIndex?: number;
x?: number | null;
y?: number | null;
},
@ConnectedSocket() socket: TrekWebSocket,
): undefined {
const user = userOf(socket);
const sid = socketIdOf(socket);
if (!user || sid == null || !message?.journeyId) return undefined;
const journeyId = Number(message.journeyId);
if (!bookPeers(journeyId).some((p) => p.socketId === sid)) return undefined;
broadcastToBook(
journeyId,
{
type: 'journey:book:cursor',
socketId: sid,
userId: user.id,
spreadIndex: Math.max(0, Math.trunc(Number(message.spreadIndex) || 0)),
x: finiteOrNull(message.x),
y: finiteOrNull(message.y),
},
sid,
);
return undefined;
}
/** The whole list, to everyone in the book including whoever just changed it. */
private announcePeers(journeyId: number): void {
broadcastToBook(journeyId, { type: 'journey:book:peers', peers: bookPeers(journeyId) });
}
@SubscribeMessage('leave')
handleLeave(
@MessageBody() message: { tripId?: number | string },
@@ -160,3 +264,16 @@ export class RealtimeGateway
return { type: 'left', tripId };
}
}
/**
* A coordinate, or null for a pointer that has left the page.
*
* The null check is explicit because `Number(null)` is 0, not NaN — leaving the
* page would have parked everyone's arrow in the top-left corner of the spread
* rather than taking it away.
*/
function finiteOrNull(value: number | null | undefined): number | null {
if (value == null) return null;
const n = Number(value);
return Number.isFinite(n) ? Math.round(n * 100) / 100 : null;
}
+65 -14
View File
@@ -15,13 +15,40 @@ import { logError } from '../audit/audit-log.logger';
// flood the process with garbage.
const WS_MSG_LIMIT = 30; // max messages
const WS_MSG_WINDOW = 10_000; // per 10 seconds
const rates = new WeakMap<TrekWebSocket, { count: number; windowStart: number }>();
function withinRate(socket: TrekWebSocket): boolean {
let rate = rates.get(socket);
/**
* A second, looser ceiling that every frame counts against.
*
* Studio's pointers (#1973) move about ten times a second per editor, which is
* three times what the limit above allows on its own — and that limit cannot
* simply be raised, because it is what stops a client flooding the process with
* frames nobody asked for.
*
* So the frames are counted twice. Everything counts against this ceiling,
* before parsing, exactly as before; the tighter limit then applies to
* everything except pointers. A client can send a lot of pointers and no more
* of anything else than it ever could, and a client sending garbage is still
* cut off — just at 200 frames per ten seconds rather than 30, which is a
* trade worth naming: those frames are small, and the alternative is either no
* live pointers or a limit high enough to be no limit.
*/
const WS_BURST_LIMIT = 200;
/** The one message type exempt from the tighter limit. */
const HIGH_RATE_TYPE = 'book:cursor';
const rates = new WeakMap<TrekWebSocket, { count: number; windowStart: number }>();
const bursts = new WeakMap<TrekWebSocket, { count: number; windowStart: number }>();
function within(
store: WeakMap<TrekWebSocket, { count: number; windowStart: number }>,
socket: TrekWebSocket,
limit: number,
): boolean {
let rate = store.get(socket);
if (!rate) {
rate = { count: 0, windowStart: Date.now() };
rates.set(socket, rate);
store.set(socket, rate);
}
const now = Date.now();
if (now - rate.windowStart > WS_MSG_WINDOW) {
@@ -30,7 +57,15 @@ function withinRate(socket: TrekWebSocket): boolean {
return true;
}
rate.count++;
return rate.count <= WS_MSG_LIMIT;
return rate.count <= limit;
}
function withinRate(socket: TrekWebSocket): boolean {
return within(rates, socket, WS_MSG_LIMIT);
}
function withinBurst(socket: TrekWebSocket): boolean {
return within(bursts, socket, WS_BURST_LIMIT);
}
/**
@@ -132,20 +167,36 @@ export class TrekWsAdapter extends WsAdapter {
transform: (data: unknown) => Observable<unknown>,
): void {
socket.on('message', (buffer: Buffer) => {
if (!withinRate(socket)) {
const tooLoud = () => {
if (socket.readyState === 1) {
socket.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' }));
}
return;
};
// The outer ceiling, before parsing: this is what stops a client flooding
// the process, and it counts every frame no matter what is in it.
if (!withinBurst(socket)) return tooLoud();
let message: { type?: unknown } | null = null;
try {
const parsed = JSON.parse(buffer.toString());
if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') message = parsed;
} catch {
// Malformed JSON. Still counted below, exactly as before.
}
let message: { type?: unknown };
try {
message = JSON.parse(buffer.toString());
} catch {
return; // Malformed JSON, ignored exactly as before.
}
if (!message || typeof message !== 'object' || typeof message.type !== 'string') return;
/*
* The tighter limit, on everything a pointer is not — malformed JSON and
* unknown types included, which is the point of counting them here rather
* than only counting what reaches a handler.
*
* Parsing first costs a JSON.parse on a small frame that the ceiling
* above has already let through. That is the price of telling a pointer
* from everything else, and it buys the tight limit staying exactly as
* tight as it was for every other frame.
*/
if (message?.type !== HIGH_RATE_TYPE && !withinRate(socket)) return tooLoud();
if (!message) return;
const handler = handlers.find((h) => h.message === message.type);
if (!handler) return; // An unknown type is ignored, not an error frame.
+92
View File
@@ -23,6 +23,18 @@ export interface TrekWebSocket extends WebSocket {
}
const rooms = new Map<number, Set<TrekWebSocket>>();
/**
* Who has a Studio book open, per journey.
*
* Separate from the trip rooms above, and separate from "is a contributor
* online": a book carries pointers and a presence list, and both are only of
* interest to the people actually looking at it. Fanning them out to every
* contributor's sockets would send a pointer moving at ten frames a second to
* someone reading the journey on their phone.
*/
const bookRooms = new Map<number, Set<TrekWebSocket>>();
const socketBooks = new WeakMap<TrekWebSocket, Set<number>>();
const socketRooms = new WeakMap<TrekWebSocket, Set<number>>();
const socketUser = new WeakMap<TrekWebSocket, User>();
const socketId = new WeakMap<TrekWebSocket, number>();
@@ -81,6 +93,86 @@ export function leaveAllRooms(ws: TrekWebSocket): void {
for (const tripId of mine) leaveRoom(ws, tripId);
}
// ── Studio books ──────────────────────────────────────────────────────────
export interface BookPeer {
socketId: number;
userId: number;
username: string;
avatar?: string | null;
}
export function joinBook(ws: TrekWebSocket, journeyId: number): void {
if (!bookRooms.has(journeyId)) bookRooms.set(journeyId, new Set());
bookRooms.get(journeyId)!.add(ws);
if (!socketBooks.has(ws)) socketBooks.set(ws, new Set());
socketBooks.get(ws)!.add(journeyId);
}
export function leaveBook(ws: TrekWebSocket, journeyId: number): void {
const room = bookRooms.get(journeyId);
if (room) {
room.delete(ws);
if (room.size === 0) bookRooms.delete(journeyId);
}
socketBooks.get(ws)?.delete(journeyId);
}
/** Every book this socket had open — called when the connection goes. */
export function leaveAllBooks(ws: TrekWebSocket): number[] {
const mine = socketBooks.get(ws);
if (!mine) return [];
const left = [...mine];
for (const journeyId of left) leaveBook(ws, journeyId);
return left;
}
/**
* Who is in a book, by socket rather than by user.
*
* One person with two tabs open is two entries on purpose: they have two
* pointers, and a list keyed by user could not say which one moved.
*/
export function bookPeers(journeyId: number): BookPeer[] {
const room = bookRooms.get(journeyId);
if (!room) return [];
const peers: BookPeer[] = [];
for (const ws of room) {
if (ws.readyState !== 1) continue;
const user = socketUser.get(ws);
const sid = socketId.get(ws);
if (!user || sid == null) continue;
peers.push({ socketId: sid, userId: user.id, username: user.username, avatar: user.avatar ?? null });
}
return peers;
}
/**
* Send to everyone looking at a book.
*
* No plugin event sink here, unlike the trip broadcast: a pointer is not
* something that happened to the trip, and announcing ten of them a second to
* every subscribed plugin would be a firehose of nothing.
*/
export function broadcastToBook(
journeyId: number,
payload: Record<string, unknown>,
excludeSid?: number,
): void {
const room = bookRooms.get(journeyId);
if (!room || room.size === 0) return;
for (const ws of room) {
if (ws.readyState !== 1) continue;
if (excludeSid != null && socketId.get(ws) === excludeSid) continue;
ws.send(JSON.stringify({ journeyId, ...payload }));
}
}
/** The socket's own id, so a handler can name the pointer it is forwarding. */
export function socketIdOf(ws: TrekWebSocket): number | undefined {
return socketId.get(ws);
}
/**
* Broadcast an event to all sockets in a trip room, optionally excluding a
* socket.
+184 -1
View File
@@ -47,16 +47,28 @@ vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal)
});
const { jsvc } = vi.hoisted(() => ({
jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() },
jsvc: {
listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(),
journeyStats: vi.fn(),
},
}));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
const { booksvc } = vi.hoisted(() => ({
booksvc: {
getBook: vi.fn(), canOpen: vi.fn(), saveBook: vi.fn(),
deleteBook: vi.fn(), broadcastSaved: vi.fn(),
},
}));
import { JourneyBookService } from '../../src/nest/journey/journey-book.service';
import { JourneyModule } from '../../src/nest/journey/journey.module';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
describe('Journey e2e (real auth guard + temp SQLite)', () => {
let server: Server;
@@ -68,12 +80,19 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
.useValue(jsvc)
.overrideProvider(JourneyShareService)
.useValue(sharesvc)
.overrideProvider(JourneyBookService)
.useValue(booksvc)
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
// by metatype, exactly as they do under buildApp(). Without it a book save
// would reach the service unvalidated here and the e2e would pass on a
// route that 400s in production.
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
@@ -89,6 +108,11 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
beforeEach(() => {
isAddonEnabled.mockReturnValue(true);
booksvc.getBook.mockReset();
booksvc.canOpen.mockReset();
booksvc.saveBook.mockReset();
booksvc.deleteBook.mockReset();
booksvc.broadcastSaved.mockReset();
});
afterAll(async () => {
@@ -128,6 +152,165 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
expect(res.body).toEqual({ error: 'Journey not found' });
});
/*
* The journey figures TREK Studio prints (#1973). Read-only and derived, so
* what e2e adds over the controller unit test is that the addon gate and the
* auth guard both run in front of it — a route that reports where someone has
* been must not be reachable without a session.
*/
it('401 for the journey stats without a session', async () => {
expect((await request(server).get('/api/journeys/9/stats')).status).toBe(401);
});
it('404 for the stats of an inaccessible journey', async () => {
jsvc.journeyStats.mockReturnValue(null);
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey not found' });
});
it('200 with the figures, returned bare rather than in an envelope', async () => {
jsvc.journeyStats.mockReturnValue({
journeyId: 9, distance: 1_189_000, days: 14, steps: 14, photos: 57, places: 0,
furthest: 408_000,
countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }],
points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS' }],
start: '2026-06-02', end: '2026-06-15',
});
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.distance).toBe(1_189_000);
expect(res.body.countries).toEqual([{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }]);
expect(jsvc.journeyStats).toHaveBeenCalledWith(9, 1);
});
it('404 (addon gate) for the stats when the Journey addon is disabled', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
});
/*
* ── The Studio book (#1973) ────────────────────────────────────────────
*
* What e2e adds over the service test: the addon gate and the auth guard both
* run in front of these, the 409 keeps its body (an exception filter that
* flattened it would cost the client the other version), and DELETE really
* answers 204 rather than Nest's default 200.
*/
it('401 for the book without a session', async () => {
expect((await request(server).get('/api/journeys/9/book')).status).toBe(401);
expect((await request(server).put('/api/journeys/9/book').send({ document: {} })).status).toBe(401);
expect((await request(server).delete('/api/journeys/9/book')).status).toBe(401);
});
it('404 (addon gate) for the book when the Journey addon is disabled', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
});
/*
* "No book yet" is not "no journey". Studio opens on an empty journey, lays a
* book out and saves it, so a 404 here would make the ordinary first visit
* look like a missing trip.
*/
it('200 with a null book for a journey that has none yet', async () => {
booksvc.getBook.mockReturnValue(null);
booksvc.canOpen.mockReturnValue(true);
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ book: null });
});
it('404 for the book of an inaccessible journey', async () => {
booksvc.getBook.mockReturnValue(null);
booksvc.canOpen.mockReturnValue(false);
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey not found' });
});
it('200 with the stored book', async () => {
booksvc.getBook.mockReturnValue({ id: 3, journeyId: 9, title: 'Iceland', version: 4 });
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.book.version).toBe(4);
expect(booksvc.getBook).toHaveBeenCalledWith(9, 1);
});
it('200 on save — not 201, the book is one object being updated', async () => {
booksvc.saveBook.mockReturnValue({ record: { id: 3, journeyId: 9, title: 'T', version: 5 } });
const res = await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.send({ title: 'T', document: { version: 1 }, baseVersion: 4 });
expect(res.status).toBe(200);
expect(res.body.version).toBe(5);
// The pipe fills the document's defaults on the way in, so the service is
// never handed a half-document to guess at.
const [journeyId, userId, input] = booksvc.saveBook.mock.calls[0];
expect([journeyId, userId]).toEqual([9, 1]);
expect(input.title).toBe('T');
expect(input.baseVersion).toBe(4);
expect(input.document.page.pageWidth).toBe(210);
expect(input.document.spreads).toEqual([]);
});
it('forwards the socket id so the saving client does not echo its own change', async () => {
booksvc.saveBook.mockReturnValue({ record: { id: 3, version: 5 } });
await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.set('X-Socket-Id', 'sock-7')
.send({ document: {} });
expect(booksvc.broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7');
});
it('409 with the current record in the body, not a bare status', async () => {
booksvc.saveBook.mockReturnValue({ conflict: { id: 3, journeyId: 9, title: 'Theirs', version: 6 } });
const res = await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.send({ document: {}, baseVersion: 4 });
expect(res.status).toBe(409);
expect(res.body.error).toBe('Book was changed by someone else');
expect(res.body.current.version).toBe(6);
expect(booksvc.broadcastSaved).not.toHaveBeenCalled();
});
it('404 on saving into an inaccessible journey', async () => {
booksvc.saveBook.mockReturnValue(null);
const res = await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.send({ document: {} });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey not found' });
});
it('400 for a save with no document at all', async () => {
const res = await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.send({ title: 'T' });
expect(res.status).toBe(400);
expect(booksvc.saveBook).not.toHaveBeenCalled();
});
it('204 on delete, and 404 when the journey is out of reach', async () => {
booksvc.deleteBook.mockReturnValue(true);
const ok = await request(server).delete('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(ok.status).toBe(204);
booksvc.deleteBook.mockReturnValue(null);
const gone = await request(server).delete('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(gone.status).toBe(404);
expect(gone.body).toEqual({ error: 'Journey not found' });
});
it('public journey read is unguarded (200 with a valid token, no cookie)', async () => {
const res = await request(server).get('/api/public/journey/tok');
expect(res.status).toBe(200);
+3 -1
View File
@@ -741,7 +741,9 @@ export function addJourneyContributor(
}
export function linkTripToJourney(db: Database.Database, journeyId: number, tripId: number): void {
// The column is added_at, not linked_at — this helper had no callers until
// #1973 and so had never actually run against the schema.
db.prepare(
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, linked_at) VALUES (?, ?, ?)'
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)'
).run(journeyId, tripId, Date.now());
}
+1
View File
@@ -71,6 +71,7 @@ const RESET_TABLES = [
'trip_members',
'trips',
// Journey
'journey_books',
'journey_share_tokens',
'journey_photos',
'journey_entries',
@@ -43,17 +43,19 @@ describe('incoming_leg_transport_mode migration', () => {
//
// >>> Appending a migration? Re-point the undo below at whatever yours
// >>> does. That is the whole maintenance cost of this guard.
// Trailing migrations today (a dev/feat-storage-driver merge landed five
// in one release window, so the rewind spans all of them):
// version-5 — reservation_day_positions cross-trip cleanup: undone by
// Trailing migrations today (two release windows landed on top of each
// other, so the rewind spans all six):
// version-6 — reservation_day_positions cross-trip cleanup: undone by
// re-inserting a mismatched reservation/day pair.
// version-4 — #1939 instance API key promotion: nothing to undo — the
// version-5 — #1939 instance API key promotion: nothing to undo, the
// seeded users are not admins and hold no keys, so the
// replay is a no-op by its own guards.
// version-3 — trek_photos capture metadata (#1614): column-guarded, so
// version-4 — trek_photos capture metadata (#1614): column-guarded, so
// the replay is a no-op; the from-scratch case above covers it.
// version-2 — journey_share_tokens.newest_first: undone by dropping the
// version-3 — journey_share_tokens.newest_first: undone by dropping the
// column and letting the replay put it back.
// version-2 — journey_books, the TREK Studio document store (#1973):
// undone by dropping the table and its index.
// version-1 — storage slice-2 trip_files 'files/' prefix strip: undone
// by re-inserting a prefixed row.
const upgraded = new Database(':memory:');
@@ -83,7 +85,13 @@ describe('incoming_leg_transport_mode migration', () => {
upgraded.exec('ALTER TABLE journey_share_tokens DROP COLUMN newest_first');
expect(hasNewestFirst()).toBe(false);
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 5);
upgraded.exec('DROP INDEX IF EXISTS idx_journey_books_journey');
upgraded.exec('DROP TABLE IF EXISTS journey_books');
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journey_books'").get()
).toBeUndefined();
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 6);
runMigrations(upgraded);
@@ -92,6 +100,12 @@ describe('incoming_leg_transport_mode migration', () => {
const files = upgraded.prepare('SELECT filename FROM trip_files').all() as { filename: string }[];
expect(files.map(r => r.filename)).toEqual(['aaa.pdf']);
expect(hasNewestFirst()).toBe(true);
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journey_books'").get()
).toEqual({ name: 'journey_books' });
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_journey_books_journey'").get()
).toEqual({ name: 'idx_journey_books_journey' });
expect(upgraded.prepare('SELECT version FROM schema_version').get()).toEqual({ version });
upgraded.close();
});
@@ -0,0 +1,334 @@
/**
* Storing TREK Studio books (#1973).
*
* The half worth testing hard is concurrency. Everyone on a journey can edit
* the same book, so "two saves arrive together" is the ordinary case rather
* than an edge one, and the failure mode of getting it wrong is somebody's
* afternoon disappearing with no error anywhere.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
},
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createJourney, addJourneyContributor } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import { db as dbConn } from '../../../src/db/database';
const dbs = new DatabaseService(dbConn);
const domain = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
const books = new JourneyBookService(dbs, domain);
/** A minimal document that survives normalizeBookDocument unchanged. */
function doc(title = 'one') {
return {
version: 1,
title,
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 8 },
spreads: [{ id: 's1', kind: 'inner', background: null, elements: [], parked: [] }],
};
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// -- Access -------------------------------------------------------------------
describe('access', () => {
it('refuses a journey the user cannot see', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
expect(books.getBook(journey.id, stranger.id)).toBeNull();
expect(books.saveBook(journey.id, stranger.id, { title: '', document: doc() })).toBeNull();
expect(books.deleteBook(journey.id, stranger.id)).toBeNull();
expect(books.canOpen(journey.id, stranger.id)).toBe(false);
});
/*
* A book inherits its journey's access exactly. A second permission model
* over the same object is how two rules end up disagreeing about who may do
* what.
*/
it('lets a contributor edit, not only the owner', () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, helper.id, 'editor');
const saved = books.saveBook(journey.id, helper.id, { title: 'B', document: doc() });
expect(saved && 'record' in saved).toBe(true);
expect(books.getBook(journey.id, helper.id)).not.toBeNull();
});
it('says nothing about a journey that does not exist', () => {
const { user } = createUser(testDb);
expect(books.canOpen(999_999, user.id)).toBe(false);
expect(books.getBook(999_999, user.id)).toBeNull();
});
});
// -- Creating and reading -----------------------------------------------------
describe('creating and reading', () => {
it('is null for a journey with no book yet', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(books.getBook(journey.id, user.id)).toBeNull();
expect(books.canOpen(journey.id, user.id)).toBe(true);
});
it('creates on first save, at version 1', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const saved = books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() });
expect(saved && 'record' in saved && saved.record.version).toBe(1);
expect(saved && 'record' in saved && saved.record.title).toBe('Iceland');
});
it('reads the document back as it went in', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
const read = books.getBook(journey.id, user.id)!;
expect(read.document.spreads).toHaveLength(1);
expect(read.document.page.pageWidth).toBe(210);
});
it('records who saved it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
expect(books.getBook(journey.id, user.id)!.updatedBy).toBe(user.id);
});
it('lists books without their documents', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() });
const list = books.listBooks(journey.id, user.id)!;
expect(list).toHaveLength(1);
expect(list[0].title).toBe('Iceland');
expect('document' in list[0]).toBe(false);
});
/*
* A document that will not parse still has to open. One drifted field should
* not lock somebody out of their own book — normalizeBookDocument drops what
* it cannot read rather than throwing.
*/
it('opens a book whose stored JSON is broken, rather than throwing', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
testDb
.prepare(
"INSERT INTO journey_books (journey_id, title, document, version) VALUES (?, 'T', '{not json', 1)",
)
.run(journey.id);
const read = books.getBook(journey.id, user.id);
expect(read).not.toBeNull();
expect(read!.document.spreads).toEqual([]);
});
});
// -- Concurrency --------------------------------------------------------------
describe('concurrency', () => {
function seed() {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
return { user, journey };
}
it('bumps the version on every save', () => {
const { user, journey } = seed();
const second = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('two'),
baseVersion: 1,
});
expect(second && 'record' in second && second.record.version).toBe(2);
});
/* The one this column exists for. */
it('refuses a save made against a version that has moved', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('three'),
baseVersion: 1,
});
expect(stale && 'conflict' in stale).toBe(true);
});
it('answers a conflict with the current record, not only a refusal', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
title: 'Mine',
document: doc('three'),
baseVersion: 1,
});
expect(stale && 'conflict' in stale && stale.conflict.version).toBe(2);
expect(stale && 'conflict' in stale && stale.conflict.title).toBe('Theirs');
expect(stale && 'conflict' in stale && stale.conflict.document.title).toBe('two');
});
it('leaves the stored document untouched when it refuses', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 });
books.saveBook(journey.id, user.id, { title: 'Mine', document: doc('three'), baseVersion: 1 });
expect(books.getBook(journey.id, user.id)!.document.title).toBe('two');
expect(books.getBook(journey.id, user.id)!.version).toBe(2);
});
/*
* "Open Studio and start editing" has to work for the second person to
* arrive, who has a document but no version yet.
*/
it('takes a save with no base version as an ordinary write', () => {
const { user, journey } = seed();
const saved = books.saveBook(journey.id, user.id, { title: 'T', document: doc('two') });
expect(saved && 'record' in saved && saved.record.version).toBe(2);
});
it('lets the loser save again once it has the new version', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const conflict = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('mine'),
baseVersion: 1,
});
expect(conflict && 'conflict' in conflict).toBe(true);
const retry = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('mine'),
baseVersion: 2,
});
expect(retry && 'record' in retry && retry.record.version).toBe(3);
expect(books.getBook(journey.id, user.id)!.document.title).toBe('mine');
});
it('does not let a version from another journey unlock this one', () => {
const { user, journey } = seed();
const other = createJourney(testDb, user.id);
books.saveBook(other.id, user.id, { title: 'O', document: doc('other') });
// Version 1 is current over there and stale here.
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('three'),
baseVersion: 1,
});
expect(stale && 'conflict' in stale).toBe(true);
expect(books.getBook(other.id, user.id)!.version).toBe(1);
});
});
// -- Broadcasting -------------------------------------------------------------
describe('broadcastSaved', () => {
it('sends the version, not the document, and excludes the saver', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const saved = books.saveBook(journey.id, user.id, { title: 'T', document: doc() })!;
const record = 'record' in saved ? saved.record : null;
const spy = vi.spyOn(domain, 'broadcastJourneyEvent').mockImplementation(() => {});
books.broadcastSaved(journey.id, user.id, record!, 'socket-7');
expect(spy).toHaveBeenCalledWith(
journey.id,
'journey:book:saved',
{ version: 1, savedBy: user.id },
'socket-7',
);
spy.mockRestore();
});
});
// -- Deleting -----------------------------------------------------------------
describe('deleting', () => {
it('removes the book and reports it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
expect(books.deleteBook(journey.id, user.id)).toBe(true);
expect(books.getBook(journey.id, user.id)).toBeNull();
});
it('reports false when there was nothing to delete', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(books.deleteBook(journey.id, user.id)).toBe(false);
});
it('goes with the journey it belongs to', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
testDb.prepare('DELETE FROM journeys WHERE id = ?').run(journey.id);
const left = testDb
.prepare('SELECT COUNT(*) AS n FROM journey_books WHERE journey_id = ?')
.get(journey.id) as { n: number };
expect(left.n).toBe(0);
});
});
@@ -0,0 +1,358 @@
/**
* journeyStats() — the SQL half of the journey figures (#1973).
*
* A real in-memory SQLite DB, because what is being tested here is the queries:
* which rows become the route, how a place assigned to three days is counted
* once, and which source wins when a journey has both entries and trip places.
* The arithmetic those rows feed into has its own tests in journey-stats.test.ts.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
},
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
/*
* The country lookup is stubbed rather than exercised. The real one loads and
* indexes 4MB of gzipped admin-0 boundaries on first call — minutes of work
* across a test suite, to verify a function that belongs to Atlas and has its
* own tests. What matters here is *which coordinates get asked about* and that
* the place_regions cache is preferred, both of which the stub shows.
*/
const countryCalls: [number, number][] = [];
vi.mock('../../../src/nest/atlas/atlas-geo', () => ({
getCountryFromCoords: (lat: number, lng: number) => {
countryCalls.push([lat, lng]);
// Rough boxes, enough to tell two countries apart in a fixture.
if (lat > 63 && lng < -13) return 'IS';
if (lat > 47 && lat < 55 && lng > 5 && lng < 15) return 'DE';
if (lat > 42 && lat < 51 && lng > -5 && lng < 8) return 'FR';
return null;
},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import {
createUser, createTrip, createJourney, createJourneyEntry, createPlace,
createDay, createDayAssignment, linkTripToJourney,
} from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { db as dbConn } from '../../../src/db/database';
const dbs = new DatabaseService(dbConn);
const svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
/** The factory has no coordinate fields, and a route is made of coordinates. */
function placeEntry(
journeyId: number,
authorId: number,
lat: number,
lng: number,
overrides: { title?: string; entry_date?: string } = {},
) {
const entry = createJourneyEntry(testDb, journeyId, authorId, overrides);
testDb
.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?')
.run(lat, lng, entry.id);
return entry;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
countryCalls.length = 0;
});
afterAll(() => {
testDb.close();
});
describe('journeyStats access', () => {
it('is null for a journey the user cannot see', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
expect(svc.journeyStats(journey.id, stranger.id)).toBeNull();
});
it('is null for a journey that does not exist', () => {
const { user } = createUser(testDb);
expect(svc.journeyStats(999_999, user.id)).toBeNull();
});
it('answers for the owner', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const stats = svc.journeyStats(journey.id, user.id);
expect(stats).not.toBeNull();
expect(stats!.journeyId).toBe(journey.id);
});
});
describe('journeyStats route', () => {
it('builds the route from the entries when they carry coordinates', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']);
expect(stats.distance).toBeGreaterThan(240_000);
expect(stats.days).toBe(5);
expect(stats.steps).toBe(2);
});
it('orders the route by entry date, not by insertion', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['first', 'second']);
});
it('skips entries without coordinates but still counts them as steps', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
createJourneyEntry(testDb, journey.id, user.id, { title: 'no place', entry_date: '2026-06-03' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points).toHaveLength(1);
expect(stats.steps).toBe(2);
});
/*
* The fallback that makes the feature work at all for the common case: a
* journey built by adding trips, before anyone has written a word.
*/
it('falls back to the trip places when no entry has coordinates', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const day1 = createDay(testDb, trip.id, { date: '2026-06-02' });
const day2 = createDay(testDb, trip.id, { date: '2026-06-04' });
const a = createPlace(testDb, trip.id, { name: 'Hallgrímskirkja', lat: 64.14, lng: -21.93 });
const b = createPlace(testDb, trip.id, { name: 'Goðafoss', lat: 65.68, lng: -17.55 });
createDayAssignment(testDb, day2.id, b.id);
createDayAssignment(testDb, day1.id, a.id);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['Hallgrímskirkja', 'Goðafoss']);
expect(stats.days).toBe(3);
});
it('prefers the entries over the trip places rather than mixing them', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { name: 'a place', lat: 48.85, lng: 2.35 });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['an entry']);
});
/*
* A hotel across three nights is one place with three assignments. Left
* un-aggregated the join would put it on the route three times and charge the
* journey two extra legs of zero length — harmless — plus two extra stops in
* the middle of the route, which is not.
*/
it('visits a place assigned to several days only once', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const hotel = createPlace(testDb, trip.id, { name: 'Hotel', lat: 64.14, lng: -21.94 });
for (const date of ['2026-06-02', '2026-06-03', '2026-06-04']) {
createDayAssignment(testDb, createDay(testDb, trip.id, { date }).id, hotel.id);
}
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.filter(p => p.label === 'Hotel')).toHaveLength(1);
expect(stats.places).toBe(1);
});
it('puts unscheduled places after the scheduled ones', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const loose = createPlace(testDb, trip.id, { name: 'unscheduled', lat: 64.2, lng: -21.8 });
const planned = createPlace(testDb, trip.id, { name: 'scheduled', lat: 64.14, lng: -21.94 });
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, planned.id);
expect(loose.id).toBeGreaterThan(0);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['scheduled', 'unscheduled']);
});
it('counts places across every trip on the journey', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const one = createTrip(testDb, user.id);
const two = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, one.id);
linkTripToJourney(testDb, journey.id, two.id);
createPlace(testDb, one.id, { lat: 48.85, lng: 2.35 });
createPlace(testDb, two.id, { lat: 52.52, lng: 13.4 });
createPlace(testDb, two.id, { lat: 50.11, lng: 8.68 });
expect(svc.journeyStats(journey.id, user.id)!.places).toBe(3);
});
});
describe('journeyStats countries', () => {
it('resolves each stop to a country and names it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.countries.map(c => c.code)).toEqual(['IS', 'DE']);
expect(stats.countries[0].name).toBe('Iceland');
expect(stats.countries[1].name).toBe('Germany');
});
/*
* Atlas already resolved these and wrote them down. Reading its cache instead
* of repeating a point-in-polygon test is the difference between a query and
* a boundary index.
*/
it('prefers the place_regions cache over recomputing a place country', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const place = createPlace(testDb, trip.id, { name: 'somewhere', lat: 64.14, lng: -21.94 });
testDb
.prepare('INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, 'fr', 'FR-75', 'Paris');
const stats = svc.journeyStats(journey.id, user.id)!;
// The cached answer wins even though the coordinates say Iceland, and the
// expensive lookup is never reached for that place.
expect(stats.countries.map(c => c.code)).toEqual(['FR']);
expect(countryCalls).toHaveLength(0);
});
it('leaves a stop with no country out of the list rather than inventing one', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.countries).toEqual([]);
expect(stats.points).toHaveLength(1);
expect(stats.points[0].country).toBeNull();
});
});
describe('journeyStats totals', () => {
it('counts the gallery photographs', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const photo = testDb
.prepare("INSERT INTO trek_photos (provider, file_path, owner_id) VALUES ('local', 'x.jpg', ?)")
.run(user.id);
testDb
.prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, 0, ?)')
.run(journey.id, photo.lastInsertRowid, Date.now());
expect(svc.journeyStats(journey.id, user.id)!.photos).toBe(1);
});
it('falls back to the trip dates when the entries carry none', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-06-01', end_date: '2026-06-10' });
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { lat: 64.14, lng: -21.94 });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.start).toBe('2026-06-01');
expect(stats.end).toBe('2026-06-10');
expect(stats.days).toBe(10);
});
it('is all zeroes for an empty journey rather than failing', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.distance).toBe(0);
expect(stats.days).toBe(0);
expect(stats.steps).toBe(0);
expect(stats.photos).toBe(0);
expect(stats.places).toBe(0);
expect(stats.countries).toEqual([]);
expect(stats.points).toEqual([]);
});
it('answers for a contributor, not only the owner', () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
testDb
.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)')
.run(journey.id, helper.id, 'editor', Date.now());
expect(svc.journeyStats(journey.id, helper.id)).not.toBeNull();
});
});
@@ -0,0 +1,364 @@
/**
* The journey-stats arithmetic (#1973).
*
* Pure module, so these run against literal arrays rather than a fixture
* database — which is the whole reason the arithmetic lives apart from the
* service that fetches the rows.
*/
import { describe, it, expect } from 'vitest';
import {
collectCountries,
computeJourneyStats,
daysBetween,
furthestFromStart,
haversine,
isoDay,
routeDistance,
thinRoute,
MAX_ROUTE_POINTS,
type StatsInputPoint,
} from '../../../src/nest/journey/journey-stats';
const point = (over: Partial<StatsInputPoint> = {}): StatsInputPoint => ({
lat: 0, lng: 0, label: '', date: null, country: null, ...over,
});
describe('haversine', () => {
it('is zero for a point against itself', () => {
expect(haversine({ lat: 64.14, lng: -21.94 }, { lat: 64.14, lng: -21.94 })).toBe(0);
});
it('matches a known great-circle distance', () => {
// Reykjavík to Akureyri, ~250km as the crow flies.
const d = haversine({ lat: 64.1466, lng: -21.9426 }, { lat: 65.6885, lng: -18.1262 });
expect(d).toBeGreaterThan(240_000);
expect(d).toBeLessThan(260_000);
});
it('is symmetric', () => {
const a = { lat: 48.85, lng: 2.35 };
const b = { lat: 52.52, lng: 13.4 };
expect(haversine(a, b)).toBeCloseTo(haversine(b, a), 6);
});
it('handles antipodal points without NaN', () => {
const d = haversine({ lat: 0, lng: 0 }, { lat: 0, lng: 180 });
expect(Number.isFinite(d)).toBe(true);
// Half the circumference, give or take the radius model.
expect(d).toBeGreaterThan(20_000_000);
});
});
describe('routeDistance', () => {
it('is zero for fewer than two points', () => {
expect(routeDistance([])).toBe(0);
expect(routeDistance([{ lat: 1, lng: 1 }])).toBe(0);
});
it('sums the legs in order', () => {
const a = { lat: 0, lng: 0 };
const b = { lat: 0, lng: 1 };
const c = { lat: 0, lng: 2 };
expect(routeDistance([a, b, c])).toBeCloseTo(haversine(a, b) + haversine(b, c), 3);
});
it('counts a there-and-back route twice, not zero', () => {
const a = { lat: 0, lng: 0 };
const b = { lat: 0, lng: 1 };
expect(routeDistance([a, b, a])).toBeCloseTo(haversine(a, b) * 2, 3);
});
});
describe('furthestFromStart', () => {
it('is zero for fewer than two points', () => {
expect(furthestFromStart([])).toBe(0);
expect(furthestFromStart([{ lat: 5, lng: 5 }])).toBe(0);
});
it('measures from the first stop, not between the extremes', () => {
const points = [
{ lat: 0, lng: 0 },
{ lat: 0, lng: 3 },
{ lat: 0, lng: 1 },
];
expect(furthestFromStart(points)).toBeCloseTo(haversine(points[0], points[1]), 3);
});
it('finds the furthest even when it is not the last stop', () => {
const points = [
{ lat: 0, lng: 0 },
{ lat: 0, lng: 10 },
{ lat: 0, lng: 2 },
];
expect(furthestFromStart(points)).toBeCloseTo(haversine(points[0], points[1]), 3);
});
});
describe('isoDay', () => {
it('takes the date out of a datetime', () => {
expect(isoDay('2026-06-02T14:30:00Z')).toBe('2026-06-02');
});
it('passes a plain date through', () => {
expect(isoDay('2026-06-02')).toBe('2026-06-02');
});
it('is null for nothing and for nonsense', () => {
expect(isoDay(null)).toBeNull();
expect(isoDay(undefined)).toBeNull();
expect(isoDay('')).toBeNull();
expect(isoDay('sometime last summer')).toBeNull();
});
});
describe('daysBetween', () => {
it('counts a single day as one, not zero', () => {
expect(daysBetween('2026-06-02', '2026-06-02')).toBe(1);
});
it('is inclusive at both ends', () => {
expect(daysBetween('2026-06-02', '2026-06-04')).toBe(3);
});
it('spans a month boundary', () => {
expect(daysBetween('2026-05-28', '2026-06-02')).toBe(6);
});
it('spans a leap day', () => {
expect(daysBetween('2028-02-27', '2028-03-01')).toBe(4);
});
it('is zero when either end is missing', () => {
expect(daysBetween(null, '2026-06-02')).toBe(0);
expect(daysBetween('2026-06-02', null)).toBe(0);
expect(daysBetween(null, null)).toBe(0);
});
it('is zero rather than negative when the dates are the wrong way round', () => {
expect(daysBetween('2026-06-04', '2026-06-02')).toBe(1);
});
});
describe('thinRoute', () => {
it('leaves a route within budget alone', () => {
const points = Array.from({ length: 10 }, (_, i) => i);
expect(thinRoute(points, 20)).toBe(points);
});
it('thins down to exactly the budget', () => {
const points = Array.from({ length: 1000 }, (_, i) => i);
expect(thinRoute(points, 50)).toHaveLength(50);
});
it('keeps both ends, because they are the start and the end of the trip', () => {
const points = Array.from({ length: 1000 }, (_, i) => i);
const thinned = thinRoute(points, 50);
expect(thinned[0]).toBe(0);
expect(thinned[thinned.length - 1]).toBe(999);
});
it('spaces what it keeps evenly rather than dropping a run', () => {
const thinned = thinRoute(Array.from({ length: 100 }, (_, i) => i), 5);
expect(thinned).toEqual([0, 25, 50, 74, 99]);
});
});
describe('collectCountries', () => {
it('orders by first visit, so the list retells the route', () => {
const points = [
point({ country: 'GT', date: '2026-03-10' }),
point({ country: 'MX', date: '2026-03-01' }),
point({ country: 'BZ', date: '2026-03-05' }),
];
expect(collectCountries(points, {}).map(c => c.code)).toEqual(['MX', 'BZ', 'GT']);
});
it('counts the stops in each country', () => {
const points = [
point({ country: 'IS', date: '2026-06-02' }),
point({ country: 'IS', date: '2026-06-03' }),
point({ country: 'FO', date: '2026-06-09' }),
];
const [iceland, faroes] = collectCountries(points, {});
expect(iceland.places).toBe(2);
expect(faroes.places).toBe(1);
});
it('takes the earliest date as the first visit, whatever order the stops arrive in', () => {
const points = [
point({ country: 'FR', date: '2026-07-08' }),
point({ country: 'FR', date: '2026-05-28' }),
];
expect(collectCountries(points, {})[0].firstVisit).toBe('2026-05-28');
});
it('uppercases the code so a lowercase row cannot become a second country', () => {
const points = [point({ country: 'is' }), point({ country: 'IS' })];
const countries = collectCountries(points, {});
expect(countries).toHaveLength(1);
expect(countries[0].code).toBe('IS');
});
it('ignores stops with no country', () => {
expect(collectCountries([point(), point({ country: null })], {})).toEqual([]);
});
it('puts undated countries after dated ones, in the order they appeared', () => {
const points = [
point({ country: 'NO' }),
point({ country: 'SE', date: '2026-01-02' }),
point({ country: 'DK' }),
];
expect(collectCountries(points, {}).map(c => c.code)).toEqual(['SE', 'NO', 'DK']);
});
it('uses the supplied name and falls back to the code', () => {
const points = [point({ country: 'IS' }), point({ country: 'XX' })];
const countries = collectCountries(points, { IS: 'Iceland' });
expect(countries[0].name).toBe('Iceland');
expect(countries[1].name).toBe('XX');
});
});
describe('computeJourneyStats', () => {
const base = {
journeyId: 7,
entries: 3,
photos: 12,
places: 9,
tripDates: [],
countryNames: { IS: 'Iceland' },
};
it('carries the counts through untouched', () => {
const stats = computeJourneyStats({ ...base, points: [] });
expect(stats.journeyId).toBe(7);
expect(stats.steps).toBe(3);
expect(stats.photos).toBe(12);
expect(stats.places).toBe(9);
});
it('takes its dates from the stops when they have them', () => {
const stats = computeJourneyStats({
...base,
points: [
point({ lat: 64, lng: -22, date: '2026-06-02' }),
point({ lat: 65, lng: -18, date: '2026-06-15' }),
],
});
expect(stats.start).toBe('2026-06-02');
expect(stats.end).toBe('2026-06-15');
expect(stats.days).toBe(14);
});
/*
* The case this exists for: a journey assembled from trips that nobody has
* written entries for still lasted a fortnight, and printing "0 DAYS" over it
* would be worse than using the trip's own dates.
*/
it('falls back to the trips dates when no stop is dated', () => {
const stats = computeJourneyStats({
...base,
points: [point({ lat: 64, lng: -22 })],
tripDates: [{ start: '2026-06-01', end: '2026-06-10' }],
});
expect(stats.start).toBe('2026-06-01');
expect(stats.end).toBe('2026-06-10');
expect(stats.days).toBe(10);
});
it('takes the widest span across several trips', () => {
const stats = computeJourneyStats({
...base,
points: [],
tripDates: [
{ start: '2026-06-05', end: '2026-06-08' },
{ start: '2026-05-28', end: '2026-07-08' },
],
});
expect(stats.start).toBe('2026-05-28');
expect(stats.end).toBe('2026-07-08');
});
it('has no dates at all when nothing carries one', () => {
const stats = computeJourneyStats({ ...base, points: [point({ lat: 1, lng: 1 })] });
expect(stats.start).toBeNull();
expect(stats.end).toBeNull();
expect(stats.days).toBe(0);
});
it('rounds distance and furthest to whole metres', () => {
const stats = computeJourneyStats({
...base,
points: [point({ lat: 0, lng: 0 }), point({ lat: 0.1234, lng: 0.4321 })],
});
expect(Number.isInteger(stats.distance)).toBe(true);
expect(Number.isInteger(stats.furthest)).toBe(true);
expect(stats.distance).toBeGreaterThan(0);
});
it('caps the route and reports the capped points', () => {
const many = Array.from({ length: 900 }, (_, i) => point({ lat: i * 0.01, lng: 0 }));
const stats = computeJourneyStats({ ...base, points: many });
expect(stats.points).toHaveLength(MAX_ROUTE_POINTS);
});
it('measures distance over the thinned route, so the figure matches the drawn line', () => {
const many = Array.from({ length: 900 }, (_, i) => point({ lat: i * 0.01, lng: 0 }));
const stats = computeJourneyStats({ ...base, points: many });
const drawn = routeDistance(stats.points);
expect(stats.distance).toBe(Math.round(drawn));
});
it('drops stops with unusable coordinates from the arithmetic but keeps the counts', () => {
const stats = computeJourneyStats({
...base,
points: [
point({ lat: 0, lng: 0, date: '2026-01-01' }),
point({ lat: Number.NaN, lng: 5, date: '2026-01-02' }),
point({ lat: 0, lng: 1, date: '2026-01-03' }),
],
});
expect(stats.distance).toBe(Math.round(haversine({ lat: 0, lng: 0 }, { lat: 0, lng: 1 })));
expect(stats.points).toHaveLength(3);
});
it('keeps each stop label and date on the route it returns', () => {
const stats = computeJourneyStats({
...base,
points: [point({ lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS' })],
});
expect(stats.points[0]).toEqual({
lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS',
});
});
it('names the countries from the map it was given', () => {
const stats = computeJourneyStats({
...base,
points: [point({ lat: 64, lng: -22, country: 'IS', date: '2026-06-02' })],
});
expect(stats.countries).toEqual([
{ code: 'IS', name: 'Iceland', places: 1, firstVisit: '2026-06-02' },
]);
});
it('is all zeroes and empty lists for a journey with nothing in it', () => {
const stats = computeJourneyStats({
journeyId: 1, points: [], entries: 0, photos: 0, places: 0, tripDates: [], countryNames: {},
});
expect(stats).toEqual({
journeyId: 1,
distance: 0,
days: 0,
steps: 0,
photos: 0,
places: 0,
furthest: 0,
countries: [],
points: [],
start: null,
end: null,
});
});
});
+159 -91
View File
@@ -7,6 +7,7 @@ import { journeyThumbName } from '../../../src/nest/memories/thumbnail.service';
import type { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
import type { JourneyService } from '../../../src/nest/journey/journey.service';
import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import type { User } from '../../../src/types';
@@ -28,6 +29,20 @@ const storageStub = {
// to exist so the constructor can call it.
const captureBackfillStub = { schedule: vi.fn(), run: vi.fn() } as unknown as PhotoCaptureBackfillService;
/**
* Build the controller.
*
* Storage and the Studio book (#1973) are injected dependencies that almost no
* case here is about, and naming all four at every call site is what made the
* previous shape unmaintainable — a fifth dependency would have meant touching
* ninety lines. Both are stubbed: pass a partial book when the case IS about
* the book, and reach for `storageStub`'s own mocks when it is about an object
* being stored or served.
*/
function ctl(service: JourneyService, books: Partial<JourneyBookService> = {}): JourneyController {
return new JourneyController(service, storageStub, books as JourneyBookService, captureBackfillStub);
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
@@ -49,130 +64,130 @@ beforeEach(() => vi.clearAllMocks());
describe('JourneyController', () => {
it('GET / lists; POST / 400 without title, else creates', () => {
expect(new JourneyController(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).list(user)).toEqual({ journeys: [{ id: 1 }] });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
expect(ctl(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).list(user)).toEqual({ journeys: [{ id: 1 }] });
expect(thrown(() => ctl(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
const createJourney = vi.fn().mockReturnValue({ id: 9 });
expect(new JourneyController(svc({ createJourney } as Partial<JourneyService>), storageStub, captureBackfillStub).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(ctl(svc({ createJourney } as Partial<JourneyService>)).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] });
});
it('GET /suggestions + /available-trips', () => {
expect(new JourneyController(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
expect(new JourneyController(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
expect(ctl(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
expect(ctl(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>)).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
});
it('PATCH/DELETE entries map 404', () => {
expect(thrown(() => new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
expect(thrown(() => new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteEntry(user, '3')).toEqual({ success: true });
expect(thrown(() => ctl(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(ctl(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
expect(thrown(() => ctl(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3')).toEqual({ success: true });
});
it('provider-photos: batch, single 400/403, success', () => {
const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>);
expect(new JourneyController(batch, storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(thrown(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
});
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
expect(thrown(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 });
const c = new JourneyController(svc({ linkPhotoToEntry } as Partial<JourneyService>), storageStub, captureBackfillStub);
const c = ctl(svc({ linkPhotoToEntry } as Partial<JourneyService>));
expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1);
// accepts the canonical journey_photo_id, 403 when the service refuses
expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('unlink photo (204) maps 404; delete photo 404 then removes the object', async () => {
expect(thrown(() => new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
expect(new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).unlinkPhoto(user, '3', '7')).toBeUndefined();
expect(await thrownAsync(() => new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7')).toEqual({ success: true });
expect(thrown(() => ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
expect(ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7')).toBeUndefined();
expect(await thrownAsync(() => ctl(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
});
it('gallery upload 400 no files / 403 not allowed, else commits + returns photos', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
expect(await thrownAsync(() => ctl(svc()).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>)).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
expect(storageStub.put).toHaveBeenCalledWith('journey', 'a.jpg', { tmpPath: undefined });
});
it('gallery video: 400 no video, 403 not allowed, else stores the clip + poster (#823)', async () => {
const files = { video: [{ filename: 'v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg' } as Express.Multer.File] };
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' } });
expect(await thrownAsync(() => ctl(svc()).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' } });
// Not-allowed after commit → the final objects are deleted through storage
// (the pre-commit unlink would miss them: file.path is already consumed).
const withPaths = { video: [{ filename: 'v.mp4', path: '/nonexistent/v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg', path: '/nonexistent/p.jpg' } as Express.Multer.File] };
expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', withPaths, { duration_ms: 'abc' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>)).uploadGalleryVideo(user, '3', withPaths, { duration_ms: 'abc' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'v.mp4');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'p.jpg');
const up = vi.fn().mockReturnValue([{ id: 7 }]);
expect(await new JourneyController(svc({ uploadGalleryPhotos: up } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', files, { duration_ms: '4200' })).toEqual({ photos: [{ id: 7 }] });
expect(await ctl(svc({ uploadGalleryPhotos: up } as Partial<JourneyService>)).uploadGalleryVideo(user, '3', files, { duration_ms: '4200' })).toEqual({ photos: [{ id: 7 }] });
expect(up).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v.mp4', thumbnail: 'journey/p.jpg', mediaType: 'video', durationMs: 4200 }]);
// No poster + no duration → thumbnail undefined, durationMs null.
const up2 = vi.fn().mockReturnValue([{ id: 8 }]);
await new JourneyController(svc({ uploadGalleryPhotos: up2 } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {});
await ctl(svc({ uploadGalleryPhotos: up2 } as Partial<JourneyService>)).uploadGalleryVideo(user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {});
expect(up2).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v2.mp4', thumbnail: undefined, mediaType: 'video', durationMs: null }]);
});
it('provider-photos forwards per-asset media_types for gallery and entries (#823)', () => {
const add = vi.fn().mockReturnValue({ id: 1 });
new JourneyController(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
ctl(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video');
expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image');
const addOne = vi.fn().mockReturnValue({ id: 2 });
new JourneyController(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
ctl(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video');
// Entry path mirrors the gallery path.
const eAdd = vi.fn().mockReturnValue({ id: 3 });
new JourneyController(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
ctl(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video');
const eOne = vi.fn().mockReturnValue({ id: 4 });
new JourneyController(svc({ addProviderPhoto: eOne } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
ctl(svc({ addProviderPhoto: eOne } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video');
});
it('GET/PATCH/DELETE /:id map 404', () => {
expect(thrown(() => new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).get(user, '9')).toEqual({ id: 9 });
expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).get(user, '9')).toEqual({ id: 9 });
expect(thrown(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
});
it('trips: POST 400 without trip_id / 403, DELETE 403', () => {
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
expect(thrown(() => new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
expect(thrown(() => ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
expect(thrown(() => ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entries under journey: list 404, create 400/404, reorder 400/403', () => {
expect(thrown(() => new JourneyController(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(new JourneyController(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
expect(thrown(() => new JourneyController(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
expect(thrown(() => new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
expect(thrown(() => ctl(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
expect(thrown(() => ctl(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
expect(thrown(() => ctl(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('contributors: add 400/403, update 403, remove 403', () => {
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
expect(thrown(() => new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
expect(thrown(() => ctl(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
expect(thrown(() => ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('preferences 403, share-link get/set/delete', () => {
expect(thrown(() => new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
expect(thrown(() => new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
expect(thrown(() => new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
expect(thrown(() => ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
expect(thrown(() => ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entry photo upload mirrors to Immich only when opted in', async () => {
@@ -180,84 +195,82 @@ describe('JourneyController', () => {
const uploadToImmich = vi.fn().mockResolvedValue('immich-1');
const setPhotoProvider = vi.fn();
const s = svc({ addPhoto, immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich, setPhotoProvider } as Partial<JourneyService>);
const res = await new JourneyController(s, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
const res = await ctl(s).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
expect(setPhotoProvider).toHaveBeenCalledWith(5, 'immich', 'immich-1', 1);
expect(res).toEqual({ photos: [{ id: 5, provider: 'immich', asset_id: 'immich-1', owner_id: 1 }] });
const noOptIn = svc({ addPhoto: vi.fn().mockReturnValue({ id: 6 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(false), uploadToImmich } as Partial<JourneyService>);
await new JourneyController(noOptIn, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
await ctl(noOptIn).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above
});
it('entry photo upload: 400 no files, 403 when nothing added, swallows immich errors and empty ids', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc()).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => ctl(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
// opted in but the immich upload throws → best-effort, the local photo still wins
const setPhotoProvider = vi.fn();
const blowsUp = svc({ addPhoto: vi.fn().mockReturnValue({ id: 8 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockRejectedValue(new Error('immich down')), setPhotoProvider } as Partial<JourneyService>);
expect(await new JourneyController(blowsUp, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
expect(await ctl(blowsUp).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
expect(setPhotoProvider).not.toHaveBeenCalled();
// opted in but immich returns a falsy id → no provider stamping
const noId = svc({ addPhoto: vi.fn().mockReturnValue({ id: 9 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockResolvedValue(''), setPhotoProvider } as Partial<JourneyService>);
expect(await new JourneyController(noId, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
expect(await ctl(noId).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
});
it('provider-photos batch passes the passphrase through when present', () => {
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
new JourneyController(svc({ addProviderPhoto } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
ctl(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image');
// single-photo success path
expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
expect(ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
});
it('PATCH photos: 404 then returns the updated photo', () => {
expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
expect(thrown(() => ctl(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(ctl(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
});
it('DELETE photo removes the storage object when a path exists', async () => {
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7')).toEqual({ success: true });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'a.jpg');
// ...and the derived thumb goes with it (spec fix #2).
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/a.jpg'));
// a vanished object is swallowed
(storageStub.delete as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ENOENT'));
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '8')).toEqual({ success: true });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '8')).toEqual({ success: true });
// a file_path outside the journey category never reaches storage
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '9')).toEqual({ success: true });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '9')).toEqual({ success: true });
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => {
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial<JourneyService>), storageStub, captureBackfillStub);
const batch = ctl(svc({ addProviderPhotoToGallery } as Partial<JourneyService>));
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image');
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
expect(thrown(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
});
it('DELETE gallery photo: 404, then removes the object when present', async () => {
expect(await thrownAsync(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
expect(await thrownAsync(() => ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
// no file_path → nothing to delete, returns void
expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '7')).toBeUndefined();
await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '8');
expect(await ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '7')).toBeUndefined();
await ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '8');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'g.jpg');
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/g.jpg'));
(storageStub.delete as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ENOENT'));
expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '9')).toBeUndefined();
expect(await ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '9')).toBeUndefined();
});
it('DELETE gallery video also reclaims its uploaded poster (spec fix #2 extension)', async () => {
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
await new JourneyController(
await ctl(
svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 10, file_path: 'journey/clip.mp4', thumbnail_path: 'journey/poster.jpg' }) } as Partial<JourneyService>),
storageStub,
captureBackfillStub,
).deleteGalleryPhoto(user, '10');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'clip.mp4');
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/clip.mp4'));
@@ -265,45 +278,100 @@ describe('JourneyController', () => {
});
it('PATCH /:id returns the updated journey on success', () => {
expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
expect(ctl(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
});
it('cover upload: 400 without file, 404 when the journey is gone, else commits + returns the journey', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await thrownAsync(() => ctl(svc()).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
expect(await thrownAsync(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } });
const updateJourney = vi.fn().mockReturnValue({ id: 9, cover_image: 'journey/c.jpg' });
expect(await new JourneyController(svc({ updateJourney } as Partial<JourneyService>), storageStub, captureBackfillStub).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' });
expect(await ctl(svc({ updateJourney } as Partial<JourneyService>)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' });
expect(storageStub.put).toHaveBeenCalledWith('journey', 'c.jpg', { tmpPath: undefined });
expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' });
});
it('DELETE /:id and trips/contributors success paths', () => {
expect(new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).remove(user, '9')).toEqual({ success: true });
expect(new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeContributor(user, '9', '2')).toEqual({ success: true });
expect(ctl(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).remove(user, '9')).toEqual({ success: true });
expect(ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(ctl(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeContributor(user, '9', '2')).toEqual({ success: true });
});
it('addContributor defaults the role to viewer when omitted', () => {
const addContributor = vi.fn().mockReturnValue(true);
new JourneyController(svc({ addContributor } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 });
ctl(svc({ addContributor } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 });
expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer');
});
it('createEntry returns the entry when the journey exists', () => {
expect(new JourneyController(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
expect(ctl(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
});
it('reorderEntries succeeds for a numeric array', () => {
expect(new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
expect(ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
});
it('preferences returns the result on success', () => {
expect(new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>), storageStub, captureBackfillStub).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
expect(ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
});
it('deleteShareLink returns success when removed', () => {
expect(new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteShareLink(user, '9')).toEqual({ success: true });
expect(ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteShareLink(user, '9')).toEqual({ success: true });
});
/*
* The Studio book (#1973). Its access shape is the same as everything around
* it — the service answers null for a journey the caller cannot see, and that
* is a 404 rather than a 403, so the route cannot be used to find out which
* journeys exist.
*/
it('book: 404 vs a journey with no book yet, and the save status codes', () => {
// A journey with no book yet is not an error: Studio opens on it, lays a
// book out and saves. 404 here would make "no book" and "no journey"
// indistinguishable to the client.
expect(ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).getBook(user, '9')).toEqual({ book: null });
expect(thrown(() => ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).getBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc(), { getBook: vi.fn().mockReturnValue({ id: 3, version: 4 }), canOpen: vi.fn() }).getBook(user, '9')).toEqual({ book: { id: 3, version: 4 } });
expect(thrown(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 404, body: { error: 'Journey not found' } });
// The record comes back bare, not in an envelope — the contract in
// shared/src/book/book-store.schema.ts is the record itself.
const broadcastSaved = vi.fn();
const saveBook = vi.fn().mockReturnValue({ record: { id: 3, version: 5 } });
expect(ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { title: 'T', document: { v: 1 }, baseVersion: 4 } as never, 'sock-7')).toEqual({ id: 3, version: 5 });
expect(saveBook).toHaveBeenCalledWith(9, 1, { title: 'T', document: { v: 1 }, baseVersion: 4 });
// Forwarded so the client that just saved does not process its own change.
expect(broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7');
// A missing title is '' rather than undefined: the column is NOT NULL and
// an untitled book is an ordinary thing to have.
const untitled = vi.fn().mockReturnValue({ record: { id: 3, version: 1 } });
ctl(svc(), { saveBook: untitled, broadcastSaved: vi.fn() }).saveBook(user, '9', { document: {} } as never);
expect(untitled.mock.calls[0][2].title).toBe('');
});
it('book: a conflict is a 409 that carries the other version', () => {
/*
* Two people editing is the normal case, not an exception. A bare 409 would
* make showing what the other version is a second round trip, at exactly
* the moment someone is worried about losing work.
*/
const broadcastSaved = vi.fn();
const saveBook = vi.fn().mockReturnValue({ conflict: { id: 3, version: 6, title: 'Theirs' } });
expect(thrown(() => ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never))).toEqual({
status: 409,
body: { error: 'Book was changed by someone else', current: { id: 3, version: 6, title: 'Theirs' } },
});
// Nothing was written, so nobody is told anything moved.
expect(broadcastSaved).not.toHaveBeenCalled();
});
it('book: delete answers 204, or 404 out of reach', () => {
expect(ctl(svc(), { deleteBook: vi.fn().mockReturnValue(true) }).deleteBook(user, '9')).toBeUndefined();
// False means there was nothing to delete, which is still a 204 — deleting
// a book that is already gone is not a failure.
expect(ctl(svc(), { deleteBook: vi.fn().mockReturnValue(false) }).deleteBook(user, '9')).toBeUndefined();
expect(thrown(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null) }).deleteBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
});
});
+311 -4
View File
@@ -14,7 +14,11 @@ vi.mock('../../../src/plugin-event-sink', () => ({
import { RealtimeGateway } from '../../../src/nest/realtime/realtime.gateway';
import {
bookPeers,
broadcast,
broadcastToBook,
joinBook,
leaveBook,
broadcastToUser,
getOnlineUserIds,
joinRoom,
@@ -26,6 +30,7 @@ import {
import { emitPluginEvent } from '../../../src/plugin-event-sink';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import type { User } from '../../../src/types';
interface FakeSocket extends TrekWebSocket {
@@ -58,8 +63,12 @@ const db = {
const consumeWithMeta = vi.fn();
const tokens = { consumeWithMeta } as unknown as EphemeralTokenService;
/** Everything is reachable except journey 4, which stands in for no access. */
const canAccessJourney = vi.fn((journeyId: number) => (journeyId === 4 ? null : { id: journeyId }));
const journeys = { canAccessJourney } as unknown as JourneyDomainService;
function connect(url: string) {
const gw = new RealtimeGateway(db, tokens);
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
gw.handleConnection(ws, { url } as never);
return { gw, ws };
@@ -120,7 +129,7 @@ describe('RealtimeGateway handshake', () => {
});
it('WSGW-007b: survives an upgrade request with no url, rather than throwing at it', () => {
const gw = new RealtimeGateway(db, tokens);
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
expect(() => gw.handleConnection(ws, {} as never)).not.toThrow();
expect(ws.closedWith).toEqual([4001, 'Authentication required']);
@@ -175,7 +184,7 @@ describe('RealtimeGateway heartbeat', () => {
alive.isAlive = true;
const stale = socket();
stale.isAlive = false;
const gw = new RealtimeGateway(db, tokens);
const gw = new RealtimeGateway(db, tokens, journeys);
gw.afterInit({ clients: new Set([alive, stale]) } as never);
vi.advanceTimersByTime(30_000);
@@ -196,7 +205,7 @@ describe('RealtimeGateway heartbeat', () => {
try {
const ws = socket();
ws.isAlive = true;
const gw = new RealtimeGateway(db, tokens);
const gw = new RealtimeGateway(db, tokens, journeys);
gw.afterInit({ clients: new Set([ws]) } as never);
gw.onModuleDestroy();
@@ -293,3 +302,301 @@ describe('ws-state fan-out', () => {
expect(getOnlineUserIds()).toEqual(new Set());
});
});
/**
* ── Studio books (#1973) ────────────────────────────────────────────────
*
* Presence and pointers for people editing the same photo book. Nothing here
* is written down: the room IS the state, and a socket that goes takes its
* pointer with it.
*/
describe('book rooms', () => {
let nextJourney = 100;
function joined(journeyId: number) {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
const reply = gw.handleBookJoin({ journeyId }, ws);
return { gw, ws, reply };
}
it('WSGW-BOOK-001: admits a socket to a journey it may see', () => {
const j = nextJourney++;
const { reply } = joined(j);
expect(reply).toEqual({ type: 'book:joined', journeyId: j });
expect(bookPeers(j).map(p => p.userId)).toEqual([3]);
});
/* Same shape as the trip room's refusal, and for the same reason. */
it('WSGW-BOOK-002: refuses a journey the user cannot see, and adds nobody', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(gw.handleBookJoin({ journeyId: 4 }, ws)).toEqual({ type: 'error', message: 'Access denied' });
expect(bookPeers(4)).toEqual([]);
});
it('WSGW-BOOK-003: tells everyone in the book who is in it', () => {
const j = nextJourney++;
const { ws: first } = joined(j);
first.sent.length = 0;
joined(j);
const peers = first.sent.map(raw => JSON.parse(raw)).filter(m => m.type === 'journey:book:peers');
expect(peers).toHaveLength(1);
expect(peers[0].peers).toHaveLength(2);
expect(peers[0].journeyId).toBe(j);
});
it('WSGW-BOOK-004: leaving empties the room and says so', () => {
const j = nextJourney++;
const { gw, ws } = joined(j);
expect(gw.handleBookLeave({ journeyId: j }, ws)).toEqual({ type: 'book:left', journeyId: j });
expect(bookPeers(j)).toEqual([]);
});
/*
* The one that leaves a ghost if it is missed: a closed tab whose arrow stays
* on everyone else's page, belonging to nobody.
*/
it('WSGW-BOOK-005: a dropped connection leaves the book too', () => {
const j = nextJourney++;
const { gw, ws } = joined(j);
const other = joined(j);
other.ws.sent.length = 0;
expect(bookPeers(j)).toHaveLength(2);
gw.handleDisconnect(ws);
expect(bookPeers(j)).toHaveLength(1);
const peers = other.ws.sent.map(raw => JSON.parse(raw)).filter(m => m.type === 'journey:book:peers');
expect(peers[peers.length - 1].peers).toHaveLength(1);
});
});
describe('book pointers', () => {
let nextJourney = 200;
function pair() {
const journeyId = nextJourney++;
const gw = new RealtimeGateway(db, tokens, journeys);
const mine = socket();
const theirs = socket();
registerSocket(mine, { id: 3, username: 'm' } as User);
registerSocket(theirs, { id: 4, username: 'other' } as User);
gw.handleBookJoin({ journeyId }, mine);
gw.handleBookJoin({ journeyId }, theirs);
mine.sent.length = 0;
theirs.sent.length = 0;
return { gw, mine, theirs, journeyId };
}
const cursorsIn = (ws: FakeSocket) =>
ws.sent.map(raw => JSON.parse(raw)).filter(m => m.type === 'journey:book:cursor');
it('WSGW-CUR-001: forwards a pointer to the others, not back to the sender', () => {
const { gw, mine, theirs, journeyId } = pair();
gw.handleBookCursor({ journeyId, spreadIndex: 2, x: 105.5, y: 60 }, mine);
expect(cursorsIn(mine)).toEqual([]);
expect(cursorsIn(theirs)).toHaveLength(1);
expect(cursorsIn(theirs)[0]).toMatchObject({ journeyId, userId: 3, spreadIndex: 2, x: 105.5, y: 60 });
});
/*
* The room is the authorisation. Nothing is checked against the database on
* this path — it runs ten times a second — so a socket that never joined has
* to reach nobody.
*/
it('WSGW-CUR-002: a socket that never joined reaches nobody', () => {
const { gw, theirs, journeyId } = pair();
const stranger = socket();
registerSocket(stranger, { id: 5, username: 'x' } as User);
gw.handleBookCursor({ journeyId, spreadIndex: 0, x: 1, y: 1 }, stranger);
expect(cursorsIn(theirs)).toEqual([]);
});
it('WSGW-CUR-003: a pointer leaving the page travels as null', () => {
const { gw, mine, theirs, journeyId } = pair();
gw.handleBookCursor({ journeyId, spreadIndex: 0, x: null, y: null }, mine);
expect(cursorsIn(theirs)[0]).toMatchObject({ x: null, y: null });
});
it('WSGW-CUR-004: refuses nonsense coordinates rather than passing them on', () => {
const { gw, mine, theirs, journeyId } = pair();
gw.handleBookCursor({ journeyId, spreadIndex: -4, x: Number.NaN, y: Infinity }, mine);
expect(cursorsIn(theirs)[0]).toMatchObject({ spreadIndex: 0, x: null, y: null });
});
});
/*
* ── The ways a book message is ignored ──────────────────────────────────
*
* Every one of these is a message that must produce nothing at all: no reply,
* no room change, no broadcast. They are worth naming because the failure is
* silent in each case — a handler that quietly did something with a missing
* journey id would not throw, it would put a socket somewhere it does not
* belong and nobody would see it until two strangers shared a page.
*/
describe('book messages that are refused', () => {
let nextJourney = 300;
it('WSGW-BOOK-006: a join without a journey id is ignored', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(gw.handleBookJoin({}, ws)).toBeUndefined();
expect(ws.sent).toEqual([]);
});
/* An unauthenticated socket never got as far as the registry. */
it('WSGW-BOOK-007: a join from a socket with no user is ignored', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const j = nextJourney++;
expect(gw.handleBookJoin({ journeyId: j }, socket())).toBeUndefined();
expect(bookPeers(j)).toEqual([]);
});
it('WSGW-BOOK-008: a journey id that is not a number is refused, not joined', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(gw.handleBookJoin({ journeyId: 'not-a-journey' }, ws))
.toEqual({ type: 'error', message: 'Access denied' });
});
it('WSGW-BOOK-009: a leave without a journey id is ignored', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(gw.handleBookLeave({}, ws)).toBeUndefined();
});
/* Leaving a room nobody is in must not create one on the way out. */
it('WSGW-BOOK-010: leaving a book that was never joined does nothing', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
const j = nextJourney++;
expect(gw.handleBookLeave({ journeyId: j }, ws)).toEqual({ type: 'book:left', journeyId: j });
expect(bookPeers(j)).toEqual([]);
});
it('WSGW-CUR-005: a pointer without a journey id reaches nobody', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const mine = socket();
const theirs = socket();
registerSocket(mine, { id: 3, username: 'm' } as User);
registerSocket(theirs, { id: 4, username: 'o' } as User);
const j = nextJourney++;
gw.handleBookJoin({ journeyId: j }, mine);
gw.handleBookJoin({ journeyId: j }, theirs);
theirs.sent.length = 0;
expect(gw.handleBookCursor({ x: 1, y: 1 }, mine)).toBeUndefined();
expect(theirs.sent).toEqual([]);
});
it('WSGW-CUR-006: a pointer from a socket with no user reaches nobody', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const theirs = socket();
registerSocket(theirs, { id: 4, username: 'o' } as User);
const j = nextJourney++;
gw.handleBookJoin({ journeyId: j }, theirs);
theirs.sent.length = 0;
expect(gw.handleBookCursor({ journeyId: j, x: 1, y: 1 }, socket())).toBeUndefined();
expect(theirs.sent).toEqual([]);
});
});
/*
* ── Sockets that are in a room but cannot be talked to ──────────────────
*
* A room holds sockets, and a socket can stop being usable without leaving:
* the connection drops and the close handler has not run yet, or it was put in
* a room without ever being registered. Both are states the peer list and the
* broadcast have to walk past rather than trip over, because the alternative is
* an editor whose peer list shows a ghost, or a throw on a path that runs ten
* times a second.
*/
describe('book rooms with unusable sockets in them', () => {
let nextJourney = 400;
it('WSST-BOOK-001: a closed socket is not a peer', () => {
const j = nextJourney++;
const live = socket();
const dead = socket();
registerSocket(live, { id: 3, username: 'm' } as User);
registerSocket(dead, { id: 9, username: 'gone' } as User);
joinBook(live, j);
joinBook(dead, j);
(dead as { readyState: number }).readyState = 3;
expect(bookPeers(j).map(p => p.userId)).toEqual([3]);
});
it('WSST-BOOK-002: a socket that was never registered is not a peer', () => {
const j = nextJourney++;
const known = socket();
registerSocket(known, { id: 3, username: 'm' } as User);
joinBook(known, j);
joinBook(socket(), j);
expect(bookPeers(j).map(p => p.userId)).toEqual([3]);
});
it('WSST-BOOK-003: a broadcast skips a closed socket instead of writing to it', () => {
const j = nextJourney++;
const live = socket();
const dead = socket();
registerSocket(live, { id: 3, username: 'm' } as User);
registerSocket(dead, { id: 9, username: 'gone' } as User);
joinBook(live, j);
joinBook(dead, j);
(dead as { readyState: number }).readyState = 3;
broadcastToBook(j, { type: 'x' });
expect(live.sent).toHaveLength(1);
expect(dead.sent).toEqual([]);
});
it('WSST-BOOK-004: a broadcast to a book nobody is in goes nowhere', () => {
expect(() => broadcastToBook(nextJourney++, { type: 'x' })).not.toThrow();
});
/*
* One person, two tabs, two books: the second join must extend what the
* socket is already in rather than replacing it, or leaving one book would
* take the other with it.
*/
it('WSST-BOOK-005: a socket can be in two books at once', () => {
const a = nextJourney++;
const b = nextJourney++;
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
joinBook(ws, a);
joinBook(ws, b);
leaveBook(ws, a);
expect(bookPeers(a)).toEqual([]);
expect(bookPeers(b).map(p => p.userId)).toEqual([3]);
});
it('WSST-BOOK-006: leaving a book that does not exist is not an error', () => {
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(() => leaveBook(ws, nextJourney++)).not.toThrow();
});
});
@@ -68,6 +68,7 @@ beforeEach(() => {
handlers = [
{ message: 'join', callback: (data) => { handled.push(data); return { type: 'joined' }; } },
{ message: 'leave', callback: (data) => { handled.push(data); return undefined; } },
{ message: 'book:cursor', callback: (data) => { handled.push(data); return undefined; } },
];
});
@@ -185,6 +186,51 @@ describe('TrekWsAdapter connection binding', () => {
});
});
/**
* The pointer exemption (#1973).
*
* Studio's pointers move about ten times a second per editor, three times what
* the tight limit allows on its own — and that limit cannot simply be raised,
* because it is what stops a client flooding the process. So there are two
* ceilings, and only pointers are exempt from the tight one.
*/
describe('TrekWsAdapter pointer budget', () => {
it('WSAD-012: lets pointers past the tight limit', () => {
const socket = fakeSocket();
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
for (let i = 0; i < 60; i++) socket.emit('message', frame({ type: 'book:cursor', x: i }));
expect(handled).toHaveLength(60);
expect(socket.sent).toHaveLength(0);
});
/* Exempt from the tight limit, not from the ceiling. */
it('WSAD-013: cuts a pointer flood off at the outer ceiling', () => {
const socket = fakeSocket();
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
for (let i = 0; i < 260; i++) socket.emit('message', frame({ type: 'book:cursor', x: i }));
expect(handled.length).toBeLessThanOrEqual(200);
expect(socket.sent.some((raw) => raw.includes('Rate limit'))).toBe(true);
});
/*
* The exemption is for pointers alone. A client sending them cannot use them
* to buy itself extra room for anything else.
*/
it('WSAD-014: a pointer flood does not loosen the limit on everything else', () => {
const socket = fakeSocket();
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
for (let i = 0; i < 50; i++) socket.emit('message', frame({ type: 'book:cursor', x: i }));
for (let i = 0; i < 31; i++) socket.emit('message', frame({ type: 'join', tripId: 7 }));
expect(socket.sent.some((raw) => raw.includes('Rate limit'))).toBe(true);
});
});
describe('TrekWsAdapter server creation', () => {
/** A real http.Server is never listened on; ws only needs it to hang an upgrade handler. */
const httpServer = { on: vi.fn(), once: vi.fn(), removeListener: vi.fn(), emit: vi.fn() } as unknown as HttpServer;