diff --git a/client/src/components/PDF/JourneyBookPDF.test.tsx b/client/src/components/PDF/JourneyBookPDF.test.tsx index bb43e711..1e8c5810 100644 --- a/client/src/components/PDF/JourneyBookPDF.test.tsx +++ b/client/src/components/PDF/JourneyBookPDF.test.tsx @@ -1,8 +1,8 @@ // FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006 // // JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)` -// that opens a new browser window and writes a full HTML document into it. -// It does NOT render a React component. Tests verify window.open behaviour. +// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern). +// Tests verify the overlay DOM structure and HTML content. import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; @@ -77,55 +77,57 @@ function buildJourney(overrides: Partial = {}): JourneyDetail { } as unknown as JourneyDetail; } -// ── Mock window.open ───────────────────────────────────────────────────────── +// ── Helpers to inspect the overlay ─────────────────────────────────────────── -let mockWindow: { - document: { write: ReturnType; close: ReturnType }; - focus: ReturnType; -}; +function getOverlay(): HTMLElement | null { + return document.getElementById('journey-pdf-overlay'); +} -beforeEach(() => { - mockWindow = { - document: { write: vi.fn(), close: vi.fn() }, - focus: vi.fn(), - }; - vi.spyOn(window, 'open').mockReturnValue(mockWindow as any); -}); +function getIframe(): HTMLIFrameElement | null { + return getOverlay()?.querySelector('iframe') ?? null; +} + +// ── Setup ──────────────────────────────────────────────────────────────────── afterEach(() => { + document.getElementById('journey-pdf-overlay')?.remove(); vi.restoreAllMocks(); }); // ── Tests ──────────────────────────────────────────────────────────────────── describe('downloadJourneyBookPDF', () => { - it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => { + it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => { await downloadJourneyBookPDF(buildJourney()); - expect(window.open).toHaveBeenCalledWith('', '_blank'); + expect(getOverlay()).not.toBeNull(); + expect(document.body.contains(getOverlay())).toBe(true); }); - it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => { + it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => { await downloadJourneyBookPDF(buildJourney()); - expect(mockWindow.document.write).toHaveBeenCalledTimes(1); - const html = mockWindow.document.write.mock.calls[0][0] as string; + const iframe = getIframe(); + expect(iframe).not.toBeNull(); + const html = iframe!.srcdoc; expect(html).toContain(''); expect(html).toContain(''); }); - it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => { + it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => { await downloadJourneyBookPDF(buildJourney()); - expect(mockWindow.document.close).toHaveBeenCalledTimes(1); + const overlay = getOverlay()!; + expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull(); + expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull(); }); it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => { await downloadJourneyBookPDF(buildJourney()); - const html = mockWindow.document.write.mock.calls[0][0] as string; + const html = getIframe()!.srcdoc; expect(html).toContain('Iceland Ring Road'); }); it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => { await downloadJourneyBookPDF(buildJourney()); - const html = mockWindow.document.write.mock.calls[0][0] as string; + const html = getIframe()!.srcdoc; expect(html).toContain('Golden Circle'); // Story text is rendered via markdown expect(html).toContain('An incredible day of geysers and waterfalls.'); @@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => { it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => { const journey = buildJourney({ entries: [] }); await downloadJourneyBookPDF(journey); - expect(window.open).toHaveBeenCalled(); - const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(getOverlay()).not.toBeNull(); + const html = getIframe()!.srcdoc; expect(html).toContain('Iceland Ring Road'); // No entry pages, but cover and closing page are still present expect(html).toContain('Journey Book'); diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx index 80d38333..bdf4133a 100644 --- a/client/src/components/PDF/JourneyBookPDF.tsx +++ b/client/src/components/PDF/JourneyBookPDF.tsx @@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) { .entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; } } - .print-bar { - position: fixed; top: 0; left: 0; right: 0; z-index: 9999; - background: rgba(15,23,42,0.95); backdrop-filter: blur(12px); - padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px; - } - .print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; } - .print-bar .btn-save { background: white; color: #0f172a; } - .print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); } - .print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); } -
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) { ` - const win = window.open('', '_blank') - if (!win) return - win.document.write(html) - win.document.close() + // Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF. + // This avoids window.open() which Safari iOS blocks in async callbacks + // and window.close() which doesn't work reliably in standalone PWA mode. + const overlay = document.createElement('div') + overlay.id = 'journey-pdf-overlay' + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;' + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() } + + const card = document.createElement('div') + card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);' + + const header = document.createElement('div') + header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;' + header.innerHTML = ` + ${esc(journey.title)} · ${totalPages} pages +
+ + +
+ ` + + const iframe = document.createElement('iframe') + iframe.style.cssText = 'flex:1;width:100%;border:none;' + iframe.sandbox = 'allow-same-origin allow-modals' + iframe.srcdoc = html + + card.appendChild(header) + card.appendChild(iframe) + overlay.appendChild(card) + document.body.appendChild(overlay) + + header.querySelector('#journey-pdf-close')!.onclick = () => overlay.remove() + header.querySelector('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() } } diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index 3cd44406..4b7aad9d 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -674,7 +674,10 @@ describe('JourneyDetailPage', () => { // ── FE-PAGE-JOURNEYDETAIL-027 ────────────────────────────────────────── describe('FE-PAGE-JOURNEYDETAIL-027: Shows loading spinner before data loads', () => { it('renders a spinner while journey data is loading', () => { - // Do NOT await the waitFor -- we check the loading state before data arrives + // Pre-seed the store into a loading state (current: null, loading: true). + // We can't rely on render() timing because RTL wraps in act(), which flushes + // all microtasks including the MSW response before render() returns. + useJourneyStore.setState({ loading: true, current: null }); render(); // The spinner has animate-spin class on a div const spinner = document.querySelector('.animate-spin'); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 9105a554..9786de43 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1423,6 +1423,24 @@ function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: ) } +// ── Photo date grouping ─────────────────────────────────────────────────── + +function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] { + const map = new Map() + for (const asset of photos) { + const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__' + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(asset) + } + return [...map.entries()].map(([date, assets]) => ({ + date, + label: date === '__unknown__' + ? 'Unknown date' + : new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }), + assets, + })) +} + // ── Provider Picker ─────────────────────────────────────────────────────── function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: { @@ -1547,7 +1565,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on : t('journey.picker.newGallery') return ( -
+
{/* Header */} @@ -1732,51 +1750,60 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on

) : ( -
- {photos.map((asset: any) => { - const isSelected = selected.has(asset.id) - const alreadyAdded = existingAssetIds.has(asset.id) - return ( -
!alreadyAdded && toggleAsset(asset.id)} - className={`relative aspect-square rounded-lg overflow-hidden ${ - alreadyAdded - ? 'opacity-40 cursor-not-allowed' - : isSelected - ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer' - : 'cursor-pointer' - }`} - > - { - const img = e.currentTarget - const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original` - if (!img.src.includes('/original')) img.src = original - }} - /> - {alreadyAdded && ( -
- -
- )} - {isSelected && !alreadyAdded && ( -
- -
- )} - {asset.city && ( -
-

{asset.city}

-
- )} +
+ {groupPhotosByDate(photos).map(group => ( +
+

+ {group.label} +

+
+ {group.assets.map((asset: any) => { + const isSelected = selected.has(asset.id) + const alreadyAdded = existingAssetIds.has(asset.id) + return ( +
!alreadyAdded && toggleAsset(asset.id)} + className={`relative aspect-square rounded-lg overflow-hidden ${ + alreadyAdded + ? 'opacity-40 cursor-not-allowed' + : isSelected + ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer' + : 'cursor-pointer' + }`} + > + { + const img = e.currentTarget + const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original` + if (!img.src.includes('/original')) img.src = original + }} + /> + {alreadyAdded && ( +
+ +
+ )} + {isSelected && !alreadyAdded && ( +
+ +
+ )} + {asset.city && ( +
+

{asset.city}

+
+ )} +
+ ) + })}
- ) - })} +
+ ))} {/* Infinite scroll trigger */} {hasMore && !selectedAlbum && }
@@ -2000,7 +2027,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa } return ( -
+
@@ -2384,7 +2411,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: { } return ( -
+
@@ -2481,7 +2508,7 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite } return ( -
+
@@ -2738,7 +2765,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { } return ( -
{ if (e.target === e.currentTarget) e.preventDefault() }}> +
{ if (e.target === e.currentTarget) e.preventDefault() }}>
e.stopPropagation()}>
diff --git a/client/src/store/journeyStore.test.ts b/client/src/store/journeyStore.test.ts index 7b1f6760..564969ec 100644 --- a/client/src/store/journeyStore.test.ts +++ b/client/src/store/journeyStore.test.ts @@ -314,6 +314,47 @@ describe('journeyStore', () => { expect(storedEntry?.photos[0].id).toBe(201); }); + // ── loadJourney silent refresh ─────────────────────────────────────────── + + it('FE-STORE-JOURNEY-016: loadJourney does not set loading when refreshing same journey', async () => { + const existing = buildJourneyDetail({ id: 5, title: 'Old' }); + useJourneyStore.setState({ current: existing, loading: false }); + + const loadingValues: boolean[] = []; + const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading)); + + const refreshed = buildJourneyDetail({ id: 5, title: 'Refreshed' }); + server.use( + http.get('/api/journeys/5', () => HttpResponse.json(refreshed)) + ); + + await useJourneyStore.getState().loadJourney(5); + unsub(); + + expect(loadingValues.every(v => v === false)).toBe(true); + expect(useJourneyStore.getState().current?.title).toBe('Refreshed'); + }); + + it('FE-STORE-JOURNEY-017: loadJourney sets loading on cold load (different journey)', async () => { + const existing = buildJourneyDetail({ id: 5 }); + useJourneyStore.setState({ current: existing, loading: false }); + + const loadingValues: boolean[] = []; + const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading)); + + const other = buildJourneyDetail({ id: 99 }); + server.use( + http.get('/api/journeys/99', () => HttpResponse.json(other)) + ); + + await useJourneyStore.getState().loadJourney(99); + unsub(); + + expect(loadingValues).toContain(true); + expect(useJourneyStore.getState().current?.id).toBe(99); + expect(useJourneyStore.getState().loading).toBe(false); + }); + // ── clear ──────────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-015: clear resets state', () => { diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index e0a8f22b..c0464133 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -124,7 +124,8 @@ export const useJourneyStore = create((set, get) => ({ }, loadJourney: async (id) => { - set({ loading: true, notFound: false }) + const cold = get().current?.id !== id + if (cold) set({ loading: true, notFound: false }) try { const data = await journeyApi.get(id) set({ current: data }) @@ -134,7 +135,7 @@ export const useJourneyStore = create((set, get) => ({ } throw err } finally { - set({ loading: false }) + if (cold) set({ loading: false }) } }, diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts index 87f3fa0f..9486234a 100644 --- a/server/src/routes/memories/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -60,16 +60,12 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => { router.post('/search', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { from, to, size } = req.body; + const { from, to, size, page } = req.body; + const pageNum = Math.max(1, Number(page) || 1); const pageSize = Math.min(Number(size) || 50, 200); - const allAssets: any[] = []; - for (let page = 1; page <= 20; page++) { - const result = await searchPhotos(authReq.user.id, from, to, page, pageSize); - if (result.error) return res.status(result.status!).json({ error: result.error }); - if (result.assets) allAssets.push(...result.assets); - if (!result.hasMore) break; - } - res.json({ assets: allAssets }); + const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ assets: result.assets || [], hasMore: !!result.hasMore }); }); // ── Asset Details ────────────────────────────────────────────────────────── diff --git a/server/src/services/journeyShareService.ts b/server/src/services/journeyShareService.ts index 46ef6926..c85621de 100644 --- a/server/src/services/journeyShareService.ts +++ b/server/src/services/journeyShareService.ts @@ -63,7 +63,7 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo FROM journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN journey_entries je ON jp.entry_id = je.id - WHERE jp.id = ? AND je.journey_id = ? + WHERE jp.photo_id = ? AND je.journey_id = ? `).get(photoId, row.journey_id) as any; if (!photo) return null; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts index eb19cd48..2b3cdefb 100644 --- a/server/tests/integration/memories-immich.test.ts +++ b/server/tests/integration/memories-immich.test.ts @@ -273,18 +273,19 @@ describe('Immich browse and search', () => { expect(res.body.buckets.length).toBeGreaterThan(0); }); - it('IMMICH-042 — POST /search returns mapped assets', async () => { + it('IMMICH-042 — POST /search returns mapped assets with hasMore flag', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) - .send({}); + .send({ page: 1, size: 50 }); expect(res.status).toBe(200); expect(Array.isArray(res.body.assets)).toBe(true); expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' }); + expect(typeof res.body.hasMore).toBe('boolean'); }); it('IMMICH-043 — POST /search when upstream throws returns 502', async () => { @@ -611,43 +612,77 @@ describe('Immich syncAlbumAssets', () => { // ── searchPhotos pagination safety ──────────────────────────────────────────── -describe('Immich searchPhotos pagination safety', () => { - it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => { +describe('Immich searchPhotos pagination pass-through', () => { + it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => { const { user } = createUser(testDb); setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); - // Return a full page of 1000 items on every call, so the loop would - // run indefinitely without the page > 20 safety check. + // Return a full page so hasMore=true (items.length >= size) const fullPageResponse = { ok: true, status: 200, headers: { get: () => null }, json: () => Promise.resolve({ assets: { - items: Array.from({ length: 1000 }, (_, i) => ({ - id: `asset-${i}`, + items: Array.from({ length: 50 }, (_, i) => ({ + id: `asset-p2-${i}`, fileCreatedAt: '2024-06-01T10:00:00.000Z', - exifInfo: { city: 'Paris', country: 'France' }, + exifInfo: { city: 'Berlin', country: 'Germany' }, })), }, }), body: null, } as any; - // Clear previous call history so the count only reflects this test vi.mocked(safeFetch).mockClear(); vi.mocked(safeFetch).mockResolvedValue(fullPageResponse); const res = await request(app) .post(`${IMMICH}/search`) .set('Cookie', authCookie(user.id)) - .send({}); + .send({ page: 2, size: 50 }); expect(res.status).toBe(200); expect(Array.isArray(res.body.assets)).toBe(true); - // 20 pages × 1000 items = 20000 assets total (safety limit) - expect(res.body.assets.length).toBe(20000); - // safeFetch should have been called exactly 20 times (the safety limit) - expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20); + // Single page returned — not 20× aggregation + expect(res.body.assets.length).toBe(50); + expect(res.body.hasMore).toBe(true); + // Immich was called exactly once + expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(1); + // page=2 was forwarded to Immich + const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string); + expect(callBody.page).toBe(2); + }); + + it('IMMICH-091 — POST /search returns hasMore=false on last page', async () => { + const { user } = createUser(testDb); + setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key'); + + // Partial page → hasMore=false + const partialPageResponse = { + ok: true, status: 200, + headers: { get: () => null }, + json: () => Promise.resolve({ + assets: { + items: Array.from({ length: 3 }, (_, i) => ({ + id: `asset-last-${i}`, + fileCreatedAt: '2024-06-01T10:00:00.000Z', + exifInfo: { city: 'Rome', country: 'Italy' }, + })), + }, + }), + body: null, + } as any; + + vi.mocked(safeFetch).mockResolvedValue(partialPageResponse); + + const res = await request(app) + .post(`${IMMICH}/search`) + .set('Cookie', authCookie(user.id)) + .send({ page: 5, size: 50 }); + + expect(res.status).toBe(200); + expect(res.body.assets.length).toBe(3); + expect(res.body.hasMore).toBe(false); }); }); diff --git a/server/tests/unit/services/journeyShareService.test.ts b/server/tests/unit/services/journeyShareService.test.ts index 371e170e..8027e068 100644 --- a/server/tests/unit/services/journeyShareService.test.ts +++ b/server/tests/unit/services/journeyShareService.test.ts @@ -58,7 +58,7 @@ afterAll(() => { // -- Helpers ------------------------------------------------------------------ -/** Insert a journey_photos row and return its id. */ +/** Insert a trek_photos + journey_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 } = {} @@ -70,11 +70,13 @@ function insertJourneyPhoto( VALUES (?, ?, ?, ?, ?) `).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now()); const trekId = trekResult.lastInsertRowid as number; - const result = testDb.prepare(` + testDb.prepare(` INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at) VALUES (?, ?, NULL, 0, ?) `).run(entryId, trekId, Date.now()); - return result.lastInsertRowid as number; + // 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 -------------------------------------------------------------------- @@ -237,6 +239,31 @@ describe('validateShareTokenForPhoto', () => { 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', () => {