1197 lines
45 KiB
TypeScript
1197 lines
45 KiB
TypeScript
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();
|
||
});
|
||
});
|