mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
97220b72f6
The gallery rows now hand the client taken_at, lat and lng, which is what the map and a capture-ordered gallery will read. The coordinates needed a projection before they could ship. A photo's capture location is a place the owner never typed and may not expect to publish, so it follows share_map — the same switch the entry coordinates already follow. Without that, turning the map off and leaving the gallery on would have handed out exactly the locations the owner had just withheld. The capture time is not a location and stays either way; a blog-style view sorts by it.
564 lines
23 KiB
TypeScript
564 lines
23 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 } 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 { 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)));
|
|
|
|
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-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-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
|
|
});
|
|
});
|