Files
immich-folio/lib/__tests__/immichTransport.test.ts

209 lines
6.2 KiB
TypeScript
Raw Permalink Normal View History

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { requestJson, ImmichUnavailableError, isTimeout } from '../immichTransport';
/**
* The distinction these tests pin is the one that matters to visitors: `null`
* means Immich answered and the thing is gone, an ImmichUnavailableError means
* Immich could not answer at all. Conflating them once rendered every album URL
* as a hard 404 during an outage — including albums that exist.
*/
const opts = {
apiUrl: 'http://immich.test/api',
apiKey: 'test-key',
timeoutMs: 5000,
endpoint: '/albums',
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('requestJson', () => {
it('returns the parsed body', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => json([{ id: 'album-1' }])),
);
await expect(requestJson(opts)).resolves.toEqual([{ id: 'album-1' }]);
});
it('sends the API key and asks for JSON', async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => json([]));
vi.stubGlobal('fetch', fetchMock);
await requestJson(opts);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://immich.test/api/albums');
expect(init).toBeDefined();
expect(init?.method).toBe('GET');
expect(init?.headers).toMatchObject({ 'x-api-key': 'test-key', Accept: 'application/json' });
});
it('posts when given a body', async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => json([]));
vi.stubGlobal('fetch', fetchMock);
await requestJson({ ...opts, body: { albumId: 'a1' } });
const [, init] = fetchMock.mock.calls[0];
expect(init?.method).toBe('POST');
expect(init?.body).toBe('{"albumId":"a1"}');
});
it('returns null for a resource that is genuinely gone', async () => {
for (const status of [404, 410]) {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status })),
);
await expect(requestJson(opts)).resolves.toBeNull();
}
});
it('throws for a status that means "not right now"', async () => {
// A 5xx, a rate limit or a rejected API key are outages, not deletions.
for (const status of [401, 429, 500, 502, 503]) {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status, statusText: 'Nope' })),
);
await expect(requestJson(opts)).rejects.toBeInstanceOf(ImmichUnavailableError);
}
});
it('carries the status on the error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 503, statusText: 'Unavailable' })),
);
await expect(requestJson(opts)).rejects.toMatchObject({ status: 503 });
});
/**
* Neither the "gone" nor the "outage" 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).
*/
it('cancels the body on the "gone" path (404/410)', async () => {
for (const status of [404, 410]) {
const res = new Response('', { status });
const cancelSpy = vi.spyOn(res.body!, 'cancel');
vi.stubGlobal(
'fetch',
vi.fn(async () => res),
);
await requestJson(opts);
expect(cancelSpy).toHaveBeenCalledOnce();
}
});
it('cancels the body on the "outage" path (5xx)', async () => {
const res = new Response('', { status: 503, statusText: 'Unavailable' });
const cancelSpy = vi.spyOn(res.body!, 'cancel');
vi.stubGlobal(
'fetch',
vi.fn(async () => res),
);
await expect(requestJson(opts)).rejects.toBeInstanceOf(ImmichUnavailableError);
expect(cancelSpy).toHaveBeenCalledOnce();
});
it('cancels the body when the content type is not JSON', async () => {
const res = new Response('<html>502 Bad Gateway</html>', {
status: 200,
headers: { 'Content-Type': 'text/html' },
});
const cancelSpy = vi.spyOn(res.body!, 'cancel');
vi.stubGlobal(
'fetch',
vi.fn(async () => res),
);
await expect(requestJson(opts)).rejects.toThrow(/non-JSON/);
expect(cancelSpy).toHaveBeenCalledOnce();
});
it('refuses a non-JSON body', async () => {
// We always send Accept: application/json, so an HTML body is a gateway
// error page, not an answer about the resource.
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response('<html>502 Bad Gateway</html>', {
status: 200,
headers: { 'Content-Type': 'text/html' },
}),
),
);
await expect(requestJson(opts)).rejects.toThrow(/non-JSON/);
});
it('reports a timeout as such, with the budget in the message', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
}),
);
await expect(requestJson(opts)).rejects.toThrow(/did not respond within 5000ms/);
});
it('reports an unreachable host separately from a timeout', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('fetch failed');
}),
);
await expect(requestJson(opts)).rejects.toThrow(/Cannot reach Immich/);
});
it('turns a malformed body into an outage rather than a crash', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response('{ not json', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);
await expect(requestJson(opts)).rejects.toThrow(/malformed JSON/);
});
});
describe('isTimeout', () => {
it('recognises both ways of giving up waiting', () => {
expect(isTimeout(Object.assign(new Error(''), { name: 'TimeoutError' }))).toBe(true);
expect(isTimeout(Object.assign(new Error(''), { name: 'AbortError' }))).toBe(true);
expect(isTimeout(new TypeError('fetch failed'))).toBe(false);
expect(isTimeout('not an error')).toBe(false);
});
});