mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
5d849ec292
journey_photos.sort_order is nothing but the insert counter, so photos added late landed behind days they belong before. Both gallery readers (the app and the shared journey) order by capture time now, falling back to the timestamp of the stop the photo belongs to and then to upload time, through one shared SQL fragment; ties keep the old order, so existing galleries only move where chronology demands it. The order inside an entry stays the user's manual arrangement.
674 lines
28 KiB
TypeScript
674 lines
28 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
|
|
});
|
|
|
|
// #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');
|
|
});
|
|
});
|