mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
Merge remote-tracking branch 'origin/feat/storage-driver' into feat/storage-driver
This commit is contained in:
@@ -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}`)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user