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 = {}; const albumManualOrders: Record = {}; // 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('../config'); return { ...actual, getConfig: () => ({ 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); }); 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']); }); }); 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); }); }); }); }); 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(); }); });