Files
immich-folio/lib/__tests__/immich.test.ts
2026-10-02 18:14:23 +02:00

1197 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { immich, ImmichUnavailableError } from '../immich';
import * as config from '../config';
import { cache } from '../cache';
import type { AlbumSortMode } from '../albumSort';
// Per-album sort is read on every getAlbum(), so the tests below need to be
// able to change it between calls without rebuilding the whole mock.
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
// Same for the subpages: the route tests below add one and take it away again.
const subpages: { slug: string; albumIds: string[]; enabled?: boolean }[] = [];
// Mock the config by wrapping it in a factory
vi.mock('../config', async () => {
const actual = await vi.importActual<typeof config>('../config');
return {
...actual,
// A spy, so the tests below can count how often the client reads it.
getConfig: vi.fn(() => ({
immich: { apiUrl: 'http://immich.test/api', apiKey: 'test-key' },
authSecret: 'test-auth-secret-32-chars-long-min',
albums: ['album-1', 'album-2'],
standaloneAlbums: ['album-2', 'album-1'].filter(
(id) => !subpages.some((sp) => sp.albumIds.includes(id)),
),
subpages,
albumOverrides: { 'album-1': 'Override Name' },
albumDescriptions: {},
albumSortModes,
albumManualOrders,
// Non-zero: a 0 TTL expires an entry in the same millisecond it is
// written, which would make any caching assertion flaky. Isolation comes
// from the cache.clear() in beforeEach, not from an unusable TTL.
cacheTtl: 60_000,
staleMaxAge: 86_400_000,
immichTimeoutMs: 15000,
})),
};
});
// Mock fetch globally
global.fetch = vi.fn();
describe('ImmichClient', () => {
const mockFetch = global.fetch as any;
beforeEach(() => {
vi.clearAllMocks();
// getAlbum() memoises into the module-level LRU; without this, later tests
// read a previous test's album and never reach the mocked fetch.
cache.clear();
for (const key of Object.keys(albumSortModes)) delete albumSortModes[key];
for (const key of Object.keys(albumManualOrders)) delete albumManualOrders[key];
});
describe('request()', () => {
it('returns data on successful JSON response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ foo: 'bar' }),
});
// Access private method via any casting for testing
const result = await (immich as any).request('/test');
expect(result).toEqual({ foo: 'bar' });
});
it('returns null on non-ok response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
});
const result = await (immich as any).request('/test');
expect(result).toBeNull();
});
// A null return means "Immich answered: this does not exist" and the page
// turns it into notFound(). Every other failure must be distinguishable,
// or an outage renders a hard 404 for albums that do exist.
describe('distinguishes "gone" from "unavailable"', () => {
it('returns null for 410 Gone', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 410, statusText: 'Gone' });
await expect((immich as any).request('/test')).resolves.toBeNull();
});
it('throws on a 5xx instead of reporting the resource as missing', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
});
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(
ImmichUnavailableError,
);
});
it('throws on a rejected API key rather than 404-ing every album', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' });
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(
ImmichUnavailableError,
);
});
it('throws when the network is unreachable', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(
ImmichUnavailableError,
);
});
it('throws when a gateway returns an HTML error page instead of JSON', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'text/html' },
json: async () => ({}),
});
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(
ImmichUnavailableError,
);
});
it('carries the upstream status for logging', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 502, statusText: 'Bad Gateway' });
await expect((immich as any).request('/test')).rejects.toMatchObject({ status: 502 });
});
});
});
// Without a budget the only ceiling is undici's default, measured in minutes.
// Every page is force-dynamic, so an Immich that accepts connections but never
// answers would hold each visitor's render open for that whole window.
describe('timeouts', () => {
const timeoutError = () => {
const err = new Error('The operation was aborted due to timeout');
err.name = 'TimeoutError';
return err;
};
it('sends an abort signal with every JSON request', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({}),
});
await (immich as any).request('/test');
expect(mockFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
it('reports a timed-out request as unavailable, not as a missing resource', async () => {
mockFetch.mockRejectedValueOnce(timeoutError());
await expect((immich as any).request('/test')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('names the budget in the error so a misconfigured timeout is diagnosable', async () => {
mockFetch.mockRejectedValueOnce(timeoutError());
await expect((immich as any).request('/test')).rejects.toThrow(/15000ms/);
});
// The stream methods deliberately use a headers-only timeout. A whole-request
// timeout would truncate a large original photo or a long video mid-transfer.
it('stops the clock once stream headers arrive, so a slow body is not truncated', async () => {
vi.useFakeTimers();
try {
let captured: AbortSignal | undefined;
mockFetch.mockImplementation(async (_url: string, init: { signal?: AbortSignal }) => {
captured = init.signal;
return {
ok: true,
status: 200,
body: new ReadableStream(),
headers: { get: () => null },
};
});
const result = await immich.streamAsset('asset-1');
expect(result).not.toBeNull();
// Far beyond the 15s budget: a whole-request timeout would abort here
// and kill an in-progress download.
vi.advanceTimersByTime(60 * 60 * 1000);
expect(captured?.aborted).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('still aborts a stream that never returns headers', async () => {
vi.useFakeTimers();
try {
let captured: AbortSignal | undefined;
mockFetch.mockImplementation(
(_url: string, init: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
captured = init.signal;
init.signal?.addEventListener('abort', () => reject(timeoutError()));
}),
);
const pending = immich.streamAsset('asset-1');
// Attach the rejection handler *before* advancing timers: the abort
// fires during advanceTimersByTimeAsync, and a promise that rejects
// with no handler yet attached is reported as an unhandled rejection,
// which fails the whole vitest run.
const rejects = expect(pending).rejects.toBeInstanceOf(ImmichUnavailableError);
await vi.advanceTimersByTimeAsync(15001);
await rejects;
expect(captured?.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
// The image/video routes serve these URLs with `immutable` on success and turn
// a null into a 404. A bare 404 is heuristically cacheable, so an outage that
// looked like "missing" could pin a broken image in the browser cache.
describe('stream failures distinguish gone from unavailable', () => {
const streamRes = (status: number) => ({
ok: status >= 200 && status < 300,
status,
body: null,
headers: { get: () => null },
});
it('streamAsset returns null for a genuinely missing asset', async () => {
mockFetch.mockResolvedValueOnce(streamRes(404));
await expect(immich.streamAsset('asset-1')).resolves.toBeNull();
});
it('streamAsset throws when Immich errors rather than reporting it missing', async () => {
mockFetch.mockResolvedValueOnce(streamRes(500));
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('streamAsset throws when the network is unreachable', async () => {
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('streamAsset throws on a 200 with no body', async () => {
mockFetch.mockResolvedValueOnce(streamRes(200));
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
// #467: Immich answers a missing full-size rendition with a redirect — to
// the preview, or to the original for a web format. Neither may be passed
// off as the rendition, so the redirect is not followed.
it('streamFullsize does not follow Immich’s redirect, and reports no rendition', async () => {
const cancel = vi.fn(async () => {});
mockFetch.mockResolvedValueOnce({
ok: false,
status: 302,
body: { cancel },
headers: { get: () => '/api/assets/asset-1/thumbnail?size=preview' },
});
await expect(immich.streamFullsize('asset-1')).resolves.toBeNull();
const [url, init] = mockFetch.mock.calls.at(-1)!;
expect(url).toMatch(/\/assets\/asset-1\/thumbnail\?size=fullsize$/);
expect(init.redirect).toBe('manual');
expect(cancel).toHaveBeenCalled();
});
it('streamFullsize streams a rendition Immich has', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
body: new ReadableStream(),
headers: { get: (h: string) => (h === 'Content-Type' ? 'image/jpeg' : '42') },
});
const result = await immich.streamFullsize('asset-1');
expect(result?.contentType).toBe('image/jpeg');
});
// #831: Immich applies an edit made in its editor only when asked.
describe('edited photos (#831)', () => {
const ok = () => ({
ok: true,
status: 200,
body: new ReadableStream(),
headers: { get: () => 'image/jpeg' },
});
const lastUrl = () => mockFetch.mock.calls.at(-1)![0] as string;
it.each([
['thumbnail', '/assets/asset-1/thumbnail?size=thumbnail&edited=true'],
['preview', '/assets/asset-1/thumbnail?size=preview&edited=true'],
['original', '/assets/asset-1/original?edited=true'],
] as const)('asks for the edited %s', async (size, path) => {
mockFetch.mockResolvedValueOnce(ok());
await immich.streamAsset('asset-1', size, true);
expect(lastUrl()).toBe(`http://immich.test/api${path}`);
});
it.each([
['thumbnail', '/assets/asset-1/thumbnail?size=thumbnail'],
['preview', '/assets/asset-1/thumbnail?size=preview'],
['original', '/assets/asset-1/original'],
] as const)('requests a %s that is not edited exactly as before', async (size, path) => {
mockFetch.mockResolvedValueOnce(ok());
await immich.streamAsset('asset-1', size);
expect(lastUrl()).toBe(`http://immich.test/api${path}`);
mockFetch.mockResolvedValueOnce(ok());
await immich.streamAsset('asset-1', size, false);
expect(lastUrl()).toBe(`http://immich.test/api${path}`);
});
it('asks for the edited full-size rendition, still without following redirects', async () => {
mockFetch.mockResolvedValueOnce(ok());
await immich.streamFullsize('asset-1', true);
expect(lastUrl()).toBe(
'http://immich.test/api/assets/asset-1/thumbnail?size=fullsize&edited=true',
);
expect(mockFetch.mock.calls.at(-1)![1].redirect).toBe('manual');
});
});
it('streamAsset still follows redirects', async () => {
mockFetch.mockResolvedValueOnce(streamRes(404));
await immich.streamAsset('asset-1', 'original');
expect(mockFetch.mock.calls.at(-1)![1].redirect).toBe('follow');
});
it('streamVideo returns null for a genuinely missing video', async () => {
mockFetch.mockResolvedValueOnce(streamRes(404));
await expect(immich.streamVideo('asset-1')).resolves.toBeNull();
});
it('streamVideo throws when Immich errors', async () => {
mockFetch.mockResolvedValueOnce(streamRes(502));
await expect(immich.streamVideo('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('streamVideo still accepts a 206 partial response for seeking', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 206,
body: new ReadableStream(),
headers: { get: () => null },
});
const result = await immich.streamVideo('asset-1', 'bytes=0-1023');
expect(result?.status).toBe(206);
});
/**
* Neither error branch reads the body. Under undici an unconsumed one
* keeps its socket out of the pool until GC finalises it, so a proxy
* answering every request with an error accumulates orphaned sockets for
* as long as the fault lasts (#635).
*/
const streamResWithBody = (status: number) => {
const cancel = vi.fn();
return {
res: {
ok: status >= 200 && status < 300,
status,
body: { cancel },
headers: { get: () => null },
},
cancel,
};
};
it('streamAsset cancels the body on the "gone" path', async () => {
const { res, cancel } = streamResWithBody(404);
mockFetch.mockResolvedValueOnce(res);
await expect(immich.streamAsset('asset-1')).resolves.toBeNull();
expect(cancel).toHaveBeenCalledOnce();
});
it('streamAsset cancels the body on the "outage" path', async () => {
const { res, cancel } = streamResWithBody(500);
mockFetch.mockResolvedValueOnce(res);
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
expect(cancel).toHaveBeenCalledOnce();
});
it('streamVideo cancels the body on the "gone" path', async () => {
const { res, cancel } = streamResWithBody(404);
mockFetch.mockResolvedValueOnce(res);
await expect(immich.streamVideo('asset-1')).resolves.toBeNull();
expect(cancel).toHaveBeenCalledOnce();
});
it('streamVideo cancels the body on the "outage" path', async () => {
const { res, cancel } = streamResWithBody(502);
mockFetch.mockResolvedValueOnce(res);
await expect(immich.streamVideo('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
expect(cancel).toHaveBeenCalledOnce();
});
});
// Measured before the fix: 5 sequential lookups of a missing asset produced 5
// upstream fetches. The homepage resolves every gallery.yaml hero ID on each
// render and is force-dynamic, so a hero photo deleted from Immich cost one
// 404 round-trip per page view, indefinitely.
describe('negative caching', () => {
const notFound = { ok: false, status: 404, statusText: 'Not Found' };
it('queries Immich once for a repeatedly-requested missing asset', async () => {
mockFetch.mockResolvedValue(notFound);
for (let i = 0; i < 5; i++) {
await expect(immich.getAssetInfo('missing-asset')).resolves.toBeNull();
}
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('queries Immich once for a repeatedly-requested missing album', async () => {
mockFetch.mockResolvedValue(notFound);
for (let i = 0; i < 3; i++) {
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
}
// One album request + one metadata-search request, from the first call only.
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('does not cache an outage, which would outlive the recovery', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 503, statusText: 'Unavailable' });
await expect(immich.getAssetInfo('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
// Immich recovers: the very next call must reach it, not replay the failure.
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ id: 'asset-1' }),
});
await expect(immich.getAssetInfo('asset-1')).resolves.toMatchObject({ id: 'asset-1' });
});
it('a cached absence is cleared by invalidation, so a fix takes effect at once', async () => {
mockFetch.mockResolvedValue(notFound);
await expect(immich.getAssetInfo('asset-1')).resolves.toBeNull();
immich.invalidateAll();
mockFetch.mockResolvedValue({
ok: true,
headers: { get: () => 'application/json' },
json: async () => ({ id: 'asset-1' }),
});
await expect(immich.getAssetInfo('asset-1')).resolves.toMatchObject({ id: 'asset-1' });
});
});
// The regression this whole error type exists to prevent.
describe('outage handling', () => {
it('getAlbum propagates an outage instead of returning null', async () => {
// Not mockResolvedValueOnce: getAlbum fires the album and metadata-search
// requests concurrently, so both need the failing response.
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' });
await expect(immich.getAlbum('album-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('getAlbums propagates an outage instead of returning an empty list', async () => {
// Returning [] here would render the homepage as "this gallery has no
// albums" while Immich is merely down.
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' });
await expect(immich.getAlbums()).rejects.toBeInstanceOf(ImmichUnavailableError);
});
it('a failed album does not leave a stale entry in the in-flight map', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' });
await expect(immich.getAlbum('album-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
// A stranded pending promise would make every later call replay the
// failure forever, even after Immich recovers.
expect((immich as any).pendingAlbumPromises.has('album-1')).toBe(false);
expect((immich as any).pendingAlbumsPromise).toBeNull();
});
it('ping reports unreachable as false rather than throwing', async () => {
// /api/health and /api/admin/status render this as a status field.
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(immich.ping()).resolves.toBe(false);
});
});
describe('getAlbums()', () => {
it('filters and overrides albums based on config', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{ id: 'album-1', albumName: 'Original Name', assetCount: 10 },
{ id: 'album-3', albumName: 'Not Allowed', assetCount: 5 },
],
});
const albums = await immich.getAlbums();
expect(albums).toHaveLength(1);
expect(albums[0].id).toBe('album-1');
expect(albums[0].albumName).toBe('Override Name');
expect(albums[0].slug).toBe('override-name');
});
});
// #522: a CJK album name slugified to '', so its link was "/" and the album
// was unreachable — no URL segment can ever be the empty string.
describe('getAlbumBySlug() with non-Latin names', () => {
function mockList(albumName: string) {
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/search/metadata')) {
return json({ assets: { items: [], nextPage: null, total: 0 } });
}
if (/\/albums\/album-2/.test(url)) {
return json({ id: 'album-2', albumName, assetCount: 0, assets: [], order: 'desc' });
}
return json([{ id: 'album-2', albumName, assetCount: 0 }]);
});
}
it('gives a CJK album a non-empty slug and resolves it', async () => {
mockList('家族相册');
const albums = await immich.getAlbums();
expect(albums[0].slug).toBe('家族相册');
const album = await immich.getAlbumBySlug('家族相册');
expect(album?.id).toBe('album-2');
});
it('resolves a Hangul slug that arrives decomposed (NFD) from the URL', async () => {
mockList('가족 앨범');
await immich.getAlbums();
const album = await immich.getAlbumBySlug('가족-앨범'.normalize('NFD'));
expect(album?.id).toBe('album-2');
});
it('resolves a slug that arrives percent-encoded from the route', async () => {
mockList('家族相册');
await immich.getAlbums();
const encoded = encodeURIComponent('家族相册');
expect(encoded).not.toBe('家族相册');
const album = await immich.getAlbumBySlug(encoded);
expect(album?.id).toBe('album-2');
});
it('falls back to the album id when the name has no letters at all', async () => {
mockList('🎉');
const albums = await immich.getAlbums();
expect(albums[0].slug).toBe('album-2');
const album = await immich.getAlbumBySlug('album-2');
expect(album?.id).toBe('album-2');
});
});
/**
* config.albums is the union of standalone and subpage albums. A slug has to
* be looked up among the albums of the route it arrived on, or a top-level
* URL answers for an album whose only route is a subpage — past that
* subpage's password.
*/
describe('getAlbumBySlug() searches the route, not the allowlist', () => {
beforeEach(() => {
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/search/metadata')) {
return json({ assets: { items: [], nextPage: null, total: 0 } });
}
const one = /\/albums\/(album-[12])/.exec(url);
if (one) {
const albumName = one[1] === 'album-1' ? 'Client Preview' : 'Open Work';
return json({ id: one[1], albumName, assetCount: 0, assets: [], order: 'desc' });
}
return json([
{ id: 'album-1', albumName: 'Client Preview', assetCount: 0 },
{ id: 'album-2', albumName: 'Open Work', assetCount: 0 },
]);
});
});
afterEach(() => {
subpages.length = 0;
});
it('finds a standalone album at the top level', async () => {
const album = await immich.getAlbumBySlug('open-work');
expect(album?.id).toBe('album-2');
});
it('does not find a subpage album at the top level', async () => {
subpages.push({ slug: 'handover', albumIds: ['album-1'] });
// album-1 carries the 'Override Name' override, so that is its slug.
expect(await immich.getAlbumBySlug('override-name')).toBeNull();
expect((await immich.getAlbumBySlug('override-name', 'handover'))?.id).toBe('album-1');
});
it('does not find anything through a disabled subpage', async () => {
subpages.push({ slug: 'handover', albumIds: ['album-1'], enabled: false });
expect(await immich.getAlbumBySlug('override-name', 'handover')).toBeNull();
expect(await immich.getAlbumBySlug('override-name')).toBeNull();
});
it('does not find a standalone album through a subpage that does not list it', async () => {
subpages.push({ slug: 'handover', albumIds: ['album-1'] });
expect(await immich.getAlbumBySlug('open-work', 'handover')).toBeNull();
});
});
describe('getStandaloneAlbums()', () => {
it('orders albums by gallery.yaml order, not Immich API order', async () => {
// The Immich API returns albums in its own (creation/update) order;
// the config lists album-2 first, so that must win on the homepage.
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{ id: 'album-1', albumName: 'First in API', assetCount: 1 },
{ id: 'album-2', albumName: 'Second in API', assetCount: 1 },
],
});
const albums = await immich.getStandaloneAlbums();
expect(albums.map((a) => a.id)).toEqual(['album-2', 'album-1']);
});
});
/**
* getConfig() re-checks the content files and clones the whole config on
* every call, and the client used to call it once per property it read —
* four times per streamed image, twice per album on a list refresh.
*/
describe('reads the config once per operation', () => {
const configReads = () => vi.mocked(config.getConfig).mock.calls.length;
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
it('streamAsset(): once per image', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
body: new ReadableStream(),
headers: { get: () => 'image/jpeg' },
});
await immich.streamAsset('asset-1', 'preview');
expect(configReads()).toBe(1);
});
it('getAlbums(): twice on a refresh, however many albums there are', async () => {
const album = (id: string) => ({ id, albumName: id, assetCount: 1, assets: [] });
mockFetch.mockResolvedValueOnce(json([album('album-1'), album('album-2')]));
expect(await immich.getAlbums()).toHaveLength(2);
// One for the request, one for filtering, naming and caching the list.
expect(configReads()).toBe(2);
});
it('getAlbum(): twice on a cache hit', async () => {
mockFetch.mockImplementation(async (url: string) =>
json(
url.includes('/search/metadata')
? { assets: { items: [], nextPage: null } }
: { id: 'album-1', albumName: 'A', assetCount: 0, assets: [], order: 'desc' },
),
);
await immich.getAlbum('album-1');
vi.mocked(config.getConfig).mockClear();
await immich.getAlbum('album-1');
// The allowlist check, then the configured sort.
expect(configReads()).toBe(2);
});
});
describe('getAlbum()', () => {
it('returns null for unallowed album IDs', async () => {
const album = await immich.getAlbum('album-3');
expect(album).toBeNull();
});
/**
* Immich 3.x returns an empty `assets` array from GET /albums/:id, so
* assets come from a separate POST /search/metadata call. Route the mock by
* URL rather than by call order — the two run concurrently.
*/
function mockAlbumApi(options: {
order?: 'asc' | 'desc';
pages?: Array<{ items: unknown[]; nextPage: string | null }>;
}) {
const pages = options.pages ?? [];
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
mockFetch.mockImplementation(async (url: string, init?: { body?: string }) => {
if (url.includes('/search/metadata')) {
const page = JSON.parse(init?.body ?? '{}').page ?? 1;
const p = pages[page - 1] ?? { items: [], nextPage: null };
return json({ assets: { items: p.items, nextPage: p.nextPage, total: p.items.length } });
}
// Mirrors the real 3.x response: assetCount set, assets empty.
return json({
id: 'album-1',
albumName: 'Name',
assetCount: 2,
assets: [],
order: options.order ?? 'desc',
});
});
}
const asset = (id: string, date: string, isTrashed = false) => ({
id,
isTrashed,
fileCreatedAt: date,
});
it('loads assets via metadata search and filters trashed ones', async () => {
mockAlbumApi({
pages: [
{
items: [
asset('asset-1', '2024-01-02T00:00:00Z'),
asset('asset-2', '2024-01-03T00:00:00Z', true),
],
nextPage: null,
},
],
});
const album = await immich.getAlbum('album-1');
expect(album?.assets).toHaveLength(1);
expect(album?.assets[0].id).toBe('asset-1');
});
it('requests EXIF data — without it the grid, lightbox and map go blank', async () => {
mockAlbumApi({ pages: [{ items: [], nextPage: null }] });
await immich.getAlbum('album-1');
const search = mockFetch.mock.calls.find((c: unknown[]) =>
String(c[0]).includes('/search/metadata'),
);
expect(search).toBeDefined();
const body = JSON.parse((search[1] as { body: string }).body);
expect(body.withExif).toBe(true);
expect(body.albumIds).toEqual(['album-1']);
expect(search[1].method).toBe('POST');
});
it('follows pagination until nextPage is null', async () => {
mockAlbumApi({
pages: [
{ items: [asset('a', '2024-01-01T00:00:00Z')], nextPage: '2' },
{ items: [asset('b', '2024-01-02T00:00:00Z')], nextPage: '3' },
{ items: [asset('c', '2024-01-03T00:00:00Z')], nextPage: null },
],
});
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']); // desc by fileCreatedAt
});
it('honours the album sort order', async () => {
const items = [
asset('old', '2024-01-01T00:00:00Z'),
asset('new', '2024-06-01T00:00:00Z'),
asset('mid', '2024-03-01T00:00:00Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
let album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['old', 'mid', 'new']);
cache.clear(); // same album id — otherwise the asc result is reused
mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] });
album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['new', 'mid', 'old']);
});
/**
* Immich's timeline sorts on localDateTime, not on the UTC instant. A
* 09:00 shot in Tokyo and a 09:00 shot in Berlin sit next to each other in
* the Immich UI even though they are eight hours apart in UTC.
*/
it('sorts by local capture time, matching the Immich timeline', async () => {
const items = [
{
id: 'berlin',
isTrashed: false,
localDateTime: '2024-05-01T09:00:00.000Z',
fileCreatedAt: '2024-05-01T07:00:00.000Z',
},
{
id: 'tokyo',
isTrashed: false,
localDateTime: '2024-05-01T08:00:00.000Z',
fileCreatedAt: '2024-04-30T23:00:00.000Z',
},
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
// UTC order would be tokyo → berlin; local capture order is the reverse.
expect(album?.assets.map((a) => a.id)).toEqual(['tokyo', 'berlin']);
});
it('falls back to fileCreatedAt when localDateTime is missing', async () => {
const items = [
asset('no-local-late', '2024-01-03T00:00:00Z'),
{
id: 'has-local',
isTrashed: false,
localDateTime: '2024-01-02T00:00:00Z',
fileCreatedAt: '2024-01-02T00:00:00Z',
},
asset('no-local-early', '2024-01-01T00:00:00Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
// Missing localDateTime sorts as 0, so those two lead — but they stay
// ordered among themselves by fileCreatedAt rather than at random.
expect(album?.assets.map((a) => a.id)).toEqual([
'no-local-early',
'no-local-late',
'has-local',
]);
});
/**
* Date.parse on an unusable value yields NaN, and a comparator returning
* NaN leaves sort() free to produce any permutation — including dropping
* the valid assets out of order.
*/
it('keeps a coherent order when a date is unparseable', async () => {
const items = [
asset('broken', 'not-a-date'),
asset('late', '2024-01-03T00:00:00Z'),
asset('early', '2024-01-01T00:00:00Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
const ids = album?.assets.map((a) => a.id) ?? [];
expect(ids).toHaveLength(3);
expect(ids.indexOf('early')).toBeLessThan(ids.indexOf('late'));
});
it('preserves sub-second precision within a burst', async () => {
const items = [
asset('frame-2', '2024-01-01T14:36:53.500Z'),
asset('frame-3', '2024-01-01T14:36:53.900Z'),
asset('frame-1', '2024-01-01T14:36:53.000Z'),
];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']);
});
/**
* The configured sort is applied on the way out of getAlbum(), so the LRU
* keeps one canonical Immich-ordered copy per album. Sorting the cached
* array in place would reorder the shared entry, and the next reader with a
* different mode would then be sorting an already-sorted array.
*/
describe('per-album sort', () => {
const items = [
{ ...asset('b', '2024-02-01T00:00:00Z'), originalFileName: 'IMG_2.jpg' },
{ ...asset('c', '2024-03-01T00:00:00Z'), originalFileName: 'IMG_10.jpg' },
{ ...asset('a', '2024-01-01T00:00:00Z'), originalFileName: 'IMG_1.jpg' },
];
it('overrides the Immich direction', async () => {
albumSortModes['album-1'] = 'oldest';
mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']);
});
it('applies the manual pin order', async () => {
albumSortModes['album-1'] = 'manual';
albumManualOrders['album-1'] = ['c'];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['c', 'a', 'b']);
});
it('leaves the cached album in canonical order', async () => {
albumSortModes['album-1'] = 'filename';
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const first = await immich.getAlbum('album-1');
expect(first?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']); // IMG_1, IMG_2, IMG_10
// Same cache entry, different mode: if the first call had sorted the
// stored array, the album would still be in filename order here.
albumSortModes['album-1'] = 'newest';
const second = await immich.getAlbum('album-1');
expect(second?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']);
expect(second?.assets).not.toBe(first?.assets);
});
it('gives concurrent callers independent arrays', async () => {
albumSortModes['album-1'] = 'manual';
albumManualOrders['album-1'] = ['b'];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
// Coalesced through the same pending promise, but each caller applies
// the sort itself — so neither can be handed the other's array.
const [one, two] = await Promise.all([
immich.getAlbum('album-1'),
immich.getAlbum('album-1'),
]);
expect(one?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
expect(two?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
expect(one?.assets).not.toBe(two?.assets);
});
});
});
/**
* Album blocks in journal entries, content pages and essay files are
* rendered from getAlbumAssetsRaw(), and so is the published-asset set. It
* used to go to Immich on every call — two requests per album per page view,
* and again on every set rebuild — with no cache and no coalescing.
*/
describe('getAlbumAssetsRaw() request count', () => {
function mockRawAlbumApi(albumId: string) {
mockFetch.mockImplementation(async (url: string) => {
const body = url.includes('/search/metadata')
? {
assets: {
items: [
{ id: 'old', isTrashed: false, fileCreatedAt: '2024-01-01T00:00:00Z' },
{ id: 'gone', isTrashed: true, fileCreatedAt: '2024-01-02T00:00:00Z' },
{ id: 'new', isTrashed: false, fileCreatedAt: '2024-01-03T00:00:00Z' },
],
nextPage: null,
},
}
: { id: albumId, albumName: 'Raw', assetCount: 3, assets: [], order: 'desc' };
return { ok: true, headers: { get: () => 'application/json' }, json: async () => body };
});
}
it('asks Immich once for many concurrent callers and serves repeats from the cache', async () => {
mockRawAlbumApi('unlisted');
const lists = await Promise.all(
Array.from({ length: 10 }, () => immich.getAlbumAssetsRaw('unlisted')),
);
// One album request plus one metadata page — not 2 × 10.
expect(mockFetch).toHaveBeenCalledTimes(2);
await immich.getAlbumAssetsRaw('unlisted');
expect(mockFetch).toHaveBeenCalledTimes(2);
// Unchanged result: trashed dropped, capture-time order of a 'desc' album.
for (const list of lists) expect(list.map((a) => a.id)).toEqual(['new', 'old']);
});
it('shares the load with getAlbum() for an allowlisted album', async () => {
mockRawAlbumApi('album-1');
await immich.getAlbum('album-1');
const raw = await immich.getAlbumAssetsRaw('album-1');
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(raw.map((a) => a.id)).toEqual(['new', 'old']);
});
it('hands each caller its own array, so no one reorders the cached entry', async () => {
mockRawAlbumApi('unlisted');
const first = await immich.getAlbumAssetsRaw('unlisted');
first.reverse();
expect((await immich.getAlbumAssetsRaw('unlisted')).map((a) => a.id)).toEqual(['new', 'old']);
});
it('goes back to Immich when the admin editors ask for a fresh copy', async () => {
mockRawAlbumApi('unlisted');
await immich.getAlbumAssetsRaw('unlisted');
await immich.getAlbumAssetsRaw('unlisted', true);
expect(mockFetch).toHaveBeenCalledTimes(4);
});
it('still does not open getAlbum() to an album outside the allowlist', async () => {
mockRawAlbumApi('unlisted');
vi.spyOn(console, 'warn').mockImplementation(() => {});
await immich.getAlbumAssetsRaw('unlisted');
expect(await immich.getAlbum('unlisted')).toBeNull();
});
});
});
describe('stale fallback when Immich is unavailable', () => {
const mockFetch = global.fetch as any;
beforeEach(() => {
vi.clearAllMocks();
cache.clear();
});
it('serves the previously cached album list instead of throwing', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{
id: 'album-1',
albumName: 'Original',
description: '',
albumThumbnailAssetId: null,
assetCount: 1,
assets: [],
createdAt: '',
updatedAt: '',
order: 'desc',
},
],
});
const fresh = await immich.getAlbums();
expect(fresh).toHaveLength(1);
vi.useFakeTimers();
// Strictly past cacheTtl (60_000ms) so cache.get()'s fresh-only check
// misses and the fallback path — not a lucky cache hit — is what's tested.
vi.advanceTimersByTime(60_001);
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const stale = await immich.getAlbums();
vi.useRealTimers();
expect(stale).toHaveLength(1);
expect(stale[0].id).toBe('album-1');
});
it('still throws when there is nothing cached to fall back to', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(immich.getAlbums()).rejects.toThrow(ImmichUnavailableError);
});
/**
* getAlbums(true) used to delete the cache entry before attempting the
* fetch. A request with forceFresh that then failed left every later
* visitor — not just the one who asked for a refresh — with nothing to
* fall back to, for as long as the outage lasted (GHSA-w293-x8pc-j4cv).
*/
it('forceFresh does not delete the entry a failed refresh could have fallen back to', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{
id: 'album-1',
albumName: 'Original',
description: '',
albumThumbnailAssetId: null,
assetCount: 1,
assets: [],
createdAt: '',
updatedAt: '',
order: 'desc',
},
],
});
expect(await immich.getAlbums()).toHaveLength(1);
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const refreshed = await immich.getAlbums(true);
expect(refreshed).toHaveLength(1);
expect(refreshed[0].id).toBe('album-1');
});
it('getAlbum(id, true) has the same guarantee as getAlbums(true)', async () => {
// GET /albums/:id and POST /search/metadata run concurrently (Promise.all
// in loadAlbum), so they must be routed by URL rather than queued in call
// order — same pattern as the getAlbum() describe block below.
mockFetch.mockImplementation(async (url: string) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () =>
url.includes('/search/metadata')
? { assets: { items: [], nextPage: null, total: 0 } }
: { id: 'album-1', albumName: 'Original', assetCount: 0, assets: [], order: 'desc' },
}));
expect(await immich.getAlbum('album-1')).not.toBeNull();
mockFetch.mockImplementation(async () => {
throw new Error('ECONNREFUSED');
});
const refreshed = await immich.getAlbum('album-1', true);
expect(refreshed).not.toBeNull();
expect(refreshed?.id).toBe('album-1');
});
it('does not swallow a definitive 404 as an outage', async () => {
// A missing album must keep reporting missing, not fall back to stale data.
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
headers: { get: () => 'application/json' },
});
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
});
it('still refuses albums outside the allowlist', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(immich.getAlbum('not-allowed')).resolves.toBeNull();
});
// #626: a cached 404 used to come back from the stale fallback as the MISSING
// sentinel itself — a truthy object with no fields. Every `if (!asset)` guard
// passed on it and the page answered 500 for the length of the outage.
const notFound = {
ok: false,
status: 404,
statusText: 'Not Found',
headers: { get: () => 'application/json' },
};
it('answers a stale "missing" asset with null during an outage', async () => {
mockFetch.mockResolvedValue(notFound);
await expect(immich.getAssetInfo('deleted-hero')).resolves.toBeNull();
vi.useFakeTimers();
vi.advanceTimersByTime(60_001); // past cacheTtl, inside the stale window
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await immich.getAssetInfo('deleted-hero');
vi.useRealTimers();
// toBeNull, not toBeFalsy: the bug was a truthy object, so the precise
// assertion is the one that would have failed.
expect(result).toBeNull();
});
it('answers a stale "missing" album with null during an outage', async () => {
mockFetch.mockResolvedValue(notFound);
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
vi.useFakeTimers();
vi.advanceTimersByTime(60_001);
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const result = await immich.getAlbum('album-1');
vi.useRealTimers();
expect(result).toBeNull();
});
});