release: v0.16.0
See CHANGELOG.md for the full v0.16.0 entry. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
208
lib/__tests__/immichTransport.test.ts
Normal file
208
lib/__tests__/immichTransport.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user