import { ADDON_IDS } from '../../../src/addons'; import { AddonGuard } from '../../../src/nest/addons/addon.guard'; import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'; import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service'; import type { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service'; import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller'; import { JourneyController } from '../../../src/nest/journey/journey.controller'; import type { JourneyService } from '../../../src/nest/journey/journey.service'; import { journeyThumbName } from '../../../src/nest/memories/thumbnail.service'; import type { StorageService } from '../../../src/nest/storage/storage.service'; import type { User } from '../../../src/types'; import { HttpException } from '@nestjs/common'; import type { Response } from 'express'; import { describe, it, expect, vi, beforeEach } from 'vitest'; const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User; function svc(o: Partial = {}): JourneyService { return { journeyAddonEnabled: vi.fn().mockReturnValue(true), ...o } as unknown as JourneyService; } const storageExists = vi.fn(); const storageSendToResponse = vi.fn(); const storageStub = { put: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), exists: storageExists, sendToResponse: storageSendToResponse, } as unknown as StorageService; // The capture backfill is detached; most cases here only need it to exist so the // constructor can call it. The spies are read by the cases that pin which journey // (or entry) a provider batch is reported against, and that uploads get the // backfill without the refresh (#1587). const scheduleForEntry = vi.fn(); const scheduleForJourney = vi.fn(); const scheduleUpload = vi.fn(); const captureBackfillStub = { scheduleForEntry, scheduleForJourney, scheduleUpload, } as unknown as JourneyPhotoCaptureService; /** * Build the controller. * * Storage and the Studio book (#1973) are injected dependencies that almost no * case here is about, and naming all four at every call site is what made the * previous shape unmaintainable — a fifth dependency would have meant touching * ninety lines. Both are stubbed: pass a partial book when the case IS about * the book, and reach for `storageStub`'s own mocks when it is about an object * being stored or served. */ function ctl(service: JourneyService, books: Partial = {}): JourneyController { return new JourneyController(service, storageStub, books as JourneyBookService, captureBackfillStub); } async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; } throw new Error('expected throw'); } beforeEach(() => vi.clearAllMocks()); describe('JourneyController', () => { it('GET / lists; POST / 400 without title, else creates', async () => { expect( await ctl(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).list(user), ).toEqual({ journeys: [{ id: 1 }] }); expect(await thrownAsync(() => ctl(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' }, }); const createJourney = vi.fn().mockReturnValue({ id: 9 }); expect( await ctl(svc({ createJourney } as Partial)).create(user, { title: ' Trip ', trip_ids: [1, '2'], }), ).toEqual({ id: 9 }); expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] }); }); it('GET /suggestions + /available-trips', async () => { expect( await ctl(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).suggestions( user, ), ).toEqual({ trips: [{ id: 1 }] }); expect( await ctl(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial)).availableTrips( user, ), ).toEqual({ trips: [{ id: 2 }] }); }); it('PATCH/DELETE entries map 404', async () => { expect( await thrownAsync(() => ctl(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial)).updateEntry(user, '3', {}), ), ).toEqual({ status: 404, body: { error: 'Entry not found' } }); expect( await ctl(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).updateEntry( user, '3', { title: 'x' }, ), ).toEqual({ id: 3 }); expect( await thrownAsync(() => ctl(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial)).deleteEntry(user, '3'), ), ).toEqual({ status: 404, body: { error: 'Entry not found' } }); expect( await ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial)).deleteEntry(user, '3'), ).toEqual({ success: true }); }); it('provider-photos: batch, single 400/403, success', async () => { const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial); expect(await ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2, }); expect(await thrownAsync(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' }, }); expect( await thrownAsync(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial)).providerPhotos( user, '3', { provider: 'immich', asset_id: 'a' }, ), ), ).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } }); }); it('provider-photos hand what they added to the shared capture refresh, by entry or by journey; uploads only backfill (#1587)', async () => { const entryPhoto = { id: 1, photo_id: 11 }; const galleryPhoto = { id: 2, photo_id: 12 }; const withAdds = svc({ addProviderPhoto: vi.fn().mockReturnValue(entryPhoto), addProviderPhotoToGallery: vi.fn().mockReturnValue(galleryPhoto), addPhoto: vi.fn().mockReturnValue(entryPhoto), uploadGalleryPhotos: vi.fn().mockReturnValue([galleryPhoto]), immichAutoUploadEnabled: vi.fn().mockReturnValue(false), } as Partial); const c = ctl(withAdds); await c.providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'] }); await c.providerPhotos(user, '3', { provider: 'immich', asset_id: 'b' }); await c.uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}); expect(scheduleForEntry.mock.calls).toEqual([ [3, [entryPhoto], 1], [3, [entryPhoto], 1], ]); await c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a'] }); await c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'b' }); await c.uploadGalleryPhotos(user, '9', [{ filename: 'g.jpg' } as Express.Multer.File]); expect(scheduleForJourney.mock.calls).toEqual([ [9, [galleryPhoto], 1], [9, [galleryPhoto], 1], ]); // The client uploads one file per request, so a refresh per upload would // reload every open client once per photo: uploads backfill and stay quiet. expect(scheduleUpload.mock.calls).toEqual([ [[entryPhoto], 1], [[galleryPhoto], 1], ]); }); it('link-photo: 400 without id (accepts legacy photo_id), 403, success', async () => { expect(await thrownAsync(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' }, }); const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 }); const c = ctl(svc({ linkPhotoToEntry } as Partial)); expect(await c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 }); expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1); // accepts the canonical journey_photo_id, 403 when the service refuses expect( await thrownAsync(() => ctl(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial)).linkPhoto(user, '3', { journey_photo_id: 9, }), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('unlink photo (204) maps 404; delete photo 404 then removes the object', async () => { expect( await thrownAsync(() => ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial)).unlinkPhoto( user, '3', '7', ), ), ).toEqual({ status: 404, body: { error: 'Not found or not allowed' } }); expect( await ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial)).unlinkPhoto( user, '3', '7', ), ).toBeUndefined(); expect( await thrownAsync(() => ctl(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial)).deletePhoto(user, '7'), ), ).toEqual({ status: 404, body: { error: 'Photo not found' } }); expect( await ctl( svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial), ).deletePhoto(user, '7'), ).toEqual({ success: true }); }); it('gallery upload 400 no files / 403 not allowed, else commits + returns photos', async () => { expect(await thrownAsync(() => ctl(svc()).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' }, }); expect( await thrownAsync(() => ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial)).uploadGalleryPhotos( user, '3', [{ filename: 'a.jpg' } as Express.Multer.File], ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); expect( await ctl( svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial), ).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]), ).toEqual({ photos: [{ id: 1 }] }); expect(storageStub.put).toHaveBeenCalledWith('journey', 'a.jpg', { tmpPath: undefined }); }); it('gallery video: 400 no video, 403 not allowed, else stores the clip + poster (#823)', async () => { const files = { video: [{ filename: 'v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg' } as Express.Multer.File], }; expect(await thrownAsync(() => ctl(svc()).uploadGalleryVideo(user, '3', {}, {}))).toEqual({ status: 400, body: { error: 'No video uploaded' }, }); // Not-allowed after commit → the final objects are deleted through storage // (the pre-commit unlink would miss them: file.path is already consumed). const withPaths = { video: [{ filename: 'v.mp4', path: '/nonexistent/v.mp4' } as Express.Multer.File], poster: [{ filename: 'p.jpg', path: '/nonexistent/p.jpg' } as Express.Multer.File], }; expect( await thrownAsync(() => ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial)).uploadGalleryVideo( user, '3', withPaths, { duration_ms: 'abc' }, ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'v.mp4'); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'p.jpg'); const up = vi.fn().mockReturnValue([{ id: 7 }]); expect( await ctl(svc({ uploadGalleryPhotos: up } as Partial)).uploadGalleryVideo(user, '3', files, { duration_ms: '4200', }), ).toEqual({ photos: [{ id: 7 }] }); expect(up).toHaveBeenCalledWith(3, 1, [ { path: 'journey/v.mp4', thumbnail: 'journey/p.jpg', mediaType: 'video', durationMs: 4200 }, ]); // No poster + no duration → thumbnail undefined, durationMs null. const up2 = vi.fn().mockReturnValue([{ id: 8 }]); await ctl(svc({ uploadGalleryPhotos: up2 } as Partial)).uploadGalleryVideo( user, '3', { video: [{ filename: 'v2.mp4' } as Express.Multer.File] }, {}, ); expect(up2).toHaveBeenCalledWith(3, 1, [ { path: 'journey/v2.mp4', thumbnail: undefined, mediaType: 'video', durationMs: null }, ]); }); it('provider-photos forwards per-asset media_types for gallery and entries (#823)', async () => { const add = vi.fn().mockReturnValue({ id: 1 }); await ctl(svc({ addProviderPhotoToGallery: add } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'], }); expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video'); expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image'); const addOne = vi.fn().mockReturnValue({ id: 2 }); await ctl(svc({ addProviderPhotoToGallery: addOne } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video', }); expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video'); // Entry path mirrors the gallery path. const eAdd = vi.fn().mockReturnValue({ id: 3 }); await ctl(svc({ addProviderPhoto: eAdd } as Partial)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c', }); expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video'); const eOne = vi.fn().mockReturnValue({ id: 4 }); await ctl(svc({ addProviderPhoto: eOne } as Partial)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video', }); expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video'); }); it('GET/PATCH/DELETE /:id map 404', async () => { expect( await thrownAsync(() => ctl(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial)).get(user, '9'), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); expect( await ctl(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial)).get(user, '9'), ).toEqual({ id: 9 }); expect( await thrownAsync(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial)).update(user, '9', {}), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); expect( await thrownAsync(() => ctl(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial)).remove(user, '9'), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); }); it('trips: POST 400 without trip_id / 403, DELETE 403', async () => { expect(await thrownAsync(() => ctl(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' }, }); expect( await thrownAsync(() => ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial)).addTrip(user, '9', { trip_id: 2, }), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); expect( await ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial)).addTrip( user, '9', { trip_id: 2 }, ), ).toEqual({ success: true }); expect( await thrownAsync(() => ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial)).removeTrip( user, '9', '2', ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('entries under journey: list 404, create 400/404, reorder 400/403', async () => { expect( await thrownAsync(() => ctl(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial)).listEntries(user, '9'), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); expect( await ctl(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listEntries( user, '9', ), ).toEqual({ entries: [{ id: 1 }] }); expect(await thrownAsync(() => ctl(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' }, }); expect( await thrownAsync(() => ctl(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial)).createEntry(user, '9', { entry_date: '2026-01-01', }), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); expect(await thrownAsync(() => ctl(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' }, }); expect( await thrownAsync(() => ctl(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial)).reorderEntries( user, '9', { orderedIds: [1, 2] }, ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('contributors: add 400/403, update 403, remove 403', async () => { expect(await thrownAsync(() => ctl(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' }, }); expect( await thrownAsync(() => ctl(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial)).addContributor( user, '9', { user_id: 2 }, ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); expect( await ctl(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial)).addContributor( user, '9', { user_id: 2 }, ), ).toEqual({ success: true }); expect( await thrownAsync(() => ctl( svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial), ).updateContributor(user, '9', '2', { role: 'editor' }), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); expect( await thrownAsync(() => ctl(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial)).removeContributor( user, '9', '2', ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('preferences 403, share-link get/set/delete', async () => { expect( await thrownAsync(() => ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial)).preferences( user, '9', {}, ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); expect( await ctl( svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: { token: 'abc' } }), } as Partial), ).getShareLink(user, '9'), ).toEqual({ link: { token: 'abc' } }); // An owner whose journey has no link gets the null; a non-owner gets the // same refusal set/delete give, so the client can tell the two apart. expect( await ctl( svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: null }) } as Partial), ).getShareLink(user, '9'), ).toEqual({ link: null }); expect( await thrownAsync(() => ctl( svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: false }) } as Partial), ).getShareLink(user, '9'), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); expect( await thrownAsync(() => ctl( svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial), ).setShareLink(user, '9', {}), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); const setLink = vi.fn().mockReturnValue({ token: 'abc' }); expect( await ctl(svc({ createOrUpdateJourneyShareLink: setLink } as Partial)).setShareLink(user, '9', { share_timeline: true, }), ).toEqual({ token: 'abc' }); // newest_first is part of the shared contract and the public viewer reads // it, so it has to reach the service instead of being dropped here (#1614). expect(setLink).toHaveBeenCalledWith(9, 1, { share_timeline: true, share_gallery: undefined, share_map: undefined, newest_first: undefined, }); const withOrder = vi.fn().mockReturnValue({ token: 'abc' }); await ctl(svc({ createOrUpdateJourneyShareLink: withOrder } as Partial)).setShareLink(user, '9', { newest_first: true, }); expect(withOrder).toHaveBeenCalledWith(9, 1, { share_timeline: undefined, share_gallery: undefined, share_map: undefined, newest_first: true, }); expect( await thrownAsync(() => ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial)).deleteShareLink( user, '9', ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('takes the committed bytes back out when the upload is refused', async () => { // The commit happens before the authorization result is known (the Immich // mirror reads the final path), so every refusal after it has to delete the // object again. Nothing sweeps orphans. const file = { filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File; await thrownAsync(() => ctl(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial)).uploadEntryPhotos( user, '3', [file], {}, ), ); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'a.jpg'); vi.clearAllMocks(); await thrownAsync(() => ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial)).uploadGalleryPhotos( user, '3', [{ filename: 'g.jpg' } as Express.Multer.File], ), ); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'g.jpg'); vi.clearAllMocks(); await thrownAsync(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial)).cover(user, '9', { filename: 'c.jpg', } as Express.Multer.File), ); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'c.jpg'); }); it('drops the files of a partly refused entry batch, keeping the accepted ones', async () => { // A 200 with fewer photos than files still leaves unreferenced objects behind. const addPhoto = vi.fn().mockReturnValueOnce({ id: 5 }).mockReturnValueOnce(null); const immichAutoUploadEnabled = vi.fn().mockReturnValue(false); const files = [ { filename: 'ok.jpg', originalname: 'ok.jpg' } as Express.Multer.File, { filename: 'no.jpg', originalname: 'no.jpg' } as Express.Multer.File, ]; const res = await ctl(svc({ addPhoto, immichAutoUploadEnabled } as Partial)).uploadEntryPhotos( user, '3', files, {}, ); expect(res).toEqual({ photos: [{ id: 5 }] }); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'no.jpg'); expect(storageStub.delete).not.toHaveBeenCalledWith('journey', 'ok.jpg'); }); it('entry photo upload mirrors to Immich only when opted in', async () => { const addPhoto = vi.fn().mockReturnValue({ id: 5 }); const uploadToImmich = vi.fn().mockResolvedValue('immich-1'); const setPhotoProvider = vi.fn(); const s = svc({ addPhoto, immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich, setPhotoProvider, } as Partial); const res = await ctl(s).uploadEntryPhotos( user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}, ); expect(setPhotoProvider).toHaveBeenCalledWith(5, 'immich', 'immich-1', 1); expect(res).toEqual({ photos: [{ id: 5, provider: 'immich', asset_id: 'immich-1', owner_id: 1 }] }); const noOptIn = svc({ addPhoto: vi.fn().mockReturnValue({ id: 6 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(false), uploadToImmich, } as Partial); await ctl(noOptIn).uploadEntryPhotos( user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {}, ); expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above }); it('entry photo upload: 400 no files, 403 when nothing added, swallows immich errors and empty ids', async () => { expect(await thrownAsync(() => ctl(svc()).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' }, }); expect( await thrownAsync(() => ctl(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial)).uploadEntryPhotos( user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}, ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); // opted in but the immich upload throws → best-effort, the local photo still wins const setPhotoProvider = vi.fn(); const blowsUp = svc({ addPhoto: vi.fn().mockReturnValue({ id: 8 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockRejectedValue(new Error('immich down')), setPhotoProvider, } as Partial); expect( await ctl(blowsUp).uploadEntryPhotos( user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' }, ), ).toEqual({ photos: [{ id: 8 }] }); expect(setPhotoProvider).not.toHaveBeenCalled(); // opted in but immich returns a falsy id → no provider stamping const noId = svc({ addPhoto: vi.fn().mockReturnValue({ id: 9 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockResolvedValue(''), setPhotoProvider, } as Partial); expect( await ctl(noId).uploadEntryPhotos( user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}, ), ).toEqual({ photos: [{ id: 9 }] }); }); it('provider-photos batch passes the passphrase through when present', async () => { const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 }); await ctl(svc({ addProviderPhoto } as Partial)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret', }); expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image'); // single-photo success path expect( await ctl( svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial), ).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }), ).toEqual({ id: 2 }); }); it('PATCH photos: 404 then returns the updated photo', async () => { expect( await thrownAsync(() => ctl(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial)).updatePhoto(user, '7', { caption: 'x', }), ), ).toEqual({ status: 404, body: { error: 'Photo not found' } }); expect( await ctl(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).updatePhoto( user, '7', { caption: 'x' }, ), ).toEqual({ id: 7 }); }); it('DELETE photo removes the storage object when a path exists', async () => { expect( await ctl( svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial), ).deletePhoto(user, '7'), ).toEqual({ success: true }); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'a.jpg'); // ...and the derived thumb goes with it (spec fix #2). expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/a.jpg')); // a vanished object is swallowed (storageStub.delete as ReturnType).mockRejectedValueOnce(new Error('ENOENT')); expect( await ctl( svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial), ).deletePhoto(user, '8'), ).toEqual({ success: true }); // a file_path outside the journey category never reaches storage (storageStub.delete as ReturnType).mockClear(); expect( await ctl( svc({ deletePhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' }), } as Partial), ).deletePhoto(user, '9'), ).toEqual({ success: true }); expect(storageStub.delete).not.toHaveBeenCalled(); }); it('gallery provider-photos: batch (with passphrase), single 400/403, success', async () => { const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 }); const batch = ctl(svc({ addProviderPhotoToGallery } as Partial)); expect( await batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' }), ).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 }); expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image'); expect(await thrownAsync(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' }, }); expect( await thrownAsync(() => ctl( svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial), ).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }), ), ).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } }); expect( await ctl( svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial), ).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }), ).toEqual({ id: 3 }); }); it('DELETE gallery photo: 404, then removes the object when present', async () => { expect( await thrownAsync(() => ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial)).deleteGalleryPhoto( user, '7', ), ), ).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } }); // no file_path → nothing to delete, returns void expect( await ctl( svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial), ).deleteGalleryPhoto(user, '7'), ).toBeUndefined(); await ctl( svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }), } as Partial), ).deleteGalleryPhoto(user, '8'); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'g.jpg'); expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/g.jpg')); (storageStub.delete as ReturnType).mockRejectedValueOnce(new Error('ENOENT')); expect( await ctl( svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }), } as Partial), ).deleteGalleryPhoto(user, '9'), ).toBeUndefined(); }); it('DELETE gallery video also reclaims its uploaded poster (spec fix #2 extension)', async () => { (storageStub.delete as ReturnType).mockClear(); await ctl( svc({ deleteGalleryPhoto: vi .fn() .mockReturnValue({ id: 10, file_path: 'journey/clip.mp4', thumbnail_path: 'journey/poster.jpg' }), } as Partial), ).deleteGalleryPhoto(user, '10'); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'clip.mp4'); expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/clip.mp4')); expect(storageStub.delete).toHaveBeenCalledWith('journey', 'poster.jpg'); }); it('PATCH /:id returns the updated journey on success', async () => { expect( await ctl(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial)).update( user, '9', { title: 'x' }, ), ).toEqual({ id: 9 }); }); it('cover upload: 400 without file, 404 when the journey is gone, else commits + returns the journey', async () => { expect(await thrownAsync(() => ctl(svc()).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' }, }); expect( await thrownAsync(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial)).cover(user, '9', { filename: 'c.jpg', } as Express.Multer.File), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); const updateJourney = vi.fn().mockReturnValue({ id: 9, cover_image: 'journey/c.jpg' }); expect( await ctl(svc({ updateJourney } as Partial)).cover(user, '9', { filename: 'c.jpg', } as Express.Multer.File), ).toEqual({ id: 9, cover_image: 'journey/c.jpg' }); expect(storageStub.put).toHaveBeenCalledWith('journey', 'c.jpg', { tmpPath: undefined }); expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' }); }); it('DELETE /:id and trips/contributors success paths', async () => { expect( await ctl(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial)).remove(user, '9'), ).toEqual({ success: true }); expect( await ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial)).removeTrip( user, '9', '2', ), ).toEqual({ success: true }); expect( await ctl( svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial), ).updateContributor(user, '9', '2', { role: 'editor' }), ).toEqual({ success: true }); expect( await ctl(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial)).removeContributor( user, '9', '2', ), ).toEqual({ success: true }); }); it('addContributor defaults the role to viewer when omitted', async () => { const addContributor = vi.fn().mockReturnValue(true); await ctl(svc({ addContributor } as Partial)).addContributor(user, '9', { user_id: 2 }); expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer'); }); it('createEntry returns the entry when the journey exists', async () => { expect( await ctl(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).createEntry( user, '9', { entry_date: '2026-01-01' }, ), ).toEqual({ id: 4 }); }); it('reorderEntries succeeds for a numeric array', async () => { expect( await ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial)).reorderEntries( user, '9', { orderedIds: [3, 1, 2] }, ), ).toEqual({ success: true }); }); it('reorderEntryPhotos forwards the order and answers 404 when refused (#824)', async () => { const reorderEntryPhotos = vi.fn().mockReturnValue(true); expect( await ctl(svc({ reorderEntryPhotos } as Partial)).reorderEntryPhotos( user, '12', { orderedIds: [3, 1] }, 'sock', ), ).toEqual({ success: true }); expect(reorderEntryPhotos).toHaveBeenCalledWith(12, user.id, [3, 1], 'sock'); expect( await thrownAsync(() => ctl(svc({ reorderEntryPhotos: vi.fn().mockReturnValue(false) } as Partial)).reorderEntryPhotos( user, '12', { orderedIds: [3] }, ), ), ).toEqual({ status: 404, body: { error: 'Entry not found' } }); }); it('restoreSuggestions answers with the count, and refuses a viewer the same way its siblings do', async () => { const restore = vi.fn().mockReturnValue({ restored: 3 }); expect( await ctl(svc({ restoreDismissedSuggestions: restore } as Partial)).restoreSuggestions(user, '9'), ).toEqual({ restored: 3 }); expect(restore).toHaveBeenCalledWith(9, 1); expect( await thrownAsync(() => ctl( svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial), ).restoreSuggestions(user, '9'), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('preferences returns the result on success', async () => { expect( await ctl( svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial), ).preferences(user, '9', { theme: 'dark' }), ).toEqual({ ok: true }); }); it('deleteShareLink returns success when removed', async () => { expect( await ctl( svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial), ).deleteShareLink(user, '9'), ).toEqual({ success: true }); }); /* * The Studio book (#1973). Its access shape is the same as everything around * it — the service answers null for a journey the caller cannot see, and that * is a 404 rather than a 403, so the route cannot be used to find out which * journeys exist. */ it('book: 404 vs a journey with no book yet, and the save status codes', async () => { // A journey with no book yet is not an error: Studio opens on it, lays a // book out and saves. 404 here would make "no book" and "no journey" // indistinguishable to the client. expect( await ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).getBook( user, '9', ), ).toEqual({ book: null }); expect( await thrownAsync(() => ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).getBook( user, '9', ), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); expect( await ctl(svc(), { getBook: vi.fn().mockReturnValue({ id: 3, version: 4 }), canOpen: vi.fn() }).getBook( user, '9', ), ).toEqual({ book: { id: 3, version: 4 } }); // A refused save says which refusal it was. Out of reach entirely is still // 404; a viewer who can open the journey but may not write it gets 403, so // the editor can say why instead of claiming the journey vanished. expect( await thrownAsync(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).saveBook( user, '9', { document: {} } as never, ), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); expect( await thrownAsync(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).saveBook( user, '9', { document: {} } as never, ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); // The record comes back bare, not in an envelope — the contract in // shared/src/book/book-store.schema.ts is the record itself. const broadcastSaved = vi.fn(); const saveBook = vi.fn().mockReturnValue({ record: { id: 3, version: 5 } }); expect( await ctl(svc(), { saveBook, broadcastSaved }).saveBook( user, '9', { title: 'T', document: { v: 1 }, baseVersion: 4 } as never, 'sock-7', ), ).toEqual({ id: 3, version: 5 }); expect(saveBook).toHaveBeenCalledWith(9, 1, { title: 'T', document: { v: 1 }, baseVersion: 4 }); // Forwarded so the client that just saved does not process its own change. expect(broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7'); // A missing title is '' rather than undefined: the column is NOT NULL and // an untitled book is an ordinary thing to have. const untitled = vi.fn().mockReturnValue({ record: { id: 3, version: 1 } }); await ctl(svc(), { saveBook: untitled, broadcastSaved: vi.fn() }).saveBook(user, '9', { document: {} } as never); expect(untitled.mock.calls[0][2].title).toBe(''); }); it('book: a conflict is a 409 that carries the other version', async () => { /* * Two people editing is the normal case, not an exception. A bare 409 would * make showing what the other version is a second round trip, at exactly * the moment someone is worried about losing work. */ const broadcastSaved = vi.fn(); const saveBook = vi.fn().mockReturnValue({ conflict: { id: 3, version: 6, title: 'Theirs' } }); expect( await thrownAsync(() => ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never), ), ).toEqual({ status: 409, body: { error: 'Book was changed by someone else', current: { id: 3, version: 6, title: 'Theirs' } }, }); // Nothing was written, so nobody is told anything moved. expect(broadcastSaved).not.toHaveBeenCalled(); }); it('book: delete answers 204, 403 for a viewer, or 404 out of reach', async () => { expect(await ctl(svc(), { deleteBook: vi.fn().mockReturnValue(true) }).deleteBook(user, '9')).toBeUndefined(); // False means there was nothing to delete, which is still a 204 — deleting // a book that is already gone is not a failure. expect(await ctl(svc(), { deleteBook: vi.fn().mockReturnValue(false) }).deleteBook(user, '9')).toBeUndefined(); expect( await thrownAsync(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).deleteBook( user, '9', ), ), ).toEqual({ status: 404, body: { error: 'Journey not found' } }); expect( await thrownAsync(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).deleteBook( user, '9', ), ), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); }); describe('JourneyPublicController', () => { it('is addon-gated like the authenticated surface', () => { // Turning the Journey addon off is meant to take the feature away; without // this, already-published journeys stayed publicly readable. expect(Reflect.getMetadata(REQUIRE_ADDON, JourneyPublicController)).toEqual({ addonId: ADDON_IDS.JOURNEY, label: 'Journey', }); expect((Reflect.getMetadata('__guards__', JourneyPublicController) ?? [])[0]).toBe(AddonGuard); }); it('GET /:token 404 / json', async () => { expect( await thrownAsync(() => new JourneyPublicController( svc({ getPublicJourney: vi.fn().mockReturnValue(null) } as Partial), storageStub, ).get('tok'), ), ).toEqual({ status: 404, body: { error: 'Not found' } }); expect( await new JourneyPublicController( svc({ getPublicJourney: vi.fn().mockReturnValue({ id: 1 }) } as Partial), storageStub, ).get('tok'), ).toEqual({ id: 1 }); }); it('photo proxy 404 on invalid token, else streams', async () => { expect( await thrownAsync(() => new JourneyPublicController( svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue(null) } as Partial), storageStub, ).photo('tok', '7', 'thumbnail', {} as Response), ), ).toEqual({ status: 404, body: { error: 'Not found' } }); const streamPhoto = vi.fn().mockResolvedValue(undefined); const s = svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue({ ownerId: 2 }), streamPhoto, } as Partial); await new JourneyPublicController(s, storageStub).photo('tok', '7', 'original', {} as Response); expect(streamPhoto).toHaveBeenCalledWith({}, 2, 7, 'original'); }); it('legacy photo proxy: 404 invalid token, immich path streams', async () => { expect( await thrownAsync(() => new JourneyPublicController( svc({ validateShareTokenForAsset: vi.fn().mockReturnValue(null) } as Partial), storageStub, ).legacyPhoto('tok', 'immich', 'a1', 'thumbnail', {} as Response), ), ).toEqual({ status: 404, body: { error: 'Not found' } }); // One call for every provider now, with the ids in a ref instead of in a // per-provider argument order. const streamProviderAsset = vi.fn().mockResolvedValue(undefined); const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }), streamProviderAsset, } as Partial); await new JourneyPublicController(s, storageStub).legacyPhoto('tok', 'immich', 'a1', 'original', {} as Response); expect(streamProviderAsset).toHaveBeenCalledWith( {}, 'immich', { userId: 5, ownerId: 5, assetId: 'a1' }, 'original', ); }); it('photo proxy streams thumbnails too', async () => { const streamPhoto = vi.fn().mockResolvedValue(undefined); const s = svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue({ ownerId: 3 }), streamPhoto, } as Partial); await new JourneyPublicController(s, storageStub).photo('tok', '7', 'thumbnail', {} as Response); expect(streamPhoto).toHaveBeenCalledWith({}, 3, 7, 'thumbnail'); }); it('legacy photo proxy: synology streams, and a failure becomes a 404 json', async () => { const streamProviderAsset = vi.fn().mockResolvedValue(undefined); const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }), streamProviderAsset, } as Partial); await new JourneyPublicController(s, storageStub).legacyPhoto( 'tok', 'synologyphotos', 'a1', 'thumbnail', {} as Response, ); expect(streamProviderAsset).toHaveBeenCalledWith( {}, 'synologyphotos', { userId: 5, ownerId: 5, assetId: 'a1' }, 'thumbnail', ); const status = vi.fn().mockReturnThis(); const json = vi.fn(); const res = { status, json } as unknown as Response; const failing = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 0 }), streamProviderAsset: vi.fn().mockRejectedValue(new Error('no synology')), } as Partial); await new JourneyPublicController(failing, storageStub).legacyPhoto('tok', 'synologyphotos', 'a1', 'original', res); expect(status).toHaveBeenCalledWith(404); expect(json).toHaveBeenCalledWith({ error: 'Provider not supported' }); }); it('legacy photo proxy: an unregistered provider 404s instead of being handed to synology', async () => { // The old if/else sent everything that was not immich to synology, which // 404'd from inside its own id parser. The registry can tell "no such // backend" apart from "that backend failed". const streamProviderAsset = vi.fn().mockReturnValue(null); const status = vi.fn().mockReturnThis(); const json = vi.fn(); const res = { status, json } as unknown as Response; const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }), streamProviderAsset, } as Partial); await new JourneyPublicController(s, storageStub).legacyPhoto('tok', 'photoprism', 'a1', 'original', res); expect(status).toHaveBeenCalledWith(404); expect(json).toHaveBeenCalledWith({ error: 'Provider not supported' }); }); it('legacy photo proxy: ignores the path ownerId, taking the owner off the token', async () => { // Whose provider credentials get tried is not something an anonymous caller // may choose from the URL. The share service resolves it, falling back to // the journey owner when the photo row carries no owner_id. const streamProviderAsset = vi.fn().mockResolvedValue(undefined); const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 3 }), streamProviderAsset, } as Partial); await new JourneyPublicController(s, storageStub).legacyPhoto('tok', 'immich', 'a1', 'original', {} as Response); expect(streamProviderAsset).toHaveBeenCalledWith( {}, 'immich', { userId: 3, ownerId: 3, assetId: 'a1' }, 'original', ); }); it('legacy photo proxy: local provider 404s when the object does not exist', async () => { storageExists.mockResolvedValue(false); const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial); expect( await thrownAsync(() => new JourneyPublicController(s, storageStub).legacyPhoto( 'tok', 'local', 'gone.jpg', 'thumbnail', {} as Response, ), ), ).toEqual({ status: 404, body: { error: 'Not found' } }); expect(storageSendToResponse).not.toHaveBeenCalled(); }); it('legacy photo proxy: local provider serves through storage with the day cache header', async () => { storageExists.mockResolvedValue(true); storageSendToResponse.mockResolvedValue(undefined); const set = vi.fn(); const res = { set } as unknown as Response; const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial); await new JourneyPublicController(s, storageStub).legacyPhoto('tok', 'local', 'photo.jpg', 'original', res); expect(storageExists).toHaveBeenCalledWith('journey', 'photo.jpg'); expect(set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=86400'); expect(storageSendToResponse).toHaveBeenCalledWith('journey', 'photo.jpg', res); }); it('legacy photo proxy: local provider cannot escape uploads/journey via a traversal asset id', async () => { storageExists.mockResolvedValue(true); storageSendToResponse.mockResolvedValue(undefined); const res = { set: vi.fn() } as unknown as Response; const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial); // Express decodes %2F in a single path param to '/', so the handler sees this. await new JourneyPublicController(s, storageStub).legacyPhoto( 'tok', 'local', '../../files/secret.pdf', 'original', res, ); // basename() collapses the traversal before the storage lookup, and central // key validation (storage-keys.ts) rejects anything that still carries a // path — the asset can never address the sibling files/ category. expect(storageExists).toHaveBeenCalledWith('journey', 'secret.pdf'); expect(storageSendToResponse).toHaveBeenCalledWith('journey', 'secret.pdf', res); }); it('legacy photo proxy: a bare traversal asset id reads as a miss, not a crash', async () => { // '..' survives basename(); storage.exists rejects it as an invalid key, // which the handler reads as a miss. storageExists.mockRejectedValue(new Error('invalid storage key: journey/..')); const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial); expect( await thrownAsync(() => new JourneyPublicController(s, storageStub).legacyPhoto('tok', 'local', '..', 'original', {} as Response), ), ).toEqual({ status: 404, body: { error: 'Not found' } }); expect(storageSendToResponse).not.toHaveBeenCalled(); }); });