Files
TREK/server/tests/unit/nest/journey.controller.test.ts
T
Maurice d17457de56 fix(storage): delete what a delete or a replace left unnamed right away again
Leaving every replaced cover, place image and deleted journey photo to the
nightly pass kept them publicly fetchable by URL for a week at least, and
for good while deletion was off. Owners now hand the values a delete or a
replace left behind to StorageReleaseService once their write committed.
It asks every registered reference source, with the same parser as the
nightly pass, and deletes at once what no row names any more: an object a
trip copy, a journey or a saved place still shows stays, which the old
inline deletes got wrong.

The release holds the pass's guards (reclaimable categories at their root
only, never an owner marker, never while a restore runs, never with a
required source missing or an overlapping location), never fails the
request and logs at debug. It runs for trip covers (update, upload, trip
delete), collection covers and saved-place images, place images (update,
bulk update, delete), journey covers and journey or gallery photos
(their thumbnail goes with a file that went), avatars (change, delete,
account deletion), trip files (permanent delete, empty trash) and collab
note and chat attachments. A trip file row now goes before its bytes, so a
failing backend no longer fails the delete. The objects of a deleted trip's
places and files are left to the nightly pass: their rows are other
domains'.

PlacesService drops the collection places repository nothing read any
more. Size, warning and boundary baselines follow the shrunk files.
2026-11-01 16:18:21 +01:00

1354 lines
56 KiB
TypeScript

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 { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import type { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service';
import type { JourneyPhotoLinksService } from '../../../src/nest/journey/journey-photo-links.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 { StorageKeyRef } from '../../../src/nest/storage/storage-reclaim.types';
import type { StorageReleaseService } from '../../../src/nest/storage/storage-release.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 fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
function svc(o: Partial<JourneyService> = {}): 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 provider-photo adds, their event and their detached capture refresh are
// JourneyPhotoCaptureService's (#1587), so the routes only shape the batch and
// the answer; the spies pin that shape. Uploads get the backfill without the
// refresh.
const addProviderPhotos = vi.fn();
const scheduleUpload = vi.fn();
const captureBackfillStub = {
addProviderPhotos,
scheduleUpload,
} as unknown as JourneyPhotoCaptureService;
/**
* What a deleted photo hands on. By default every `journey/<name>` it names
* goes, as the real release answers when no other row names them; a case
* about a shared object answers what it keeps.
*/
const releaseValues = vi.fn(async (values: ReadonlyArray<string | null | undefined>): Promise<StorageKeyRef[]> =>
values
.filter((value): value is string => typeof value === 'string' && value.startsWith('journey/'))
.map((value) => ({ category: 'journey', key: value.slice('journey/'.length) })),
);
const releaseStub = { releaseValues } as unknown as StorageReleaseService;
/**
* 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<JourneyBookService> = {},
photoLinks: Partial<JourneyPhotoLinksService> = {},
): JourneyController {
return new JourneyController(
service,
storageStub,
books as JourneyBookService,
captureBackfillStub,
photoLinks as JourneyPhotoLinksService,
// The live env: a case about demo mode stubs DEMO_MODE, every other runs outside it.
new RuntimeEnvService(),
releaseStub,
);
}
async function thrownAsync(fn: () => Promise<unknown>): 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();
// A case's own answers for the provider-photo use case never carry into the next.
addProviderPhotos.mockReset();
});
describe('JourneyController', () => {
it('GET / lists; POST / 400 without title, else creates', async () => {
expect(
await ctl(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).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<JourneyService>)).create(
user,
{ title: ' Trip ', trip_ids: [1, '2'] },
'sock-1',
),
).toEqual({ id: 9 });
// The socket id goes on, so the creating tab is left out of journey:updated.
expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] }, 'sock-1');
});
it('GET /suggestions + /available-trips', async () => {
expect(
await ctl(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).suggestions(
user,
),
).toEqual({ trips: [{ id: 1 }] });
expect(
await ctl(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>)).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<JourneyService>)).updateEntry(user, '3', {}),
),
).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(
await ctl(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).updateEntry(
user,
'3',
{ title: 'x' },
),
).toEqual({ id: 3 });
expect(
await thrownAsync(() =>
ctl(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteEntry(user, '3'),
),
).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(
await ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3'),
).toEqual({ success: true });
});
it('provider-photos: batch, single 400/403, success', async () => {
addProviderPhotos.mockResolvedValueOnce([{ id: 1 }, { id: 1 }]);
expect(await ctl(svc()).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(addProviderPhotos).toHaveBeenCalledTimes(1);
addProviderPhotos.mockResolvedValueOnce([]);
expect(
await thrownAsync(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })),
).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
});
it('provider-photos go through the one use case, by entry or by journey, with the socket id; uploads only backfill (#1587)', async () => {
const entryPhoto = { id: 1, photo_id: 11 };
const galleryPhoto = { id: 2, photo_id: 12 };
addProviderPhotos.mockImplementation(async (target: { entryId?: number }) =>
target.entryId === undefined ? [galleryPhoto] : [entryPhoto],
);
const c = ctl(
svc({
addPhoto: vi.fn().mockReturnValue(entryPhoto),
uploadGalleryPhotos: vi.fn().mockReturnValue([galleryPhoto]),
immichAutoUploadEnabled: vi.fn().mockReturnValue(false),
} as Partial<JourneyService>),
);
await c.providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'] }, 'sock-1');
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], {});
await c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a'] }, 'sock-1');
await c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'b' });
await c.uploadGalleryPhotos(user, '9', [{ filename: 'g.jpg' } as Express.Multer.File]);
expect(addProviderPhotos.mock.calls.map(([target, userId, , sid]) => [target, userId, sid])).toEqual([
[{ entryId: 3 }, 1, 'sock-1'],
[{ entryId: 3 }, 1, undefined],
[{ journeyId: 9 }, 1, 'sock-1'],
[{ journeyId: 9 }, 1, undefined],
]);
// 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, and the asking tab is skipped', async () => {
expect(await thrownAsync(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({
status: 400,
body: { error: 'journey_photo_id required' },
});
const link = vi.fn().mockResolvedValue({ id: 5 });
const c = ctl(svc(), {}, { link });
expect(await c.linkPhoto(user, '3', { photo_id: 5 }, 'sock-1')).toEqual({ id: 5 });
expect(link).toHaveBeenCalledWith(3, 5, 1, 'sock-1');
// accepts the canonical journey_photo_id, 403 when the service refuses
expect(
await thrownAsync(() =>
ctl(svc(), {}, { link: vi.fn().mockResolvedValue(null) }).linkPhoto(user, '3', { journey_photo_id: 9 }),
),
).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('move photos: hands the source entry, the body and the socket to the service, and answers what moved', async () => {
const moved = { moved: [5], fromEntryId: 3, toEntryId: 4, kept: false };
const move = vi.fn().mockResolvedValue(moved);
const c = ctl(svc(), {}, { move });
expect(await c.movePhotos(user, '3', { toEntryId: 4, photoIds: [5] }, 'sock-1')).toEqual(moved);
expect(move).toHaveBeenCalledWith(3, { toEntryId: 4, photoIds: [5] }, 1, 'sock-1');
});
it('unlink photo (204) maps 404; delete photo 404 then removes the object', async () => {
expect(
await thrownAsync(() => ctl(svc(), {}, { unlink: vi.fn().mockResolvedValue(false) }).unlinkPhoto(user, '3', '7')),
).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
const unlink = vi.fn().mockResolvedValue(true);
expect(await ctl(svc(), {}, { unlink }).unlinkPhoto(user, '3', '7', 'sock-1')).toBeUndefined();
expect(unlink).toHaveBeenCalledWith(3, 7, 1, 'sock-1');
expect(
await thrownAsync(() =>
ctl(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).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<JourneyService>),
).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<JourneyService>)).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<JourneyService>),
).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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 () => {
addProviderPhotos.mockResolvedValue([{ id: 1 }]);
await ctl(svc()).galleryProviderPhotos(user, '9', {
provider: 'immich',
asset_ids: ['a', 'b'],
media_types: ['video', 'image'],
});
await ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
// Entry path mirrors the gallery path, and only an entry keeps the caption.
await ctl(svc()).providerPhotos(user, '4', {
provider: 'immich',
asset_ids: ['x'],
media_types: ['video'],
caption: 'c',
});
await ctl(svc()).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
await ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['z'], caption: 'dropped' });
expect(addProviderPhotos.mock.calls.map(([target, , batch]) => [target, batch])).toEqual([
[{ journeyId: 9 }, { provider: 'immich', assetIds: ['a', 'b'], mediaTypes: ['video', 'image'] }],
[{ journeyId: 9 }, { provider: 'immich', assetIds: ['c'], mediaTypes: ['video'] }],
[{ entryId: 4 }, { provider: 'immich', assetIds: ['x'], mediaTypes: ['video'], caption: 'c' }],
[{ entryId: 4 }, { provider: 'immich', assetIds: ['y'], mediaTypes: ['video'] }],
[{ journeyId: 9 }, { provider: 'immich', assetIds: ['z'] }],
]);
});
it('GET/PATCH/DELETE /:id map 404', async () => {
expect(
await thrownAsync(() =>
ctl(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get(user, '9'),
),
).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(
await ctl(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).get(user, '9'),
).toEqual({ id: 9 });
expect(
await thrownAsync(() =>
ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).update(user, '9', {}),
),
).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(
await thrownAsync(() =>
ctl(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).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<JourneyService>)).addTrip(user, '9', {
trip_id: 2,
}),
),
).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(
await ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addTrip(
user,
'9',
{ trip_id: 2 },
),
).toEqual({ success: true });
expect(
await thrownAsync(() =>
ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).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<JourneyService>)).listEntries(user, '9'),
),
).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(
await ctl(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).addContributor(
user,
'9',
{ user_id: 2 },
),
),
).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(
await ctl(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addContributor(
user,
'9',
{ user_id: 2 },
),
).toEqual({ success: true });
expect(
await thrownAsync(() =>
ctl(
svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>),
).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<JourneyService>)).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<JourneyService>)).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<JourneyService>),
).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<JourneyService>),
).getShareLink(user, '9'),
).toEqual({ link: null });
expect(
await thrownAsync(() =>
ctl(
svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: false }) } as Partial<JourneyService>),
).getShareLink(user, '9'),
),
).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(
await thrownAsync(() =>
ctl(
svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>),
).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).uploadEntryPhotos(
user,
'3',
[file],
{},
),
);
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'a.jpg');
vi.clearAllMocks();
await thrownAsync(() =>
ctl(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>);
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<JourneyService>);
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<JourneyService>)).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<JourneyService>);
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<JourneyService>);
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 () => {
addProviderPhotos.mockResolvedValueOnce([{ id: 1 }]);
await ctl(svc()).providerPhotos(user, '3', {
provider: 'immich',
asset_ids: ['a'],
caption: 'cap',
passphrase: 'secret',
});
expect(addProviderPhotos).toHaveBeenCalledWith(
{ entryId: 3 },
1,
{ provider: 'immich', assetIds: ['a'], mediaTypes: undefined, caption: 'cap', passphrase: 'secret' },
undefined,
);
// single-photo success path
addProviderPhotos.mockResolvedValueOnce([{ id: 2 }]);
expect(await ctl(svc()).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().mockResolvedValue(null) }).updatePhoto(user, '7', { caption: 'x' }),
),
).toEqual({ status: 404, body: { error: 'Photo not found' } });
const updatePhoto = vi.fn().mockResolvedValue({ id: 7 });
expect(await ctl(svc(), {}, { updatePhoto }).updatePhoto(user, '7', { caption: 'x' }, 'sock-1')).toEqual({ id: 7 });
expect(updatePhoto).toHaveBeenCalledWith(7, 1, { caption: 'x' }, 'sock-1');
});
it('DELETE photo releases its file and poster once the row is gone, and the thumb of a file that went', async () => {
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
releaseValues.mockClear();
const deletePhoto = vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg', thumbnail_path: 'journey/p.jpg' });
expect(await ctl(svc({ deletePhoto } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({
success: true,
});
expect(deletePhoto).toHaveBeenCalledWith(7, 1);
expect(releaseValues).toHaveBeenCalledWith(['journey/a.jpg', 'journey/p.jpg'], { bare: 'journey' });
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/a.jpg'));
});
it('DELETE photo keeps the thumb of a file another row still names, and of a path outside journey/', async () => {
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
releaseValues.mockResolvedValueOnce([]);
const shared = vi.fn().mockReturnValue({ id: 8, file_path: 'journey/shared.jpg', thumbnail_path: null });
await ctl(svc({ deletePhoto: shared } as Partial<JourneyService>)).deletePhoto(user, '8');
const outside = vi.fn().mockReturnValue({ id: 9, file_path: 'elsewhere/x.jpg' });
await ctl(svc({ deletePhoto: outside } as Partial<JourneyService>)).deletePhoto(user, '9');
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('gallery provider-photos: batch (with passphrase), single 400/403, success', async () => {
addProviderPhotos.mockResolvedValueOnce([{ id: 1 }, { id: 1 }]);
expect(
await ctl(svc()).galleryProviderPhotos(user, '9', {
provider: 'immich',
asset_ids: ['a', 'b'],
passphrase: 'pw',
}),
).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(addProviderPhotos).toHaveBeenCalledWith(
{ journeyId: 9 },
1,
{ provider: 'immich', assetIds: ['a', 'b'], mediaTypes: undefined, caption: undefined, passphrase: 'pw' },
undefined,
);
expect(await thrownAsync(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({
status: 400,
body: { error: 'provider and asset_id required' },
});
addProviderPhotos.mockResolvedValueOnce([]);
expect(
await thrownAsync(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })),
).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
addProviderPhotos.mockResolvedValueOnce([{ id: 3 }]);
expect(await ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
});
it('DELETE gallery photo: 404, then releases the video, its poster and its thumb', async () => {
expect(
await thrownAsync(() =>
ctl(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deleteGalleryPhoto(
user,
'7',
),
),
).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
(storageStub.delete as ReturnType<typeof vi.fn>).mockClear();
releaseValues.mockClear();
const deleteGalleryPhoto = vi
.fn()
.mockReturnValue({ id: 10, file_path: 'journey/clip.mp4', thumbnail_path: 'journey/poster.jpg' });
expect(
await ctl(svc({ deleteGalleryPhoto } as Partial<JourneyService>)).deleteGalleryPhoto(user, '10'),
).toBeUndefined();
expect(deleteGalleryPhoto).toHaveBeenCalledWith(10, 1);
expect(releaseValues).toHaveBeenCalledWith(['journey/clip.mp4', 'journey/poster.jpg'], { bare: 'journey' });
expect(storageStub.delete).toHaveBeenCalledWith('journey', journeyThumbName('journey/clip.mp4'));
// A thumb delete that finds nothing is swallowed.
(storageStub.delete as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ENOENT'));
await expect(
ctl(svc({ deleteGalleryPhoto } as Partial<JourneyService>)).deleteGalleryPhoto(user, '10'),
).resolves.toBeUndefined();
});
it('PATCH /:id returns the updated journey on success', async () => {
expect(
await ctl(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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' }, undefined);
});
it('DELETE /:id and trips/contributors success paths', async () => {
expect(
await ctl(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).remove(user, '9'),
).toEqual({ success: true });
expect(
await ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeTrip(
user,
'9',
'2',
),
).toEqual({ success: true });
expect(
await ctl(
svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>),
).updateContributor(user, '9', '2', { role: 'editor' }),
).toEqual({ success: true });
expect(
await ctl(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).restoreSuggestions(user, '9'),
).toEqual({ restored: 3 });
expect(restore).toHaveBeenCalledWith(9, 1, undefined);
expect(
await thrownAsync(() =>
ctl(
svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial<JourneyService>),
).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<JourneyService>),
).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<JourneyService>),
).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 saveBook = vi.fn().mockReturnValue({ record: { id: 3, version: 5 } });
expect(
await ctl(svc(), { saveBook }).saveBook(
user,
'9',
{ title: 'T', document: { v: 1 }, baseVersion: 4 } as never,
'sock-7',
),
).toEqual({ id: 3, version: 5 });
// The socket goes to the service, which announces the save after its
// commit and leaves out the tab that saved, so it does not echo its change.
expect(saveBook).toHaveBeenCalledWith(
9,
1,
{ title: 'T', document: { v: 1 }, baseVersion: 4 },
{ socketId: '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 }).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 saveBook = vi.fn().mockReturnValue({ conflict: { id: 3, version: 6, title: 'Theirs' } });
expect(
await thrownAsync(() => ctl(svc(), { saveBook }).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' } },
});
});
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('JourneyController in demo mode', () => {
// The shared demo account is refused every journey upload with the 403 the
// other upload routes answer it (common/demo-write.ts), and what multer
// spooled is removed before anything is committed: a committed object would
// stay, since a demo instance keeps what is deleted (StorageService.delete).
const demo = { ...user, email: 'demo@trek.app' } as User;
let spoolDir: string;
const spooled = (name: string): Express.Multer.File => {
const file = path.join(spoolDir, name);
fs.writeFileSync(file, 'x');
return { filename: name, originalname: name, path: file } as Express.Multer.File;
};
/** A service that takes every upload, so a case shows what the demo check alone decides. */
const takesAll = () =>
svc({
addPhoto: vi.fn().mockReturnValue({ id: 5 }),
immichAutoUploadEnabled: vi.fn().mockReturnValue(false),
uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 7 }]),
updateJourney: vi.fn().mockReturnValue({ id: 9 }),
} as Partial<JourneyService>);
const routes: Array<[string, (c: JourneyController, u: User) => Promise<unknown>]> = [
['POST /entries/:entryId/photos', (c, u) => c.uploadEntryPhotos(u, '3', [spooled('a.jpg'), spooled('b.jpg')], {})],
[
'POST /entries/:entryId/video',
(c, u) => c.uploadEntryVideo(u, '3', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}),
],
['POST /:id/gallery/photos', (c, u) => c.uploadGalleryPhotos(u, '9', [spooled('g.jpg')])],
[
'POST /:id/gallery/video',
(c, u) => c.uploadGalleryVideo(u, '9', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}),
],
['POST /:id/cover', (c, u) => c.cover(u, '9', spooled('c.jpg'))],
];
beforeEach(() => {
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-journey-demo-'));
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(spoolDir, { recursive: true, force: true });
});
it.each(routes)('%s: 403 for the demo account, its spooled parts removed and nothing stored', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'true');
const service = takesAll();
expect(await thrownAsync(() => call(ctl(service), demo))).toEqual({
status: 403,
body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
});
expect(fs.readdirSync(spoolDir)).toEqual([]);
expect(storageStub.put).not.toHaveBeenCalled();
expect(service.addPhoto).not.toHaveBeenCalled();
expect(service.uploadGalleryPhotos).not.toHaveBeenCalled();
expect(service.updateJourney).not.toHaveBeenCalled();
});
it.each(routes)('%s: still taken from any other account in demo mode', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'true');
await call(ctl(takesAll()), user);
expect(storageStub.put).toHaveBeenCalled();
});
it.each(routes)('%s: still taken from the demo account outside demo mode', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'false');
await call(ctl(takesAll()), demo);
expect(storageStub.put).toHaveBeenCalled();
});
});
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<JourneyService>),
storageStub,
).get('tok'),
),
).toEqual({ status: 404, body: { error: 'Not found' } });
expect(
await new JourneyPublicController(
svc({ getPublicJourney: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>),
storageStub,
).get('tok'),
).toEqual({ id: 1 });
});
it('GET /:token/map-layers 404 on an unknown link, else the layers as the service cut them (JRN-04)', async () => {
expect(
await thrownAsync(() =>
new JourneyPublicController(
svc({ getPublicMapLayers: vi.fn().mockResolvedValue(null) } as Partial<JourneyService>),
storageStub,
).mapLayers('tok'),
),
).toEqual({ status: 404, body: { error: 'Not found' } });
const layers = { places: { status: 'withheld' } };
const getPublicMapLayers = vi.fn().mockResolvedValue(layers);
expect(
await new JourneyPublicController(svc({ getPublicMapLayers } as Partial<JourneyService>), storageStub).mapLayers(
'tok',
),
).toBe(layers);
expect(getPublicMapLayers).toHaveBeenCalledWith('tok');
});
it('photo proxy 404 on invalid token, else streams', async () => {
expect(
await thrownAsync(() =>
new JourneyPublicController(
svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>),
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<JourneyService>);
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<JourneyService>),
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
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<JourneyService>);
// 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<JourneyService>);
expect(
await thrownAsync(() =>
new JourneyPublicController(s, storageStub).legacyPhoto('tok', 'local', '..', 'original', {} as Response),
),
).toEqual({ status: 404, body: { error: 'Not found' } });
expect(storageSendToResponse).not.toHaveBeenCalled();
});
});