mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
1f66ef1d32
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile fetched without a key since 26.08.2026, and both CartoDB presets plus the built-in fallback are CARTO. The key is free and needs no account, so add it as a setting rather than dropping the provider. carto_api_key sits next to mapbox_access_token: per user in Settings/Map, instance-wide under Admin/User Defaults, encrypted at rest, injected from CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the map provider, since a raster key says nothing about the renderer. The key is appended as ?key= at render time instead of being stored in the template, so a saved URL stays portable and survives a rotation. That is also what reaches the four maps that pinned CARTO and ignored the user's tile setting: atlas, collections, the public trip share and the public journey. The two share pages read it from their token-gated payload, the same way baseCurrency already travels. Also lifts the release notice above page chrome. Its overlay sat at z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither on top nor modal. Adds a six-step layer scale to index.css and moves the three notice shells onto it. Closes #2054 Closes #2052
650 lines
27 KiB
TypeScript
650 lines
27 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');
|
|
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-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
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|