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

This commit is contained in:
jubnl
2026-08-20 09:14:25 +02:00
146 changed files with 31120 additions and 852 deletions
+184 -1
View File
@@ -47,16 +47,28 @@ vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal)
});
const { jsvc } = vi.hoisted(() => ({
jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() },
jsvc: {
listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(),
journeyStats: vi.fn(),
},
}));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
const { booksvc } = vi.hoisted(() => ({
booksvc: {
getBook: vi.fn(), canOpen: vi.fn(), saveBook: vi.fn(),
deleteBook: vi.fn(), broadcastSaved: vi.fn(),
},
}));
import { JourneyBookService } from '../../src/nest/journey/journey-book.service';
import { JourneyModule } from '../../src/nest/journey/journey.module';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
describe('Journey e2e (real auth guard + temp SQLite)', () => {
let server: Server;
@@ -68,12 +80,19 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
.useValue(jsvc)
.overrideProvider(JourneyShareService)
.useValue(sharesvc)
.overrideProvider(JourneyBookService)
.useValue(booksvc)
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
// by metatype, exactly as they do under buildApp(). Without it a book save
// would reach the service unvalidated here and the e2e would pass on a
// route that 400s in production.
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
@@ -89,6 +108,11 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
beforeEach(() => {
isAddonEnabled.mockReturnValue(true);
booksvc.getBook.mockReset();
booksvc.canOpen.mockReset();
booksvc.saveBook.mockReset();
booksvc.deleteBook.mockReset();
booksvc.broadcastSaved.mockReset();
});
afterAll(async () => {
@@ -128,6 +152,165 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
expect(res.body).toEqual({ error: 'Journey not found' });
});
/*
* The journey figures TREK Studio prints (#1973). Read-only and derived, so
* what e2e adds over the controller unit test is that the addon gate and the
* auth guard both run in front of it — a route that reports where someone has
* been must not be reachable without a session.
*/
it('401 for the journey stats without a session', async () => {
expect((await request(server).get('/api/journeys/9/stats')).status).toBe(401);
});
it('404 for the stats of an inaccessible journey', async () => {
jsvc.journeyStats.mockReturnValue(null);
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey not found' });
});
it('200 with the figures, returned bare rather than in an envelope', async () => {
jsvc.journeyStats.mockReturnValue({
journeyId: 9, distance: 1_189_000, days: 14, steps: 14, photos: 57, places: 0,
furthest: 408_000,
countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }],
points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS' }],
start: '2026-06-02', end: '2026-06-15',
});
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.distance).toBe(1_189_000);
expect(res.body.countries).toEqual([{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }]);
expect(jsvc.journeyStats).toHaveBeenCalledWith(9, 1);
});
it('404 (addon gate) for the stats when the Journey addon is disabled', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/journeys/9/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
});
/*
* ── The Studio book (#1973) ────────────────────────────────────────────
*
* What e2e adds over the service test: the addon gate and the auth guard both
* run in front of these, the 409 keeps its body (an exception filter that
* flattened it would cost the client the other version), and DELETE really
* answers 204 rather than Nest's default 200.
*/
it('401 for the book without a session', async () => {
expect((await request(server).get('/api/journeys/9/book')).status).toBe(401);
expect((await request(server).put('/api/journeys/9/book').send({ document: {} })).status).toBe(401);
expect((await request(server).delete('/api/journeys/9/book')).status).toBe(401);
});
it('404 (addon gate) for the book when the Journey addon is disabled', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
});
/*
* "No book yet" is not "no journey". Studio opens on an empty journey, lays a
* book out and saves it, so a 404 here would make the ordinary first visit
* look like a missing trip.
*/
it('200 with a null book for a journey that has none yet', async () => {
booksvc.getBook.mockReturnValue(null);
booksvc.canOpen.mockReturnValue(true);
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ book: null });
});
it('404 for the book of an inaccessible journey', async () => {
booksvc.getBook.mockReturnValue(null);
booksvc.canOpen.mockReturnValue(false);
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey not found' });
});
it('200 with the stored book', async () => {
booksvc.getBook.mockReturnValue({ id: 3, journeyId: 9, title: 'Iceland', version: 4 });
const res = await request(server).get('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.book.version).toBe(4);
expect(booksvc.getBook).toHaveBeenCalledWith(9, 1);
});
it('200 on save — not 201, the book is one object being updated', async () => {
booksvc.saveBook.mockReturnValue({ record: { id: 3, journeyId: 9, title: 'T', version: 5 } });
const res = await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.send({ title: 'T', document: { version: 1 }, baseVersion: 4 });
expect(res.status).toBe(200);
expect(res.body.version).toBe(5);
// The pipe fills the document's defaults on the way in, so the service is
// never handed a half-document to guess at.
const [journeyId, userId, input] = booksvc.saveBook.mock.calls[0];
expect([journeyId, userId]).toEqual([9, 1]);
expect(input.title).toBe('T');
expect(input.baseVersion).toBe(4);
expect(input.document.page.pageWidth).toBe(210);
expect(input.document.spreads).toEqual([]);
});
it('forwards the socket id so the saving client does not echo its own change', async () => {
booksvc.saveBook.mockReturnValue({ record: { id: 3, version: 5 } });
await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.set('X-Socket-Id', 'sock-7')
.send({ document: {} });
expect(booksvc.broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7');
});
it('409 with the current record in the body, not a bare status', async () => {
booksvc.saveBook.mockReturnValue({ conflict: { id: 3, journeyId: 9, title: 'Theirs', version: 6 } });
const res = await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.send({ document: {}, baseVersion: 4 });
expect(res.status).toBe(409);
expect(res.body.error).toBe('Book was changed by someone else');
expect(res.body.current.version).toBe(6);
expect(booksvc.broadcastSaved).not.toHaveBeenCalled();
});
it('404 on saving into an inaccessible journey', async () => {
booksvc.saveBook.mockReturnValue(null);
const res = await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.send({ document: {} });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey not found' });
});
it('400 for a save with no document at all', async () => {
const res = await request(server)
.put('/api/journeys/9/book')
.set('Cookie', sessionCookie(1))
.send({ title: 'T' });
expect(res.status).toBe(400);
expect(booksvc.saveBook).not.toHaveBeenCalled();
});
it('204 on delete, and 404 when the journey is out of reach', async () => {
booksvc.deleteBook.mockReturnValue(true);
const ok = await request(server).delete('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(ok.status).toBe(204);
booksvc.deleteBook.mockReturnValue(null);
const gone = await request(server).delete('/api/journeys/9/book').set('Cookie', sessionCookie(1));
expect(gone.status).toBe(404);
expect(gone.body).toEqual({ error: 'Journey not found' });
});
it('public journey read is unguarded (200 with a valid token, no cookie)', async () => {
const res = await request(server).get('/api/public/journey/tok');
expect(res.status).toBe(200);
+3 -1
View File
@@ -741,7 +741,9 @@ export function addJourneyContributor(
}
export function linkTripToJourney(db: Database.Database, journeyId: number, tripId: number): void {
// The column is added_at, not linked_at — this helper had no callers until
// #1973 and so had never actually run against the schema.
db.prepare(
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, linked_at) VALUES (?, ?, ?)'
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)'
).run(journeyId, tripId, Date.now());
}
+1
View File
@@ -71,6 +71,7 @@ const RESET_TABLES = [
'trip_members',
'trips',
// Journey
'journey_books',
'journey_share_tokens',
'journey_photos',
'journey_entries',
@@ -43,17 +43,19 @@ describe('incoming_leg_transport_mode migration', () => {
//
// >>> Appending a migration? Re-point the undo below at whatever yours
// >>> does. That is the whole maintenance cost of this guard.
// Trailing migrations today (a dev/feat-storage-driver merge landed five
// in one release window, so the rewind spans all of them):
// version-5 — reservation_day_positions cross-trip cleanup: undone by
// Trailing migrations today (two release windows landed on top of each
// other, so the rewind spans all six):
// version-6 — reservation_day_positions cross-trip cleanup: undone by
// re-inserting a mismatched reservation/day pair.
// version-4 — #1939 instance API key promotion: nothing to undo — the
// version-5 — #1939 instance API key promotion: nothing to undo, the
// seeded users are not admins and hold no keys, so the
// replay is a no-op by its own guards.
// version-3 — trek_photos capture metadata (#1614): column-guarded, so
// version-4 — trek_photos capture metadata (#1614): column-guarded, so
// the replay is a no-op; the from-scratch case above covers it.
// version-2 — journey_share_tokens.newest_first: undone by dropping the
// version-3 — journey_share_tokens.newest_first: undone by dropping the
// column and letting the replay put it back.
// version-2 — journey_books, the TREK Studio document store (#1973):
// undone by dropping the table and its index.
// version-1 — storage slice-2 trip_files 'files/' prefix strip: undone
// by re-inserting a prefixed row.
const upgraded = new Database(':memory:');
@@ -83,7 +85,13 @@ describe('incoming_leg_transport_mode migration', () => {
upgraded.exec('ALTER TABLE journey_share_tokens DROP COLUMN newest_first');
expect(hasNewestFirst()).toBe(false);
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 5);
upgraded.exec('DROP INDEX IF EXISTS idx_journey_books_journey');
upgraded.exec('DROP TABLE IF EXISTS journey_books');
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journey_books'").get()
).toBeUndefined();
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 6);
runMigrations(upgraded);
@@ -92,6 +100,12 @@ describe('incoming_leg_transport_mode migration', () => {
const files = upgraded.prepare('SELECT filename FROM trip_files').all() as { filename: string }[];
expect(files.map(r => r.filename)).toEqual(['aaa.pdf']);
expect(hasNewestFirst()).toBe(true);
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journey_books'").get()
).toEqual({ name: 'journey_books' });
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_journey_books_journey'").get()
).toEqual({ name: 'idx_journey_books_journey' });
expect(upgraded.prepare('SELECT version FROM schema_version').get()).toEqual({ version });
upgraded.close();
});
@@ -0,0 +1,334 @@
/**
* Storing TREK Studio books (#1973).
*
* The half worth testing hard is concurrency. Everyone on a journey can edit
* the same book, so "two saves arrive together" is the ordinary case rather
* than an edge one, and the failure mode of getting it wrong is somebody's
* afternoon disappearing with no error anywhere.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
},
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createJourney, addJourneyContributor } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import { db as dbConn } from '../../../src/db/database';
const dbs = new DatabaseService(dbConn);
const domain = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
const books = new JourneyBookService(dbs, domain);
/** A minimal document that survives normalizeBookDocument unchanged. */
function doc(title = 'one') {
return {
version: 1,
title,
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 8 },
spreads: [{ id: 's1', kind: 'inner', background: null, elements: [], parked: [] }],
};
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// -- Access -------------------------------------------------------------------
describe('access', () => {
it('refuses a journey the user cannot see', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
expect(books.getBook(journey.id, stranger.id)).toBeNull();
expect(books.saveBook(journey.id, stranger.id, { title: '', document: doc() })).toBeNull();
expect(books.deleteBook(journey.id, stranger.id)).toBeNull();
expect(books.canOpen(journey.id, stranger.id)).toBe(false);
});
/*
* A book inherits its journey's access exactly. A second permission model
* over the same object is how two rules end up disagreeing about who may do
* what.
*/
it('lets a contributor edit, not only the owner', () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, helper.id, 'editor');
const saved = books.saveBook(journey.id, helper.id, { title: 'B', document: doc() });
expect(saved && 'record' in saved).toBe(true);
expect(books.getBook(journey.id, helper.id)).not.toBeNull();
});
it('says nothing about a journey that does not exist', () => {
const { user } = createUser(testDb);
expect(books.canOpen(999_999, user.id)).toBe(false);
expect(books.getBook(999_999, user.id)).toBeNull();
});
});
// -- Creating and reading -----------------------------------------------------
describe('creating and reading', () => {
it('is null for a journey with no book yet', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(books.getBook(journey.id, user.id)).toBeNull();
expect(books.canOpen(journey.id, user.id)).toBe(true);
});
it('creates on first save, at version 1', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const saved = books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() });
expect(saved && 'record' in saved && saved.record.version).toBe(1);
expect(saved && 'record' in saved && saved.record.title).toBe('Iceland');
});
it('reads the document back as it went in', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
const read = books.getBook(journey.id, user.id)!;
expect(read.document.spreads).toHaveLength(1);
expect(read.document.page.pageWidth).toBe(210);
});
it('records who saved it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
expect(books.getBook(journey.id, user.id)!.updatedBy).toBe(user.id);
});
it('lists books without their documents', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() });
const list = books.listBooks(journey.id, user.id)!;
expect(list).toHaveLength(1);
expect(list[0].title).toBe('Iceland');
expect('document' in list[0]).toBe(false);
});
/*
* A document that will not parse still has to open. One drifted field should
* not lock somebody out of their own book — normalizeBookDocument drops what
* it cannot read rather than throwing.
*/
it('opens a book whose stored JSON is broken, rather than throwing', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
testDb
.prepare(
"INSERT INTO journey_books (journey_id, title, document, version) VALUES (?, 'T', '{not json', 1)",
)
.run(journey.id);
const read = books.getBook(journey.id, user.id);
expect(read).not.toBeNull();
expect(read!.document.spreads).toEqual([]);
});
});
// -- Concurrency --------------------------------------------------------------
describe('concurrency', () => {
function seed() {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
return { user, journey };
}
it('bumps the version on every save', () => {
const { user, journey } = seed();
const second = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('two'),
baseVersion: 1,
});
expect(second && 'record' in second && second.record.version).toBe(2);
});
/* The one this column exists for. */
it('refuses a save made against a version that has moved', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('three'),
baseVersion: 1,
});
expect(stale && 'conflict' in stale).toBe(true);
});
it('answers a conflict with the current record, not only a refusal', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
title: 'Mine',
document: doc('three'),
baseVersion: 1,
});
expect(stale && 'conflict' in stale && stale.conflict.version).toBe(2);
expect(stale && 'conflict' in stale && stale.conflict.title).toBe('Theirs');
expect(stale && 'conflict' in stale && stale.conflict.document.title).toBe('two');
});
it('leaves the stored document untouched when it refuses', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 });
books.saveBook(journey.id, user.id, { title: 'Mine', document: doc('three'), baseVersion: 1 });
expect(books.getBook(journey.id, user.id)!.document.title).toBe('two');
expect(books.getBook(journey.id, user.id)!.version).toBe(2);
});
/*
* "Open Studio and start editing" has to work for the second person to
* arrive, who has a document but no version yet.
*/
it('takes a save with no base version as an ordinary write', () => {
const { user, journey } = seed();
const saved = books.saveBook(journey.id, user.id, { title: 'T', document: doc('two') });
expect(saved && 'record' in saved && saved.record.version).toBe(2);
});
it('lets the loser save again once it has the new version', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const conflict = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('mine'),
baseVersion: 1,
});
expect(conflict && 'conflict' in conflict).toBe(true);
const retry = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('mine'),
baseVersion: 2,
});
expect(retry && 'record' in retry && retry.record.version).toBe(3);
expect(books.getBook(journey.id, user.id)!.document.title).toBe('mine');
});
it('does not let a version from another journey unlock this one', () => {
const { user, journey } = seed();
const other = createJourney(testDb, user.id);
books.saveBook(other.id, user.id, { title: 'O', document: doc('other') });
// Version 1 is current over there and stale here.
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('three'),
baseVersion: 1,
});
expect(stale && 'conflict' in stale).toBe(true);
expect(books.getBook(other.id, user.id)!.version).toBe(1);
});
});
// -- Broadcasting -------------------------------------------------------------
describe('broadcastSaved', () => {
it('sends the version, not the document, and excludes the saver', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const saved = books.saveBook(journey.id, user.id, { title: 'T', document: doc() })!;
const record = 'record' in saved ? saved.record : null;
const spy = vi.spyOn(domain, 'broadcastJourneyEvent').mockImplementation(() => {});
books.broadcastSaved(journey.id, user.id, record!, 'socket-7');
expect(spy).toHaveBeenCalledWith(
journey.id,
'journey:book:saved',
{ version: 1, savedBy: user.id },
'socket-7',
);
spy.mockRestore();
});
});
// -- Deleting -----------------------------------------------------------------
describe('deleting', () => {
it('removes the book and reports it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
expect(books.deleteBook(journey.id, user.id)).toBe(true);
expect(books.getBook(journey.id, user.id)).toBeNull();
});
it('reports false when there was nothing to delete', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(books.deleteBook(journey.id, user.id)).toBe(false);
});
it('goes with the journey it belongs to', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
testDb.prepare('DELETE FROM journeys WHERE id = ?').run(journey.id);
const left = testDb
.prepare('SELECT COUNT(*) AS n FROM journey_books WHERE journey_id = ?')
.get(journey.id) as { n: number };
expect(left.n).toBe(0);
});
});
@@ -0,0 +1,358 @@
/**
* journeyStats() — the SQL half of the journey figures (#1973).
*
* A real in-memory SQLite DB, because what is being tested here is the queries:
* which rows become the route, how a place assigned to three days is counted
* once, and which source wins when a journey has both entries and trip places.
* The arithmetic those rows feed into has its own tests in journey-stats.test.ts.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
},
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
/*
* The country lookup is stubbed rather than exercised. The real one loads and
* indexes 4MB of gzipped admin-0 boundaries on first call — minutes of work
* across a test suite, to verify a function that belongs to Atlas and has its
* own tests. What matters here is *which coordinates get asked about* and that
* the place_regions cache is preferred, both of which the stub shows.
*/
const countryCalls: [number, number][] = [];
vi.mock('../../../src/nest/atlas/atlas-geo', () => ({
getCountryFromCoords: (lat: number, lng: number) => {
countryCalls.push([lat, lng]);
// Rough boxes, enough to tell two countries apart in a fixture.
if (lat > 63 && lng < -13) return 'IS';
if (lat > 47 && lat < 55 && lng > 5 && lng < 15) return 'DE';
if (lat > 42 && lat < 51 && lng > -5 && lng < 8) return 'FR';
return null;
},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import {
createUser, createTrip, createJourney, createJourneyEntry, createPlace,
createDay, createDayAssignment, linkTripToJourney,
} from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { db as dbConn } from '../../../src/db/database';
const dbs = new DatabaseService(dbConn);
const svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
/** The factory has no coordinate fields, and a route is made of coordinates. */
function placeEntry(
journeyId: number,
authorId: number,
lat: number,
lng: number,
overrides: { title?: string; entry_date?: string } = {},
) {
const entry = createJourneyEntry(testDb, journeyId, authorId, overrides);
testDb
.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?')
.run(lat, lng, entry.id);
return entry;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
countryCalls.length = 0;
});
afterAll(() => {
testDb.close();
});
describe('journeyStats access', () => {
it('is null for a journey the user cannot see', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
expect(svc.journeyStats(journey.id, stranger.id)).toBeNull();
});
it('is null for a journey that does not exist', () => {
const { user } = createUser(testDb);
expect(svc.journeyStats(999_999, user.id)).toBeNull();
});
it('answers for the owner', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const stats = svc.journeyStats(journey.id, user.id);
expect(stats).not.toBeNull();
expect(stats!.journeyId).toBe(journey.id);
});
});
describe('journeyStats route', () => {
it('builds the route from the entries when they carry coordinates', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']);
expect(stats.distance).toBeGreaterThan(240_000);
expect(stats.days).toBe(5);
expect(stats.steps).toBe(2);
});
it('orders the route by entry date, not by insertion', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['first', 'second']);
});
it('skips entries without coordinates but still counts them as steps', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
createJourneyEntry(testDb, journey.id, user.id, { title: 'no place', entry_date: '2026-06-03' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points).toHaveLength(1);
expect(stats.steps).toBe(2);
});
/*
* The fallback that makes the feature work at all for the common case: a
* journey built by adding trips, before anyone has written a word.
*/
it('falls back to the trip places when no entry has coordinates', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const day1 = createDay(testDb, trip.id, { date: '2026-06-02' });
const day2 = createDay(testDb, trip.id, { date: '2026-06-04' });
const a = createPlace(testDb, trip.id, { name: 'Hallgrímskirkja', lat: 64.14, lng: -21.93 });
const b = createPlace(testDb, trip.id, { name: 'Goðafoss', lat: 65.68, lng: -17.55 });
createDayAssignment(testDb, day2.id, b.id);
createDayAssignment(testDb, day1.id, a.id);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['Hallgrímskirkja', 'Goðafoss']);
expect(stats.days).toBe(3);
});
it('prefers the entries over the trip places rather than mixing them', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { name: 'a place', lat: 48.85, lng: 2.35 });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['an entry']);
});
/*
* A hotel across three nights is one place with three assignments. Left
* un-aggregated the join would put it on the route three times and charge the
* journey two extra legs of zero length — harmless — plus two extra stops in
* the middle of the route, which is not.
*/
it('visits a place assigned to several days only once', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const hotel = createPlace(testDb, trip.id, { name: 'Hotel', lat: 64.14, lng: -21.94 });
for (const date of ['2026-06-02', '2026-06-03', '2026-06-04']) {
createDayAssignment(testDb, createDay(testDb, trip.id, { date }).id, hotel.id);
}
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.filter(p => p.label === 'Hotel')).toHaveLength(1);
expect(stats.places).toBe(1);
});
it('puts unscheduled places after the scheduled ones', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const loose = createPlace(testDb, trip.id, { name: 'unscheduled', lat: 64.2, lng: -21.8 });
const planned = createPlace(testDb, trip.id, { name: 'scheduled', lat: 64.14, lng: -21.94 });
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, planned.id);
expect(loose.id).toBeGreaterThan(0);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['scheduled', 'unscheduled']);
});
it('counts places across every trip on the journey', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const one = createTrip(testDb, user.id);
const two = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, one.id);
linkTripToJourney(testDb, journey.id, two.id);
createPlace(testDb, one.id, { lat: 48.85, lng: 2.35 });
createPlace(testDb, two.id, { lat: 52.52, lng: 13.4 });
createPlace(testDb, two.id, { lat: 50.11, lng: 8.68 });
expect(svc.journeyStats(journey.id, user.id)!.places).toBe(3);
});
});
describe('journeyStats countries', () => {
it('resolves each stop to a country and names it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.countries.map(c => c.code)).toEqual(['IS', 'DE']);
expect(stats.countries[0].name).toBe('Iceland');
expect(stats.countries[1].name).toBe('Germany');
});
/*
* Atlas already resolved these and wrote them down. Reading its cache instead
* of repeating a point-in-polygon test is the difference between a query and
* a boundary index.
*/
it('prefers the place_regions cache over recomputing a place country', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const place = createPlace(testDb, trip.id, { name: 'somewhere', lat: 64.14, lng: -21.94 });
testDb
.prepare('INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, 'fr', 'FR-75', 'Paris');
const stats = svc.journeyStats(journey.id, user.id)!;
// The cached answer wins even though the coordinates say Iceland, and the
// expensive lookup is never reached for that place.
expect(stats.countries.map(c => c.code)).toEqual(['FR']);
expect(countryCalls).toHaveLength(0);
});
it('leaves a stop with no country out of the list rather than inventing one', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.countries).toEqual([]);
expect(stats.points).toHaveLength(1);
expect(stats.points[0].country).toBeNull();
});
});
describe('journeyStats totals', () => {
it('counts the gallery photographs', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const photo = testDb
.prepare("INSERT INTO trek_photos (provider, file_path, owner_id) VALUES ('local', 'x.jpg', ?)")
.run(user.id);
testDb
.prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, 0, ?)')
.run(journey.id, photo.lastInsertRowid, Date.now());
expect(svc.journeyStats(journey.id, user.id)!.photos).toBe(1);
});
it('falls back to the trip dates when the entries carry none', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-06-01', end_date: '2026-06-10' });
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { lat: 64.14, lng: -21.94 });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.start).toBe('2026-06-01');
expect(stats.end).toBe('2026-06-10');
expect(stats.days).toBe(10);
});
it('is all zeroes for an empty journey rather than failing', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.distance).toBe(0);
expect(stats.days).toBe(0);
expect(stats.steps).toBe(0);
expect(stats.photos).toBe(0);
expect(stats.places).toBe(0);
expect(stats.countries).toEqual([]);
expect(stats.points).toEqual([]);
});
it('answers for a contributor, not only the owner', () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
testDb
.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)')
.run(journey.id, helper.id, 'editor', Date.now());
expect(svc.journeyStats(journey.id, helper.id)).not.toBeNull();
});
});
@@ -0,0 +1,364 @@
/**
* The journey-stats arithmetic (#1973).
*
* Pure module, so these run against literal arrays rather than a fixture
* database — which is the whole reason the arithmetic lives apart from the
* service that fetches the rows.
*/
import { describe, it, expect } from 'vitest';
import {
collectCountries,
computeJourneyStats,
daysBetween,
furthestFromStart,
haversine,
isoDay,
routeDistance,
thinRoute,
MAX_ROUTE_POINTS,
type StatsInputPoint,
} from '../../../src/nest/journey/journey-stats';
const point = (over: Partial<StatsInputPoint> = {}): StatsInputPoint => ({
lat: 0, lng: 0, label: '', date: null, country: null, ...over,
});
describe('haversine', () => {
it('is zero for a point against itself', () => {
expect(haversine({ lat: 64.14, lng: -21.94 }, { lat: 64.14, lng: -21.94 })).toBe(0);
});
it('matches a known great-circle distance', () => {
// Reykjavík to Akureyri, ~250km as the crow flies.
const d = haversine({ lat: 64.1466, lng: -21.9426 }, { lat: 65.6885, lng: -18.1262 });
expect(d).toBeGreaterThan(240_000);
expect(d).toBeLessThan(260_000);
});
it('is symmetric', () => {
const a = { lat: 48.85, lng: 2.35 };
const b = { lat: 52.52, lng: 13.4 };
expect(haversine(a, b)).toBeCloseTo(haversine(b, a), 6);
});
it('handles antipodal points without NaN', () => {
const d = haversine({ lat: 0, lng: 0 }, { lat: 0, lng: 180 });
expect(Number.isFinite(d)).toBe(true);
// Half the circumference, give or take the radius model.
expect(d).toBeGreaterThan(20_000_000);
});
});
describe('routeDistance', () => {
it('is zero for fewer than two points', () => {
expect(routeDistance([])).toBe(0);
expect(routeDistance([{ lat: 1, lng: 1 }])).toBe(0);
});
it('sums the legs in order', () => {
const a = { lat: 0, lng: 0 };
const b = { lat: 0, lng: 1 };
const c = { lat: 0, lng: 2 };
expect(routeDistance([a, b, c])).toBeCloseTo(haversine(a, b) + haversine(b, c), 3);
});
it('counts a there-and-back route twice, not zero', () => {
const a = { lat: 0, lng: 0 };
const b = { lat: 0, lng: 1 };
expect(routeDistance([a, b, a])).toBeCloseTo(haversine(a, b) * 2, 3);
});
});
describe('furthestFromStart', () => {
it('is zero for fewer than two points', () => {
expect(furthestFromStart([])).toBe(0);
expect(furthestFromStart([{ lat: 5, lng: 5 }])).toBe(0);
});
it('measures from the first stop, not between the extremes', () => {
const points = [
{ lat: 0, lng: 0 },
{ lat: 0, lng: 3 },
{ lat: 0, lng: 1 },
];
expect(furthestFromStart(points)).toBeCloseTo(haversine(points[0], points[1]), 3);
});
it('finds the furthest even when it is not the last stop', () => {
const points = [
{ lat: 0, lng: 0 },
{ lat: 0, lng: 10 },
{ lat: 0, lng: 2 },
];
expect(furthestFromStart(points)).toBeCloseTo(haversine(points[0], points[1]), 3);
});
});
describe('isoDay', () => {
it('takes the date out of a datetime', () => {
expect(isoDay('2026-06-02T14:30:00Z')).toBe('2026-06-02');
});
it('passes a plain date through', () => {
expect(isoDay('2026-06-02')).toBe('2026-06-02');
});
it('is null for nothing and for nonsense', () => {
expect(isoDay(null)).toBeNull();
expect(isoDay(undefined)).toBeNull();
expect(isoDay('')).toBeNull();
expect(isoDay('sometime last summer')).toBeNull();
});
});
describe('daysBetween', () => {
it('counts a single day as one, not zero', () => {
expect(daysBetween('2026-06-02', '2026-06-02')).toBe(1);
});
it('is inclusive at both ends', () => {
expect(daysBetween('2026-06-02', '2026-06-04')).toBe(3);
});
it('spans a month boundary', () => {
expect(daysBetween('2026-05-28', '2026-06-02')).toBe(6);
});
it('spans a leap day', () => {
expect(daysBetween('2028-02-27', '2028-03-01')).toBe(4);
});
it('is zero when either end is missing', () => {
expect(daysBetween(null, '2026-06-02')).toBe(0);
expect(daysBetween('2026-06-02', null)).toBe(0);
expect(daysBetween(null, null)).toBe(0);
});
it('is zero rather than negative when the dates are the wrong way round', () => {
expect(daysBetween('2026-06-04', '2026-06-02')).toBe(1);
});
});
describe('thinRoute', () => {
it('leaves a route within budget alone', () => {
const points = Array.from({ length: 10 }, (_, i) => i);
expect(thinRoute(points, 20)).toBe(points);
});
it('thins down to exactly the budget', () => {
const points = Array.from({ length: 1000 }, (_, i) => i);
expect(thinRoute(points, 50)).toHaveLength(50);
});
it('keeps both ends, because they are the start and the end of the trip', () => {
const points = Array.from({ length: 1000 }, (_, i) => i);
const thinned = thinRoute(points, 50);
expect(thinned[0]).toBe(0);
expect(thinned[thinned.length - 1]).toBe(999);
});
it('spaces what it keeps evenly rather than dropping a run', () => {
const thinned = thinRoute(Array.from({ length: 100 }, (_, i) => i), 5);
expect(thinned).toEqual([0, 25, 50, 74, 99]);
});
});
describe('collectCountries', () => {
it('orders by first visit, so the list retells the route', () => {
const points = [
point({ country: 'GT', date: '2026-03-10' }),
point({ country: 'MX', date: '2026-03-01' }),
point({ country: 'BZ', date: '2026-03-05' }),
];
expect(collectCountries(points, {}).map(c => c.code)).toEqual(['MX', 'BZ', 'GT']);
});
it('counts the stops in each country', () => {
const points = [
point({ country: 'IS', date: '2026-06-02' }),
point({ country: 'IS', date: '2026-06-03' }),
point({ country: 'FO', date: '2026-06-09' }),
];
const [iceland, faroes] = collectCountries(points, {});
expect(iceland.places).toBe(2);
expect(faroes.places).toBe(1);
});
it('takes the earliest date as the first visit, whatever order the stops arrive in', () => {
const points = [
point({ country: 'FR', date: '2026-07-08' }),
point({ country: 'FR', date: '2026-05-28' }),
];
expect(collectCountries(points, {})[0].firstVisit).toBe('2026-05-28');
});
it('uppercases the code so a lowercase row cannot become a second country', () => {
const points = [point({ country: 'is' }), point({ country: 'IS' })];
const countries = collectCountries(points, {});
expect(countries).toHaveLength(1);
expect(countries[0].code).toBe('IS');
});
it('ignores stops with no country', () => {
expect(collectCountries([point(), point({ country: null })], {})).toEqual([]);
});
it('puts undated countries after dated ones, in the order they appeared', () => {
const points = [
point({ country: 'NO' }),
point({ country: 'SE', date: '2026-01-02' }),
point({ country: 'DK' }),
];
expect(collectCountries(points, {}).map(c => c.code)).toEqual(['SE', 'NO', 'DK']);
});
it('uses the supplied name and falls back to the code', () => {
const points = [point({ country: 'IS' }), point({ country: 'XX' })];
const countries = collectCountries(points, { IS: 'Iceland' });
expect(countries[0].name).toBe('Iceland');
expect(countries[1].name).toBe('XX');
});
});
describe('computeJourneyStats', () => {
const base = {
journeyId: 7,
entries: 3,
photos: 12,
places: 9,
tripDates: [],
countryNames: { IS: 'Iceland' },
};
it('carries the counts through untouched', () => {
const stats = computeJourneyStats({ ...base, points: [] });
expect(stats.journeyId).toBe(7);
expect(stats.steps).toBe(3);
expect(stats.photos).toBe(12);
expect(stats.places).toBe(9);
});
it('takes its dates from the stops when they have them', () => {
const stats = computeJourneyStats({
...base,
points: [
point({ lat: 64, lng: -22, date: '2026-06-02' }),
point({ lat: 65, lng: -18, date: '2026-06-15' }),
],
});
expect(stats.start).toBe('2026-06-02');
expect(stats.end).toBe('2026-06-15');
expect(stats.days).toBe(14);
});
/*
* The case this exists for: a journey assembled from trips that nobody has
* written entries for still lasted a fortnight, and printing "0 DAYS" over it
* would be worse than using the trip's own dates.
*/
it('falls back to the trips dates when no stop is dated', () => {
const stats = computeJourneyStats({
...base,
points: [point({ lat: 64, lng: -22 })],
tripDates: [{ start: '2026-06-01', end: '2026-06-10' }],
});
expect(stats.start).toBe('2026-06-01');
expect(stats.end).toBe('2026-06-10');
expect(stats.days).toBe(10);
});
it('takes the widest span across several trips', () => {
const stats = computeJourneyStats({
...base,
points: [],
tripDates: [
{ start: '2026-06-05', end: '2026-06-08' },
{ start: '2026-05-28', end: '2026-07-08' },
],
});
expect(stats.start).toBe('2026-05-28');
expect(stats.end).toBe('2026-07-08');
});
it('has no dates at all when nothing carries one', () => {
const stats = computeJourneyStats({ ...base, points: [point({ lat: 1, lng: 1 })] });
expect(stats.start).toBeNull();
expect(stats.end).toBeNull();
expect(stats.days).toBe(0);
});
it('rounds distance and furthest to whole metres', () => {
const stats = computeJourneyStats({
...base,
points: [point({ lat: 0, lng: 0 }), point({ lat: 0.1234, lng: 0.4321 })],
});
expect(Number.isInteger(stats.distance)).toBe(true);
expect(Number.isInteger(stats.furthest)).toBe(true);
expect(stats.distance).toBeGreaterThan(0);
});
it('caps the route and reports the capped points', () => {
const many = Array.from({ length: 900 }, (_, i) => point({ lat: i * 0.01, lng: 0 }));
const stats = computeJourneyStats({ ...base, points: many });
expect(stats.points).toHaveLength(MAX_ROUTE_POINTS);
});
it('measures distance over the thinned route, so the figure matches the drawn line', () => {
const many = Array.from({ length: 900 }, (_, i) => point({ lat: i * 0.01, lng: 0 }));
const stats = computeJourneyStats({ ...base, points: many });
const drawn = routeDistance(stats.points);
expect(stats.distance).toBe(Math.round(drawn));
});
it('drops stops with unusable coordinates from the arithmetic but keeps the counts', () => {
const stats = computeJourneyStats({
...base,
points: [
point({ lat: 0, lng: 0, date: '2026-01-01' }),
point({ lat: Number.NaN, lng: 5, date: '2026-01-02' }),
point({ lat: 0, lng: 1, date: '2026-01-03' }),
],
});
expect(stats.distance).toBe(Math.round(haversine({ lat: 0, lng: 0 }, { lat: 0, lng: 1 })));
expect(stats.points).toHaveLength(3);
});
it('keeps each stop label and date on the route it returns', () => {
const stats = computeJourneyStats({
...base,
points: [point({ lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS' })],
});
expect(stats.points[0]).toEqual({
lat: 64, lng: -22, label: 'Reykjavík', date: '2026-06-02', country: 'IS',
});
});
it('names the countries from the map it was given', () => {
const stats = computeJourneyStats({
...base,
points: [point({ lat: 64, lng: -22, country: 'IS', date: '2026-06-02' })],
});
expect(stats.countries).toEqual([
{ code: 'IS', name: 'Iceland', places: 1, firstVisit: '2026-06-02' },
]);
});
it('is all zeroes and empty lists for a journey with nothing in it', () => {
const stats = computeJourneyStats({
journeyId: 1, points: [], entries: 0, photos: 0, places: 0, tripDates: [], countryNames: {},
});
expect(stats).toEqual({
journeyId: 1,
distance: 0,
days: 0,
steps: 0,
photos: 0,
places: 0,
furthest: 0,
countries: [],
points: [],
start: null,
end: null,
});
});
});
+159 -91
View File
@@ -7,6 +7,7 @@ import { journeyThumbName } from '../../../src/nest/memories/thumbnail.service';
import type { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
import type { JourneyService } from '../../../src/nest/journey/journey.service';
import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import type { User } from '../../../src/types';
@@ -28,6 +29,20 @@ const storageStub = {
// to exist so the constructor can call it.
const captureBackfillStub = { schedule: vi.fn(), run: vi.fn() } as unknown as PhotoCaptureBackfillService;
/**
* Build the controller.
*
* Storage and the Studio book (#1973) are injected dependencies that almost no
* case here is about, and naming all four at every call site is what made the
* previous shape unmaintainable — a fifth dependency would have meant touching
* ninety lines. Both are stubbed: pass a partial book when the case IS about
* the book, and reach for `storageStub`'s own mocks when it is about an object
* being stored or served.
*/
function ctl(service: JourneyService, books: Partial<JourneyBookService> = {}): JourneyController {
return new JourneyController(service, storageStub, books as JourneyBookService, captureBackfillStub);
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
@@ -49,130 +64,130 @@ beforeEach(() => vi.clearAllMocks());
describe('JourneyController', () => {
it('GET / lists; POST / 400 without title, else creates', () => {
expect(new JourneyController(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).list(user)).toEqual({ journeys: [{ id: 1 }] });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
expect(ctl(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).list(user)).toEqual({ journeys: [{ id: 1 }] });
expect(thrown(() => ctl(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
const createJourney = vi.fn().mockReturnValue({ id: 9 });
expect(new JourneyController(svc({ createJourney } as Partial<JourneyService>), storageStub, captureBackfillStub).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(ctl(svc({ createJourney } as Partial<JourneyService>)).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] });
});
it('GET /suggestions + /available-trips', () => {
expect(new JourneyController(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
expect(new JourneyController(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
expect(ctl(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
expect(ctl(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>)).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
});
it('PATCH/DELETE entries map 404', () => {
expect(thrown(() => new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
expect(thrown(() => new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteEntry(user, '3')).toEqual({ success: true });
expect(thrown(() => ctl(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(ctl(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
expect(thrown(() => ctl(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3')).toEqual({ success: true });
});
it('provider-photos: batch, single 400/403, success', () => {
const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>);
expect(new JourneyController(batch, storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(thrown(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
});
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
expect(thrown(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 });
const c = new JourneyController(svc({ linkPhotoToEntry } as Partial<JourneyService>), storageStub, captureBackfillStub);
const c = ctl(svc({ linkPhotoToEntry } as Partial<JourneyService>));
expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1);
// accepts the canonical journey_photo_id, 403 when the service refuses
expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('unlink photo (204) maps 404; delete photo 404 then removes the object', async () => {
expect(thrown(() => new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
expect(new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).unlinkPhoto(user, '3', '7')).toBeUndefined();
expect(await thrownAsync(() => new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7')).toEqual({ success: true });
expect(thrown(() => ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
expect(ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7')).toBeUndefined();
expect(await thrownAsync(() => ctl(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
});
it('gallery upload 400 no files / 403 not allowed, else commits + returns photos', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
expect(await thrownAsync(() => ctl(svc()).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>)).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
expect(storageStub.put).toHaveBeenCalledWith('journey', 'a.jpg', { tmpPath: undefined });
});
it('gallery video: 400 no video, 403 not allowed, else stores the clip + poster (#823)', async () => {
const files = { video: [{ filename: 'v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg' } as Express.Multer.File] };
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' } });
expect(await thrownAsync(() => ctl(svc()).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' } });
// Not-allowed after commit → the final objects are deleted through storage
// (the pre-commit unlink would miss them: file.path is already consumed).
const withPaths = { video: [{ filename: 'v.mp4', path: '/nonexistent/v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg', path: '/nonexistent/p.jpg' } as Express.Multer.File] };
expect(await thrownAsync(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', withPaths, { duration_ms: 'abc' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>)).uploadGalleryVideo(user, '3', withPaths, { duration_ms: 'abc' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'v.mp4');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'p.jpg');
const up = vi.fn().mockReturnValue([{ id: 7 }]);
expect(await new JourneyController(svc({ uploadGalleryPhotos: up } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', files, { duration_ms: '4200' })).toEqual({ photos: [{ id: 7 }] });
expect(await ctl(svc({ uploadGalleryPhotos: up } as Partial<JourneyService>)).uploadGalleryVideo(user, '3', files, { duration_ms: '4200' })).toEqual({ photos: [{ id: 7 }] });
expect(up).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v.mp4', thumbnail: 'journey/p.jpg', mediaType: 'video', durationMs: 4200 }]);
// No poster + no duration → thumbnail undefined, durationMs null.
const up2 = vi.fn().mockReturnValue([{ id: 8 }]);
await new JourneyController(svc({ uploadGalleryPhotos: up2 } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadGalleryVideo(user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {});
await ctl(svc({ uploadGalleryPhotos: up2 } as Partial<JourneyService>)).uploadGalleryVideo(user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {});
expect(up2).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v2.mp4', thumbnail: undefined, mediaType: 'video', durationMs: null }]);
});
it('provider-photos forwards per-asset media_types for gallery and entries (#823)', () => {
const add = vi.fn().mockReturnValue({ id: 1 });
new JourneyController(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
ctl(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video');
expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image');
const addOne = vi.fn().mockReturnValue({ id: 2 });
new JourneyController(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
ctl(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video');
// Entry path mirrors the gallery path.
const eAdd = vi.fn().mockReturnValue({ id: 3 });
new JourneyController(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
ctl(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video');
const eOne = vi.fn().mockReturnValue({ id: 4 });
new JourneyController(svc({ addProviderPhoto: eOne } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
ctl(svc({ addProviderPhoto: eOne } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video');
});
it('GET/PATCH/DELETE /:id map 404', () => {
expect(thrown(() => new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).get(user, '9')).toEqual({ id: 9 });
expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).get(user, '9')).toEqual({ id: 9 });
expect(thrown(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
});
it('trips: POST 400 without trip_id / 403, DELETE 403', () => {
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
expect(thrown(() => new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
expect(thrown(() => ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
expect(thrown(() => ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entries under journey: list 404, create 400/404, reorder 400/403', () => {
expect(thrown(() => new JourneyController(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(new JourneyController(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>), storageStub, captureBackfillStub).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
expect(thrown(() => new JourneyController(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
expect(thrown(() => new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
expect(thrown(() => ctl(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
expect(thrown(() => ctl(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
expect(thrown(() => ctl(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('contributors: add 400/403, update 403, remove 403', () => {
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
expect(thrown(() => new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
expect(thrown(() => new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
expect(thrown(() => ctl(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
expect(thrown(() => ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('preferences 403, share-link get/set/delete', () => {
expect(thrown(() => new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
expect(thrown(() => new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
expect(thrown(() => new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
expect(thrown(() => ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
expect(thrown(() => ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entry photo upload mirrors to Immich only when opted in', async () => {
@@ -180,84 +195,82 @@ describe('JourneyController', () => {
const uploadToImmich = vi.fn().mockResolvedValue('immich-1');
const setPhotoProvider = vi.fn();
const s = svc({ addPhoto, immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich, setPhotoProvider } as Partial<JourneyService>);
const res = await new JourneyController(s, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
const res = await ctl(s).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
expect(setPhotoProvider).toHaveBeenCalledWith(5, 'immich', 'immich-1', 1);
expect(res).toEqual({ photos: [{ id: 5, provider: 'immich', asset_id: 'immich-1', owner_id: 1 }] });
const noOptIn = svc({ addPhoto: vi.fn().mockReturnValue({ id: 6 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(false), uploadToImmich } as Partial<JourneyService>);
await new JourneyController(noOptIn, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
await ctl(noOptIn).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above
});
it('entry photo upload: 400 no files, 403 when nothing added, swallows immich errors and empty ids', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc()).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
expect(await thrownAsync(() => ctl(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
// opted in but the immich upload throws → best-effort, the local photo still wins
const setPhotoProvider = vi.fn();
const blowsUp = svc({ addPhoto: vi.fn().mockReturnValue({ id: 8 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockRejectedValue(new Error('immich down')), setPhotoProvider } as Partial<JourneyService>);
expect(await new JourneyController(blowsUp, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
expect(await ctl(blowsUp).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
expect(setPhotoProvider).not.toHaveBeenCalled();
// opted in but immich returns a falsy id → no provider stamping
const noId = svc({ addPhoto: vi.fn().mockReturnValue({ id: 9 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockResolvedValue(''), setPhotoProvider } as Partial<JourneyService>);
expect(await new JourneyController(noId, storageStub, captureBackfillStub).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
expect(await ctl(noId).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
});
it('provider-photos batch passes the passphrase through when present', () => {
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
new JourneyController(svc({ addProviderPhoto } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
ctl(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image');
// single-photo success path
expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
expect(ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
});
it('PATCH photos: 404 then returns the updated photo', () => {
expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
expect(thrown(() => ctl(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(ctl(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
});
it('DELETE photo removes the storage object when a path exists', async () => {
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '7')).toEqual({ success: true });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'a.jpg');
// ...and the derived thumb goes with it (spec fix #2).
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/a.jpg'));
// a vanished object is swallowed
(storageStub.delete as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ENOENT'));
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '8')).toEqual({ success: true });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '8')).toEqual({ success: true });
// a file_path outside the journey category never reaches storage
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
expect(await new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deletePhoto(user, '9')).toEqual({ success: true });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '9')).toEqual({ success: true });
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => {
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial<JourneyService>), storageStub, captureBackfillStub);
const batch = ctl(svc({ addProviderPhotoToGallery } as Partial<JourneyService>));
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image');
expect(thrown(() => new JourneyController(svc(), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
expect(thrown(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
});
it('DELETE gallery photo: 404, then removes the object when present', async () => {
expect(await thrownAsync(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
expect(await thrownAsync(() => ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
// no file_path → nothing to delete, returns void
expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '7')).toBeUndefined();
await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '8');
expect(await ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '7')).toBeUndefined();
await ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '8');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'g.jpg');
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/g.jpg'));
(storageStub.delete as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ENOENT'));
expect(await new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteGalleryPhoto(user, '9')).toBeUndefined();
expect(await ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '9')).toBeUndefined();
});
it('DELETE gallery video also reclaims its uploaded poster (spec fix #2 extension)', async () => {
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
await new JourneyController(
await ctl(
svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 10, file_path: 'journey/clip.mp4', thumbnail_path: 'journey/poster.jpg' }) } as Partial<JourneyService>),
storageStub,
captureBackfillStub,
).deleteGalleryPhoto(user, '10');
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'clip.mp4');
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/clip.mp4'));
@@ -265,45 +278,100 @@ describe('JourneyController', () => {
});
it('PATCH /:id returns the updated journey on success', () => {
expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
expect(ctl(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
});
it('cover upload: 400 without file, 404 when the journey is gone, else commits + returns the journey', async () => {
expect(await thrownAsync(() => new JourneyController(svc(), storageStub, captureBackfillStub).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
expect(await thrownAsync(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>), storageStub, captureBackfillStub).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await thrownAsync(() => ctl(svc()).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
expect(await thrownAsync(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } });
const updateJourney = vi.fn().mockReturnValue({ id: 9, cover_image: 'journey/c.jpg' });
expect(await new JourneyController(svc({ updateJourney } as Partial<JourneyService>), storageStub, captureBackfillStub).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' });
expect(await ctl(svc({ updateJourney } as Partial<JourneyService>)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' });
expect(storageStub.put).toHaveBeenCalledWith('journey', 'c.jpg', { tmpPath: undefined });
expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' });
});
it('DELETE /:id and trips/contributors success paths', () => {
expect(new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).remove(user, '9')).toEqual({ success: true });
expect(new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).removeContributor(user, '9', '2')).toEqual({ success: true });
expect(ctl(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).remove(user, '9')).toEqual({ success: true });
expect(ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(ctl(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeContributor(user, '9', '2')).toEqual({ success: true });
});
it('addContributor defaults the role to viewer when omitted', () => {
const addContributor = vi.fn().mockReturnValue(true);
new JourneyController(svc({ addContributor } as Partial<JourneyService>), storageStub, captureBackfillStub).addContributor(user, '9', { user_id: 2 });
ctl(svc({ addContributor } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 });
expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer');
});
it('createEntry returns the entry when the journey exists', () => {
expect(new JourneyController(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>), storageStub, captureBackfillStub).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
expect(ctl(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
});
it('reorderEntries succeeds for a numeric array', () => {
expect(new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
expect(ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
});
it('preferences returns the result on success', () => {
expect(new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>), storageStub, captureBackfillStub).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
expect(ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
});
it('deleteShareLink returns success when removed', () => {
expect(new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>), storageStub, captureBackfillStub).deleteShareLink(user, '9')).toEqual({ success: true });
expect(ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteShareLink(user, '9')).toEqual({ success: true });
});
/*
* The Studio book (#1973). Its access shape is the same as everything around
* it — the service answers null for a journey the caller cannot see, and that
* is a 404 rather than a 403, so the route cannot be used to find out which
* journeys exist.
*/
it('book: 404 vs a journey with no book yet, and the save status codes', () => {
// A journey with no book yet is not an error: Studio opens on it, lays a
// book out and saves. 404 here would make "no book" and "no journey"
// indistinguishable to the client.
expect(ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).getBook(user, '9')).toEqual({ book: null });
expect(thrown(() => ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).getBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc(), { getBook: vi.fn().mockReturnValue({ id: 3, version: 4 }), canOpen: vi.fn() }).getBook(user, '9')).toEqual({ book: { id: 3, version: 4 } });
expect(thrown(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 404, body: { error: 'Journey not found' } });
// The record comes back bare, not in an envelope — the contract in
// shared/src/book/book-store.schema.ts is the record itself.
const broadcastSaved = vi.fn();
const saveBook = vi.fn().mockReturnValue({ record: { id: 3, version: 5 } });
expect(ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { title: 'T', document: { v: 1 }, baseVersion: 4 } as never, 'sock-7')).toEqual({ id: 3, version: 5 });
expect(saveBook).toHaveBeenCalledWith(9, 1, { title: 'T', document: { v: 1 }, baseVersion: 4 });
// Forwarded so the client that just saved does not process its own change.
expect(broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7');
// A missing title is '' rather than undefined: the column is NOT NULL and
// an untitled book is an ordinary thing to have.
const untitled = vi.fn().mockReturnValue({ record: { id: 3, version: 1 } });
ctl(svc(), { saveBook: untitled, broadcastSaved: vi.fn() }).saveBook(user, '9', { document: {} } as never);
expect(untitled.mock.calls[0][2].title).toBe('');
});
it('book: a conflict is a 409 that carries the other version', () => {
/*
* Two people editing is the normal case, not an exception. A bare 409 would
* make showing what the other version is a second round trip, at exactly
* the moment someone is worried about losing work.
*/
const broadcastSaved = vi.fn();
const saveBook = vi.fn().mockReturnValue({ conflict: { id: 3, version: 6, title: 'Theirs' } });
expect(thrown(() => ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never))).toEqual({
status: 409,
body: { error: 'Book was changed by someone else', current: { id: 3, version: 6, title: 'Theirs' } },
});
// Nothing was written, so nobody is told anything moved.
expect(broadcastSaved).not.toHaveBeenCalled();
});
it('book: delete answers 204, or 404 out of reach', () => {
expect(ctl(svc(), { deleteBook: vi.fn().mockReturnValue(true) }).deleteBook(user, '9')).toBeUndefined();
// False means there was nothing to delete, which is still a 204 — deleting
// a book that is already gone is not a failure.
expect(ctl(svc(), { deleteBook: vi.fn().mockReturnValue(false) }).deleteBook(user, '9')).toBeUndefined();
expect(thrown(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null) }).deleteBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
});
});
+311 -4
View File
@@ -14,7 +14,11 @@ vi.mock('../../../src/plugin-event-sink', () => ({
import { RealtimeGateway } from '../../../src/nest/realtime/realtime.gateway';
import {
bookPeers,
broadcast,
broadcastToBook,
joinBook,
leaveBook,
broadcastToUser,
getOnlineUserIds,
joinRoom,
@@ -26,6 +30,7 @@ import {
import { emitPluginEvent } from '../../../src/plugin-event-sink';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import type { User } from '../../../src/types';
interface FakeSocket extends TrekWebSocket {
@@ -58,8 +63,12 @@ const db = {
const consumeWithMeta = vi.fn();
const tokens = { consumeWithMeta } as unknown as EphemeralTokenService;
/** Everything is reachable except journey 4, which stands in for no access. */
const canAccessJourney = vi.fn((journeyId: number) => (journeyId === 4 ? null : { id: journeyId }));
const journeys = { canAccessJourney } as unknown as JourneyDomainService;
function connect(url: string) {
const gw = new RealtimeGateway(db, tokens);
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
gw.handleConnection(ws, { url } as never);
return { gw, ws };
@@ -120,7 +129,7 @@ describe('RealtimeGateway handshake', () => {
});
it('WSGW-007b: survives an upgrade request with no url, rather than throwing at it', () => {
const gw = new RealtimeGateway(db, tokens);
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
expect(() => gw.handleConnection(ws, {} as never)).not.toThrow();
expect(ws.closedWith).toEqual([4001, 'Authentication required']);
@@ -175,7 +184,7 @@ describe('RealtimeGateway heartbeat', () => {
alive.isAlive = true;
const stale = socket();
stale.isAlive = false;
const gw = new RealtimeGateway(db, tokens);
const gw = new RealtimeGateway(db, tokens, journeys);
gw.afterInit({ clients: new Set([alive, stale]) } as never);
vi.advanceTimersByTime(30_000);
@@ -196,7 +205,7 @@ describe('RealtimeGateway heartbeat', () => {
try {
const ws = socket();
ws.isAlive = true;
const gw = new RealtimeGateway(db, tokens);
const gw = new RealtimeGateway(db, tokens, journeys);
gw.afterInit({ clients: new Set([ws]) } as never);
gw.onModuleDestroy();
@@ -293,3 +302,301 @@ describe('ws-state fan-out', () => {
expect(getOnlineUserIds()).toEqual(new Set());
});
});
/**
* ── Studio books (#1973) ────────────────────────────────────────────────
*
* Presence and pointers for people editing the same photo book. Nothing here
* is written down: the room IS the state, and a socket that goes takes its
* pointer with it.
*/
describe('book rooms', () => {
let nextJourney = 100;
function joined(journeyId: number) {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
const reply = gw.handleBookJoin({ journeyId }, ws);
return { gw, ws, reply };
}
it('WSGW-BOOK-001: admits a socket to a journey it may see', () => {
const j = nextJourney++;
const { reply } = joined(j);
expect(reply).toEqual({ type: 'book:joined', journeyId: j });
expect(bookPeers(j).map(p => p.userId)).toEqual([3]);
});
/* Same shape as the trip room's refusal, and for the same reason. */
it('WSGW-BOOK-002: refuses a journey the user cannot see, and adds nobody', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(gw.handleBookJoin({ journeyId: 4 }, ws)).toEqual({ type: 'error', message: 'Access denied' });
expect(bookPeers(4)).toEqual([]);
});
it('WSGW-BOOK-003: tells everyone in the book who is in it', () => {
const j = nextJourney++;
const { ws: first } = joined(j);
first.sent.length = 0;
joined(j);
const peers = first.sent.map(raw => JSON.parse(raw)).filter(m => m.type === 'journey:book:peers');
expect(peers).toHaveLength(1);
expect(peers[0].peers).toHaveLength(2);
expect(peers[0].journeyId).toBe(j);
});
it('WSGW-BOOK-004: leaving empties the room and says so', () => {
const j = nextJourney++;
const { gw, ws } = joined(j);
expect(gw.handleBookLeave({ journeyId: j }, ws)).toEqual({ type: 'book:left', journeyId: j });
expect(bookPeers(j)).toEqual([]);
});
/*
* The one that leaves a ghost if it is missed: a closed tab whose arrow stays
* on everyone else's page, belonging to nobody.
*/
it('WSGW-BOOK-005: a dropped connection leaves the book too', () => {
const j = nextJourney++;
const { gw, ws } = joined(j);
const other = joined(j);
other.ws.sent.length = 0;
expect(bookPeers(j)).toHaveLength(2);
gw.handleDisconnect(ws);
expect(bookPeers(j)).toHaveLength(1);
const peers = other.ws.sent.map(raw => JSON.parse(raw)).filter(m => m.type === 'journey:book:peers');
expect(peers[peers.length - 1].peers).toHaveLength(1);
});
});
describe('book pointers', () => {
let nextJourney = 200;
function pair() {
const journeyId = nextJourney++;
const gw = new RealtimeGateway(db, tokens, journeys);
const mine = socket();
const theirs = socket();
registerSocket(mine, { id: 3, username: 'm' } as User);
registerSocket(theirs, { id: 4, username: 'other' } as User);
gw.handleBookJoin({ journeyId }, mine);
gw.handleBookJoin({ journeyId }, theirs);
mine.sent.length = 0;
theirs.sent.length = 0;
return { gw, mine, theirs, journeyId };
}
const cursorsIn = (ws: FakeSocket) =>
ws.sent.map(raw => JSON.parse(raw)).filter(m => m.type === 'journey:book:cursor');
it('WSGW-CUR-001: forwards a pointer to the others, not back to the sender', () => {
const { gw, mine, theirs, journeyId } = pair();
gw.handleBookCursor({ journeyId, spreadIndex: 2, x: 105.5, y: 60 }, mine);
expect(cursorsIn(mine)).toEqual([]);
expect(cursorsIn(theirs)).toHaveLength(1);
expect(cursorsIn(theirs)[0]).toMatchObject({ journeyId, userId: 3, spreadIndex: 2, x: 105.5, y: 60 });
});
/*
* The room is the authorisation. Nothing is checked against the database on
* this path — it runs ten times a second — so a socket that never joined has
* to reach nobody.
*/
it('WSGW-CUR-002: a socket that never joined reaches nobody', () => {
const { gw, theirs, journeyId } = pair();
const stranger = socket();
registerSocket(stranger, { id: 5, username: 'x' } as User);
gw.handleBookCursor({ journeyId, spreadIndex: 0, x: 1, y: 1 }, stranger);
expect(cursorsIn(theirs)).toEqual([]);
});
it('WSGW-CUR-003: a pointer leaving the page travels as null', () => {
const { gw, mine, theirs, journeyId } = pair();
gw.handleBookCursor({ journeyId, spreadIndex: 0, x: null, y: null }, mine);
expect(cursorsIn(theirs)[0]).toMatchObject({ x: null, y: null });
});
it('WSGW-CUR-004: refuses nonsense coordinates rather than passing them on', () => {
const { gw, mine, theirs, journeyId } = pair();
gw.handleBookCursor({ journeyId, spreadIndex: -4, x: Number.NaN, y: Infinity }, mine);
expect(cursorsIn(theirs)[0]).toMatchObject({ spreadIndex: 0, x: null, y: null });
});
});
/*
* ── The ways a book message is ignored ──────────────────────────────────
*
* Every one of these is a message that must produce nothing at all: no reply,
* no room change, no broadcast. They are worth naming because the failure is
* silent in each case — a handler that quietly did something with a missing
* journey id would not throw, it would put a socket somewhere it does not
* belong and nobody would see it until two strangers shared a page.
*/
describe('book messages that are refused', () => {
let nextJourney = 300;
it('WSGW-BOOK-006: a join without a journey id is ignored', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(gw.handleBookJoin({}, ws)).toBeUndefined();
expect(ws.sent).toEqual([]);
});
/* An unauthenticated socket never got as far as the registry. */
it('WSGW-BOOK-007: a join from a socket with no user is ignored', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const j = nextJourney++;
expect(gw.handleBookJoin({ journeyId: j }, socket())).toBeUndefined();
expect(bookPeers(j)).toEqual([]);
});
it('WSGW-BOOK-008: a journey id that is not a number is refused, not joined', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(gw.handleBookJoin({ journeyId: 'not-a-journey' }, ws))
.toEqual({ type: 'error', message: 'Access denied' });
});
it('WSGW-BOOK-009: a leave without a journey id is ignored', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(gw.handleBookLeave({}, ws)).toBeUndefined();
});
/* Leaving a room nobody is in must not create one on the way out. */
it('WSGW-BOOK-010: leaving a book that was never joined does nothing', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
const j = nextJourney++;
expect(gw.handleBookLeave({ journeyId: j }, ws)).toEqual({ type: 'book:left', journeyId: j });
expect(bookPeers(j)).toEqual([]);
});
it('WSGW-CUR-005: a pointer without a journey id reaches nobody', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const mine = socket();
const theirs = socket();
registerSocket(mine, { id: 3, username: 'm' } as User);
registerSocket(theirs, { id: 4, username: 'o' } as User);
const j = nextJourney++;
gw.handleBookJoin({ journeyId: j }, mine);
gw.handleBookJoin({ journeyId: j }, theirs);
theirs.sent.length = 0;
expect(gw.handleBookCursor({ x: 1, y: 1 }, mine)).toBeUndefined();
expect(theirs.sent).toEqual([]);
});
it('WSGW-CUR-006: a pointer from a socket with no user reaches nobody', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const theirs = socket();
registerSocket(theirs, { id: 4, username: 'o' } as User);
const j = nextJourney++;
gw.handleBookJoin({ journeyId: j }, theirs);
theirs.sent.length = 0;
expect(gw.handleBookCursor({ journeyId: j, x: 1, y: 1 }, socket())).toBeUndefined();
expect(theirs.sent).toEqual([]);
});
});
/*
* ── Sockets that are in a room but cannot be talked to ──────────────────
*
* A room holds sockets, and a socket can stop being usable without leaving:
* the connection drops and the close handler has not run yet, or it was put in
* a room without ever being registered. Both are states the peer list and the
* broadcast have to walk past rather than trip over, because the alternative is
* an editor whose peer list shows a ghost, or a throw on a path that runs ten
* times a second.
*/
describe('book rooms with unusable sockets in them', () => {
let nextJourney = 400;
it('WSST-BOOK-001: a closed socket is not a peer', () => {
const j = nextJourney++;
const live = socket();
const dead = socket();
registerSocket(live, { id: 3, username: 'm' } as User);
registerSocket(dead, { id: 9, username: 'gone' } as User);
joinBook(live, j);
joinBook(dead, j);
(dead as { readyState: number }).readyState = 3;
expect(bookPeers(j).map(p => p.userId)).toEqual([3]);
});
it('WSST-BOOK-002: a socket that was never registered is not a peer', () => {
const j = nextJourney++;
const known = socket();
registerSocket(known, { id: 3, username: 'm' } as User);
joinBook(known, j);
joinBook(socket(), j);
expect(bookPeers(j).map(p => p.userId)).toEqual([3]);
});
it('WSST-BOOK-003: a broadcast skips a closed socket instead of writing to it', () => {
const j = nextJourney++;
const live = socket();
const dead = socket();
registerSocket(live, { id: 3, username: 'm' } as User);
registerSocket(dead, { id: 9, username: 'gone' } as User);
joinBook(live, j);
joinBook(dead, j);
(dead as { readyState: number }).readyState = 3;
broadcastToBook(j, { type: 'x' });
expect(live.sent).toHaveLength(1);
expect(dead.sent).toEqual([]);
});
it('WSST-BOOK-004: a broadcast to a book nobody is in goes nowhere', () => {
expect(() => broadcastToBook(nextJourney++, { type: 'x' })).not.toThrow();
});
/*
* One person, two tabs, two books: the second join must extend what the
* socket is already in rather than replacing it, or leaving one book would
* take the other with it.
*/
it('WSST-BOOK-005: a socket can be in two books at once', () => {
const a = nextJourney++;
const b = nextJourney++;
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
joinBook(ws, a);
joinBook(ws, b);
leaveBook(ws, a);
expect(bookPeers(a)).toEqual([]);
expect(bookPeers(b).map(p => p.userId)).toEqual([3]);
});
it('WSST-BOOK-006: leaving a book that does not exist is not an error', () => {
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
expect(() => leaveBook(ws, nextJourney++)).not.toThrow();
});
});
@@ -68,6 +68,7 @@ beforeEach(() => {
handlers = [
{ message: 'join', callback: (data) => { handled.push(data); return { type: 'joined' }; } },
{ message: 'leave', callback: (data) => { handled.push(data); return undefined; } },
{ message: 'book:cursor', callback: (data) => { handled.push(data); return undefined; } },
];
});
@@ -185,6 +186,51 @@ describe('TrekWsAdapter connection binding', () => {
});
});
/**
* The pointer exemption (#1973).
*
* Studio's pointers move about ten times a second per editor, three times what
* the tight limit allows on its own — and that limit cannot simply be raised,
* because it is what stops a client flooding the process. So there are two
* ceilings, and only pointers are exempt from the tight one.
*/
describe('TrekWsAdapter pointer budget', () => {
it('WSAD-012: lets pointers past the tight limit', () => {
const socket = fakeSocket();
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
for (let i = 0; i < 60; i++) socket.emit('message', frame({ type: 'book:cursor', x: i }));
expect(handled).toHaveLength(60);
expect(socket.sent).toHaveLength(0);
});
/* Exempt from the tight limit, not from the ceiling. */
it('WSAD-013: cuts a pointer flood off at the outer ceiling', () => {
const socket = fakeSocket();
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
for (let i = 0; i < 260; i++) socket.emit('message', frame({ type: 'book:cursor', x: i }));
expect(handled.length).toBeLessThanOrEqual(200);
expect(socket.sent.some((raw) => raw.includes('Rate limit'))).toBe(true);
});
/*
* The exemption is for pointers alone. A client sending them cannot use them
* to buy itself extra room for anything else.
*/
it('WSAD-014: a pointer flood does not loosen the limit on everything else', () => {
const socket = fakeSocket();
adapter.bindMessageHandlers(socket as never, handlers as never, transform);
for (let i = 0; i < 50; i++) socket.emit('message', frame({ type: 'book:cursor', x: i }));
for (let i = 0; i < 31; i++) socket.emit('message', frame({ type: 'join', tripId: 7 }));
expect(socket.sent.some((raw) => raw.includes('Rate limit'))).toBe(true);
});
});
describe('TrekWsAdapter server creation', () => {
/** A real http.Server is never listened on; ws only needs it to hang an upgrade handler. */
const httpServer = { on: vi.fn(), once: vi.fn(), removeListener: vi.fn(), emit: vi.fn() } as unknown as HttpServer;