Spaces:
Sleeping
Sleeping
| /** | |
| * 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 { | |
| createOrUpdateJourneyShareLink, | |
| getJourneyShareLink, | |
| deleteJourneyShareLink, | |
| validateShareTokenForPhoto, | |
| validateShareTokenForAsset, | |
| getPublicJourney, | |
| } from '../../../src/services/journeyShareService'; | |
| 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 = 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); | |
| createOrUpdateJourneyShareLink(journey.id, user.id, { | |
| share_timeline: true, | |
| share_gallery: false, | |
| share_map: false, | |
| }); | |
| const link = 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 = createOrUpdateJourneyShareLink(journey.id, user.id, { | |
| share_timeline: true, | |
| share_gallery: true, | |
| share_map: true, | |
| }); | |
| const second = 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 = 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 = createOrUpdateJourneyShareLink(j1.id, user.id, {}); | |
| const r2 = 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 = 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); | |
| createOrUpdateJourneyShareLink(journey.id, user.id, { | |
| share_timeline: true, | |
| share_gallery: false, | |
| share_map: true, | |
| }); | |
| const result = 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); | |
| createOrUpdateJourneyShareLink(journey.id, user.id, {}); | |
| const ok = deleteJourneyShareLink(journey.id, user.id); | |
| expect(ok).toBe(true); | |
| expect(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(() => 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, {}); | |
| const result = 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 = 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 } = createOrUpdateJourneyShareLink(journey1.id, user.id, {}); | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, {}); | |
| const result = validateShareTokenForPhoto(token, photoId); | |
| expect(result).not.toBeNull(); | |
| expect(result!.ownerId).toBe(user.id); | |
| }); | |
| 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, {}); | |
| // photoId = trek_photos.id (6), not journey_photos.id (1) | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, {}); | |
| const result = 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 = validateShareTokenForAsset('bad-token', 'some-asset'); | |
| expect(result).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 } = 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 = validateShareTokenForAsset(token, 'nonexistent-asset'); | |
| expect(result).toBeNull(); | |
| }); | |
| }); | |
| describe('getPublicJourney', () => { | |
| it('JOURNEY-SHARE-016: returns null for invalid token', () => { | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, { | |
| share_timeline: true, | |
| share_gallery: true, | |
| share_map: false, | |
| }); | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, {}); | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, {}); | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, {}); | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, { | |
| share_timeline: false, share_gallery: false, share_map: false, | |
| }); | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, { | |
| share_timeline: true, share_gallery: true, share_map: false, | |
| }); | |
| const result = 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, { | |
| share_timeline: false, share_gallery: false, share_map: true, | |
| }); | |
| const result = 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 | |
| }); | |
| 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 } = createOrUpdateJourneyShareLink(journey.id, user.id, { | |
| share_timeline: true, share_gallery: false, share_map: true, | |
| }); | |
| const result = 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 | |
| }); | |
| }); | |