Files
TREK/server/tests/unit/nest/journey-share.service.test.ts
T
Maurice f0e4c9a33d fix: five small defects the audit turned up in the release
An explicit null on a place's duration_minutes advertised a reset it never
performed: the column sat behind COALESCE, so null and absent were the same
thing and the day plan kept budgeting the old stay length. It clears now, and
0 still writes 0.

update_trip_file and link_trip_file never learned the budget_item_id target the
REST routes gained, so a receipt could not be attached to an expense from MCP.
Added to both, along with the cross-trip check that goes with it: adding the
field without it would have opened the hole the same change closed for REST.

The roadtrip preferences PUT dropped the X-Socket-Id it was given, so the tab
that saved a setting received its own change back and spent a second store
commit and a route recompute on an answer it already had. Its two siblings on
the same feature forward it.

A shared journey did not carry the three "this journey does not use that field"
switches, so the phone card read undefined, decided everything was switched on,
and showed mood and weather chips on a journey whose owner had turned them off.

An entry's country flag followed the pin when the entry was edited and not when
the trip sync moved the place underneath it, so a stop dragged across a border
kept the old flag with nothing to ever correct it.

Also: dawarich.helpers.ts held a raw NUL byte as a hash separator, which made
every search tool treat the file as binary and skip it. Same character, written
as an escape.
2026-09-13 23:49:00 +02:00

692 lines
29 KiB
TypeScript

/**
* Unit tests for JourneyShareService — JOURNEY-SHARE-001 through JOURNEY-SHARE-018.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// -- DB setup -----------------------------------------------------------------
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');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createJourney, createJourneyEntry, 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 { JourneyShareService } from '../../../src/nest/journey/journey-share.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { db as dbConn } from '../../../src/db/database';
const dbs = new DatabaseService(dbConn);
const svc = new JourneyShareService(
dbs,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
new SettingsService(dbs),
);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// -- Helpers ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
): number {
const provider = opts.assetId ? 'immich' : 'local';
const filePath = !opts.assetId ? (opts.filePath ?? '/photos/test.jpg') : null;
const trekResult = testDb.prepare(`
INSERT INTO trek_photos (provider, asset_id, file_path, owner_id, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number;
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
const journeyId = entryRow.journey_id;
const now = Date.now();
testDb.prepare(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
`).run(journeyId, trekId, now);
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
testDb.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, 0, ?)
`).run(entryId, galleryRow.id, now);
// Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId;
}
// -- Tests --------------------------------------------------------------------
describe('createOrUpdateJourneyShareLink', () => {
it('JOURNEY-SHARE-001: creates a new share link with default permissions', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(result.created).toBe(true);
expect(result.token).toBeTruthy();
expect(result.token.length).toBeGreaterThan(10);
});
it('JOURNEY-SHARE-002: creates a share link with custom permissions', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
});
const link = svc.getJourneyShareLink(journey.id);
expect(link).not.toBeNull();
expect(link!.share_timeline).toBe(true);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
});
it('JOURNEY-SHARE-003: updates permissions on existing link without regenerating token', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const first = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: true,
share_map: true,
});
const second = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
});
expect(second.created).toBe(false);
expect(second.token).toBe(first.token);
const link = svc.getJourneyShareLink(journey.id);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
});
it('JOURNEY-SHARE-029: reading the link separates "no link" from "not yours"', () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, helper.id, 'editor');
// Nothing published yet: an owner is allowed to look and finds nothing.
expect(svc.readJourneyShareLink(journey.id, owner.id)).toEqual({ allowed: true, link: null });
svc.createOrUpdateJourneyShareLink(journey.id, owner.id, {});
const asOwner = svc.readJourneyShareLink(journey.id, owner.id);
expect(asOwner.allowed).toBe(true);
expect(asOwner.allowed && asOwner.link?.token).toBeTruthy();
// An editor is refused outright. Answering { link: null } here would tell a
// published journey's editor it is unpublished and offer to publish it.
expect(svc.readJourneyShareLink(journey.id, helper.id)).toEqual({ allowed: false });
});
it('JOURNEY-SHARE-027: an update leaves out flags it was not given', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
newest_first: true,
});
// A caller that only flips the timeline must not silently re-publish the
// gallery and map at the unchanged token.
svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false });
const link = svc.getJourneyShareLink(journey.id);
expect(link!.share_timeline).toBe(false);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
expect(link!.newest_first).toBe(true);
});
it('JOURNEY-SHARE-028: newest_first survives a flag-only update and is settable', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(false);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, { newest_first: true });
expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(true);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false });
expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(true);
});
it('JOURNEY-SHARE-004: different journeys get different tokens', () => {
const { user } = createUser(testDb);
const j1 = createJourney(testDb, user.id);
const j2 = createJourney(testDb, user.id);
const r1 = svc.createOrUpdateJourneyShareLink(j1.id, user.id, {});
const r2 = svc.createOrUpdateJourneyShareLink(j2.id, user.id, {});
expect(r1.token).not.toBe(r2.token);
});
});
describe('getJourneyShareLink', () => {
it('JOURNEY-SHARE-005: returns null when no share link exists', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = svc.getJourneyShareLink(journey.id);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-006: returns share link info when it exists', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: true,
});
const result = svc.getJourneyShareLink(journey.id);
expect(result).not.toBeNull();
expect(result!.token).toBeTruthy();
expect(result!.share_timeline).toBe(true);
expect(result!.share_gallery).toBe(false);
expect(result!.share_map).toBe(true);
expect(result!.created_at).toBeTruthy();
});
});
describe('deleteJourneyShareLink', () => {
it('JOURNEY-SHARE-007: owner can remove an existing share link', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const ok = svc.deleteJourneyShareLink(journey.id, user.id);
expect(ok).toBe(true);
expect(svc.getJourneyShareLink(journey.id)).toBeNull();
});
it('JOURNEY-SHARE-008: does not throw when deleting non-existent link', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(() => svc.deleteJourneyShareLink(journey.id, user.id)).not.toThrow();
});
});
describe('validateShareTokenForPhoto', () => {
it('JOURNEY-SHARE-009: returns journeyId and ownerId for valid token + photo', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.validateShareTokenForPhoto(token, photoId);
expect(result).not.toBeNull();
expect(result!.journeyId).toBe(journey.id);
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-010: returns null for invalid token', () => {
const result = svc.validateShareTokenForPhoto('nonexistent-token', 1);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-011: returns null when photo does not belong to shared journey', () => {
const { user } = createUser(testDb);
const journey1 = createJourney(testDb, user.id);
const journey2 = createJourney(testDb, user.id);
const entry2 = createJourneyEntry(testDb, journey2.id, user.id);
const photoId = insertJourneyPhoto(entry2.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey1.id, user.id, {});
const result = svc.validateShareTokenForPhoto(token, photoId);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-012: falls back to journey owner_id when photo has no owner_id', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: undefined });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.validateShareTokenForPhoto(token, photoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
// Regression — GHSA-9hc8 sibling: the byte proxy must honour share_gallery.
it('JOURNEY-SHARE-017: returns null when the owner disabled the gallery (share_gallery=false)', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
expect(svc.validateShareTokenForPhoto(token, photoId)).toBeNull();
});
it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', () => {
// Simulate a user who has many trek_photos from Immich syncs before adding a journey photo.
// trek_photos.id will be higher than journey_photos.id — the previous bug matched on jp.id
// instead of jp.photo_id, causing a 404 for Immich photos in public shares.
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
// Pre-populate trek_photos to push the autoincrement higher
for (let i = 0; i < 5; i++) {
testDb.prepare(`INSERT INTO trek_photos (provider, asset_id, owner_id, created_at) VALUES ('immich', ?, ?, ?)`).run(`bulk-asset-${i}`, user.id, Date.now());
}
// This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// photoId = trek_photos.id (6), not journey_photos.id (1)
const result = svc.validateShareTokenForPhoto(token, trekPhotoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
expect(result!.journeyId).toBe(journey.id);
});
});
describe('validateShareTokenForAsset', () => {
it('JOURNEY-SHARE-013: returns ownerId when asset belongs to shared journey', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-123', ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.validateShareTokenForAsset(token, 'immich-asset-123');
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-014: returns null for invalid token', () => {
const result = svc.validateShareTokenForAsset('bad-token', 'some-asset');
expect(result).toBeNull();
});
// Regression — GHSA-9hc8 sibling: the asset proxy must honour share_gallery.
it('JOURNEY-SHARE-018: returns null when the owner disabled the gallery (share_gallery=false)', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-999', ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
expect(svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull();
});
it('JOURNEY-SHARE-029: falls back to the journey owner when the photo has no owner_id', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-orphan' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// Without the fallback the controller filled the gap from the :ownerId path
// segment, i.e. an anonymous caller picked whose provider credentials to try.
const result = svc.validateShareTokenForAsset(token, 'immich-asset-orphan');
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-015: denies (returns null) when the asset is not part of the shared journey', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// A valid share token must NOT resolve arbitrary asset IDs to the owner —
// otherwise it could proxy any asset out of the owner's Immich/Synology
// library (IDOR). Only assets actually in the journey may resolve.
const result = svc.validateShareTokenForAsset(token, 'nonexistent-asset');
expect(result).toBeNull();
});
});
describe('getPublicJourney', () => {
it('JOURNEY-SHARE-016: returns null for invalid token', () => {
const result = svc.getPublicJourney('invalid-token');
expect(result).toBeNull();
});
it('JOURNEY-SHARE-017: returns journey data with entries, stats, and permissions', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, {
title: 'Japan 2026',
subtitle: 'Cherry blossom season',
});
const entry1 = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry',
title: 'Arrived in Tokyo',
entry_date: '2026-03-20',
location_name: 'Tokyo',
});
createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry',
title: 'Kyoto Day Trip',
entry_date: '2026-03-22',
location_name: 'Kyoto',
});
insertJourneyPhoto(entry1.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: true,
share_map: false,
});
const result = svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.journey.title).toBe('Japan 2026');
expect(result!.journey.subtitle).toBe('Cherry blossom season');
// The "this journey does not use that field" switches travel with the share:
// the phone card reads them, and reading an absent one made every field look
// switched on, so a reader saw chips the owner had turned off.
expect(result!.journey).toMatchObject({ show_verdict: 1, show_mood: 1, show_weather: 1 });
expect(result!.entries).toHaveLength(2);
expect(result!.stats.entries).toBe(2);
expect(result!.stats.photos).toBe(1);
expect(result!.stats.places).toBe(2);
expect(result!.permissions.share_timeline).toBe(true);
expect(result!.permissions.share_gallery).toBe(true);
expect(result!.permissions.share_map).toBe(false);
});
it('JOURNEY-SHARE-017b: a field the owner switched off is switched off for the reader too', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
testDb.prepare('UPDATE journeys SET show_mood = 0, show_weather = 0 WHERE id = ?').run(journey.id);
createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Tag 1', entry_date: '2026-03-20' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: false, share_map: true,
});
const result = svc.getPublicJourney(token)!;
expect(result.journey).toMatchObject({ show_mood: 0, show_weather: 0, show_verdict: 1 });
});
it('JOURNEY-SHARE-018: excludes skeleton entries from public view', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry',
title: 'Visible Entry',
entry_date: '2026-01-10',
});
createJourneyEntry(testDb, journey.id, user.id, {
type: 'skeleton',
title: 'Skeleton Entry',
entry_date: '2026-01-11',
});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.entries).toHaveLength(1);
expect(result!.entries[0].title).toBe('Visible Entry');
});
it('JOURNEY-SHARE-019: enriches entries with parsed tags and photos', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry',
entry_date: '2026-04-01',
});
// Set tags on the entry directly
testDb.prepare('UPDATE journey_entries SET tags = ? WHERE id = ?')
.run(JSON.stringify(['food', 'culture']), entry.id);
insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' });
insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.getPublicJourney(token);
expect(result).not.toBeNull();
const enriched = result!.entries[0];
expect(enriched.tags).toEqual(['food', 'culture']);
expect(enriched.photos).toHaveLength(2);
});
it('JOURNEY-SHARE-020: returns empty entries array for journey with no entries', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Empty Journey' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.getPublicJourney(token);
expect(result).not.toBeNull();
expect(result!.entries).toEqual([]);
expect(result!.stats.entries).toBe(0);
expect(result!.stats.photos).toBe(0);
expect(result!.stats.places).toBe(0);
});
it('JOURNEY-SHARE-021: withholds timeline, gallery and GPS when all flags are off', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Secret' });
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris',
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
insertJourneyPhoto(entry.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: false, share_map: false,
});
const result = svc.getPublicJourney(token)!;
expect(result.entries).toEqual([]); // no timeline / story / GPS leaked
expect(result.gallery).toEqual([]); // no gallery leaked
expect(result.stats.entries).toBe(1); // counts stay accurate
});
it('JOURNEY-SHARE-022: shares the timeline but strips GPS when the map flag is off', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', location_name: 'Paris',
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: false,
});
const result = svc.getPublicJourney(token)!;
expect(result.entries).toHaveLength(1);
const e = result.entries[0] as Record<string, unknown>;
expect(e.story).toBe('notes'); // narrative present
expect(e.location_lat).toBeNull(); // GPS withheld
expect(e.location_lng).toBeNull();
});
it('JOURNEY-SHARE-023: map-only share exposes coordinates but not the story', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris',
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: false, share_map: true,
});
const result = svc.getPublicJourney(token)!;
expect(result.entries).toHaveLength(1);
const e = result.entries[0] as Record<string, unknown>;
expect(e.location_lat).toBe(48.8566); // coords for the map
expect(e.story).toBeUndefined(); // narrative withheld
});
// #1614 — a photo now carries the coordinates it was taken at. That is a place
// the owner never typed, so it has to follow the same switch the entry
// coordinates follow rather than riding in on the gallery flag.
it('JOURNEY-SHARE-025: withholds photo capture coordinates when the map flag is off', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01',
});
const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id });
testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ?, taken_at = ? WHERE id = ?')
.run(48.8584, 2.2945, '2026-05-01T10:00:00Z', trekId);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: false,
});
const result = svc.getPublicJourney(token)!;
const gallery = result.gallery as Record<string, unknown>[];
expect(gallery).toHaveLength(1);
expect(gallery[0].lat).toBeNull();
expect(gallery[0].lng).toBeNull();
// The capture time is not a location and stays — it is what a blog view sorts by.
expect(gallery[0].taken_at).toBe('2026-05-01T10:00:00Z');
const inline = (result.entries[0] as Record<string, unknown>).photos as Record<string, unknown>[];
expect(inline[0].lat).toBeNull();
expect(inline[0].lng).toBeNull();
});
it('JOURNEY-SHARE-026: hands out photo coordinates once the map is shared', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', entry_date: '2026-05-01',
});
const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id });
testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ? WHERE id = ?')
.run(48.8584, 2.2945, trekId);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: true,
});
const gallery = svc.getPublicJourney(token)!.gallery as Record<string, unknown>[];
expect(gallery[0].lat).toBe(48.8584);
expect(gallery[0].lng).toBe(2.2945);
});
it('JOURNEY-SHARE-024: strips inline entry photos (and their asset metadata) when the gallery is off', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01',
});
insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: false, share_map: true,
});
const result = svc.getPublicJourney(token)!;
expect(result.gallery).toEqual([]); // gallery array withheld
expect(result.entries).toHaveLength(1);
expect((result.entries[0] as Record<string, unknown>).photos).toEqual([]); // inline photos withheld too
});
// #2200: the reader of a shared journey gets the same chronology as the owner,
// so the public gallery cannot fall back to upload order.
it('JOURNEY-SHARE-031: the public gallery reads in capture order, not upload order', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const day1 = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 1', entry_date: '2026-05-01',
});
const day2 = createJourneyEntry(testDb, journey.id, user.id, {
type: 'entry', title: 'Day 2', entry_date: '2026-05-02',
});
const late = insertJourneyPhoto(day2.id, { filePath: '/photos/day2.jpg' });
insertJourneyPhoto(day1.id, { filePath: '/photos/day1.jpg' });
testDb.prepare('UPDATE trek_photos SET taken_at = ? WHERE id = ?').run('2026-05-02T16:00:00.000Z', late);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: true,
});
const gallery = svc.getPublicJourney(token)!.gallery as Record<string, unknown>[];
expect(gallery.map(p => p.file_path)).toEqual(['/photos/day1.jpg', '/photos/day2.jpg']);
});
it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true });
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('');
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('instance-key');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id);
expect(svc.getPublicJourney(token)!.cartoApiKey).toBe('owner-key');
});
});