2026-09-29 22:50:30 +02:00
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
|
|
import { NextRequest } from 'next/server';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Resource bounds of the ZIP-of-originals stream: how many archives one client
|
|
|
|
|
|
* may hold open at once, that every way an archive can end gives its slot
|
|
|
|
|
|
* back, and that a client which stops reading loses the archive (and its
|
|
|
|
|
|
* upstream Immich body) instead of pinning both for as long as it likes.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
vi.mock('@/lib/immich', () => ({ immich: { streamAsset: vi.fn() } }));
|
|
|
|
|
|
vi.mock('@/lib/rate-limit', () => ({
|
|
|
|
|
|
getClientIp: vi.fn((req: NextRequest) => req.headers.get('x-test-ip') ?? 'unknown'),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
ARCHIVE_STALL_TIMEOUT_MS,
|
|
|
|
|
|
MAX_CONCURRENT_ARCHIVES,
|
|
|
|
|
|
__archivesInFlight,
|
2026-10-02 18:14:23 +02:00
|
|
|
|
entryDate,
|
2026-09-29 22:50:30 +02:00
|
|
|
|
withArchiveSlot,
|
|
|
|
|
|
} from '@/lib/zipArchive';
|
|
|
|
|
|
import { immich, type ImmichAsset } from '@/lib/immich';
|
|
|
|
|
|
|
|
|
|
|
|
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
|
|
|
|
|
|
|
|
const ASSETS = [
|
|
|
|
|
|
{ id: 'a1', type: 'IMAGE', originalFileName: 'a1.jpg' },
|
|
|
|
|
|
{ id: 'a2', type: 'IMAGE', originalFileName: 'a2.jpg' },
|
|
|
|
|
|
] as unknown as ImmichAsset[];
|
|
|
|
|
|
|
|
|
|
|
|
let ipSeq = 0;
|
|
|
|
|
|
/** A fresh client per test, so slots can never leak from one test into another. */
|
|
|
|
|
|
function freshIp(): string {
|
|
|
|
|
|
ipSeq++;
|
|
|
|
|
|
return `203.0.113.${ipSeq}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const req = (ip: string) =>
|
|
|
|
|
|
new NextRequest('http://localhost/api/download/tok/archive', { headers: { 'x-test-ip': ip } });
|
|
|
|
|
|
|
|
|
|
|
|
/** Start an archive of `assets` for `ip` through the slot wrapper. */
|
|
|
|
|
|
function start(ip: string, assets: ImmichAsset[] = ASSETS) {
|
|
|
|
|
|
return withArchiveSlot(req(ip), async (stream) => stream('Album', assets));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* An Immich originals body that produces `chunks` × 64 KB on demand and records
|
|
|
|
|
|
* whether it was cancelled — i.e. whether the upstream socket was let go.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function upstream(chunks: number) {
|
|
|
|
|
|
const state = { pulled: 0, cancelled: false };
|
|
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
|
|
|
|
pull(controller) {
|
|
|
|
|
|
if (state.pulled >= chunks) {
|
|
|
|
|
|
controller.close();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
state.pulled++;
|
|
|
|
|
|
controller.enqueue(new Uint8Array(64 * 1024).fill(state.pulled));
|
|
|
|
|
|
},
|
|
|
|
|
|
cancel() {
|
|
|
|
|
|
state.cancelled = true;
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
return { state, result: { stream, contentType: 'image/jpeg', contentLength: null } };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
mockStream.mockReset();
|
|
|
|
|
|
mockStream.mockImplementation(async () => upstream(2).result);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('archive concurrency cap', () => {
|
|
|
|
|
|
it(`refuses the ${MAX_CONCURRENT_ARCHIVES + 1}th open archive of one client with a 429`, async () => {
|
|
|
|
|
|
const ip = freshIp();
|
|
|
|
|
|
const open = [];
|
|
|
|
|
|
for (let i = 0; i < MAX_CONCURRENT_ARCHIVES; i++) {
|
|
|
|
|
|
const res = await start(ip);
|
|
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
|
|
open.push(res);
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(__archivesInFlight(ip)).toBe(MAX_CONCURRENT_ARCHIVES);
|
|
|
|
|
|
|
|
|
|
|
|
const handler = vi.fn();
|
|
|
|
|
|
const refused = await withArchiveSlot(req(ip), handler);
|
|
|
|
|
|
expect(refused.status).toBe(429);
|
|
|
|
|
|
expect(Number(refused.headers.get('retry-after'))).toBeGreaterThan(0);
|
|
|
|
|
|
// Refused before the handler, so before any Immich request.
|
|
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
|
|
// Another client is unaffected.
|
|
|
|
|
|
const other = freshIp();
|
|
|
|
|
|
const res = await start(other);
|
|
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all([...open, res].map((r) => r.body!.cancel()));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('gives the slot back when the archive is read to the end', async () => {
|
|
|
|
|
|
const ip = freshIp();
|
|
|
|
|
|
const res = await start(ip);
|
|
|
|
|
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
|
|
|
|
expect(bytes[0]).toBe(0x50); // 'P'
|
|
|
|
|
|
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('gives the slot back when the archive fails', async () => {
|
|
|
|
|
|
const ip = freshIp();
|
|
|
|
|
|
mockStream.mockRejectedValue(new Error('Immich went away'));
|
|
|
|
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
|
|
const res = await start(ip);
|
|
|
|
|
|
await expect(res.arrayBuffer()).rejects.toThrow();
|
|
|
|
|
|
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('gives the slot back, and lets go of Immich, when the download is cancelled', async () => {
|
|
|
|
|
|
const ip = freshIp();
|
|
|
|
|
|
const origin = upstream(1000);
|
|
|
|
|
|
mockStream.mockResolvedValue(origin.result);
|
|
|
|
|
|
const res = await start(ip);
|
|
|
|
|
|
const reader = res.body!.getReader();
|
|
|
|
|
|
await reader.read();
|
|
|
|
|
|
await reader.cancel();
|
|
|
|
|
|
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
|
|
|
|
|
await vi.waitFor(() => expect(origin.state.cancelled).toBe(true));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('gives the slot back when the route refuses or throws instead of streaming', async () => {
|
|
|
|
|
|
const ip = freshIp();
|
|
|
|
|
|
const refused = await withArchiveSlot(req(ip), async () => new Response(null, { status: 404 }));
|
|
|
|
|
|
expect(refused.status).toBe(404);
|
|
|
|
|
|
expect(__archivesInFlight(ip)).toBe(0);
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
withArchiveSlot(req(ip), async () => {
|
|
|
|
|
|
throw new Error('boom');
|
|
|
|
|
|
}),
|
|
|
|
|
|
).rejects.toThrow('boom');
|
|
|
|
|
|
expect(__archivesInFlight(ip)).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('never releases a slot twice', async () => {
|
|
|
|
|
|
const ip = freshIp();
|
|
|
|
|
|
const first = await start(ip);
|
|
|
|
|
|
const second = await start(ip);
|
|
|
|
|
|
await first.arrayBuffer();
|
|
|
|
|
|
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(1));
|
|
|
|
|
|
// A late cancel on the finished body must not free the other archive's slot.
|
|
|
|
|
|
await first.body?.cancel().catch(() => {});
|
|
|
|
|
|
expect(__archivesInFlight(ip)).toBe(1);
|
|
|
|
|
|
await second.body!.cancel();
|
|
|
|
|
|
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('archive stall timeout', () => {
|
|
|
|
|
|
it('tears down the archive and the upstream body when the client stops reading', async () => {
|
|
|
|
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
|
|
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
|
|
const ip = freshIp();
|
|
|
|
|
|
const origin = upstream(1000);
|
|
|
|
|
|
mockStream.mockResolvedValue(origin.result);
|
|
|
|
|
|
|
|
|
|
|
|
const res = await start(ip);
|
|
|
|
|
|
const reader = res.body!.getReader();
|
|
|
|
|
|
await reader.read();
|
|
|
|
|
|
|
|
|
|
|
|
// Still within the window: nothing happens.
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(ARCHIVE_STALL_TIMEOUT_MS - 1_000);
|
|
|
|
|
|
expect(origin.state.cancelled).toBe(false);
|
|
|
|
|
|
expect(__archivesInFlight(ip)).toBe(1);
|
|
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
|
|
await vi.waitFor(() => expect(origin.state.cancelled).toBe(true));
|
|
|
|
|
|
expect(__archivesInFlight(ip)).toBe(0);
|
|
|
|
|
|
// The response is ended with an error rather than left open. The one chunk
|
|
|
|
|
|
// queued before the stall may still be handed out first.
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
(async () => {
|
|
|
|
|
|
for (;;) if ((await reader.read()).done) return;
|
|
|
|
|
|
})(),
|
|
|
|
|
|
).rejects.toThrow(/stalled/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('does not cut a slow download that keeps reading', async () => {
|
|
|
|
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
|
|
|
|
const ip = freshIp();
|
|
|
|
|
|
const origin = upstream(128);
|
|
|
|
|
|
mockStream.mockResolvedValue(origin.result);
|
|
|
|
|
|
|
|
|
|
|
|
const res = await start(ip, [ASSETS[0]]);
|
|
|
|
|
|
const reader = res.body!.getReader();
|
|
|
|
|
|
let bytes = 0;
|
|
|
|
|
|
let elapsed = 0;
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
|
if (done) break;
|
|
|
|
|
|
bytes += value.byteLength;
|
|
|
|
|
|
// Well under the stall timeout per read, far over it in total.
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(ARCHIVE_STALL_TIMEOUT_MS / 2);
|
|
|
|
|
|
elapsed += ARCHIVE_STALL_TIMEOUT_MS / 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
expect(elapsed).toBeGreaterThan(ARCHIVE_STALL_TIMEOUT_MS * 3);
|
|
|
|
|
|
expect(bytes).toBeGreaterThan(128 * 64 * 1024);
|
|
|
|
|
|
expect(origin.state.cancelled).toBe(false);
|
|
|
|
|
|
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-10-02 18:14:23 +02:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* F-16: every entry carried the time the ZIP was built. Each now carries the
|
|
|
|
|
|
* capture time, as the wall clock of the camera.
|
|
|
|
|
|
*/
|
|
|
|
|
|
describe('archive entry timestamps', () => {
|
|
|
|
|
|
/** The DOS date/time of every local file header, as `YYYY-MM-DD HH:MM:SS`. */
|
|
|
|
|
|
function entryTimes(bytes: Uint8Array): string[] {
|
|
|
|
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
|
|
|
|
const out: string[] = [];
|
|
|
|
|
|
for (let i = 0; i + 30 <= bytes.length; i++) {
|
|
|
|
|
|
if (view.getUint32(i, true) !== 0x04034b50) continue;
|
|
|
|
|
|
const time = view.getUint16(i + 10, true);
|
|
|
|
|
|
const date = view.getUint16(i + 12, true);
|
|
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
|
|
|
out.push(
|
|
|
|
|
|
`${(date >> 9) + 1980}-${pad((date >> 5) & 15)}-${pad(date & 31)} ` +
|
|
|
|
|
|
`${pad(time >> 11)}:${pad((time >> 5) & 63)}:${pad((time & 31) * 2)}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('dates each entry by when the photo was taken, on the camera clock', async () => {
|
|
|
|
|
|
const assets = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'a1',
|
|
|
|
|
|
originalFileName: 'a1.jpg',
|
|
|
|
|
|
// 14:30 in Berlin; the instant is 12:30 UTC.
|
|
|
|
|
|
localDateTime: '2024-05-01T14:30:10.000Z',
|
|
|
|
|
|
exifInfo: { dateTimeOriginal: '2024-05-01T12:30:10.000Z' },
|
|
|
|
|
|
fileCreatedAt: '2024-05-01T12:30:10.000Z',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'a2',
|
|
|
|
|
|
originalFileName: 'a2.jpg',
|
|
|
|
|
|
exifInfo: { dateTimeOriginal: '2019-12-24T18:00:00.000Z' },
|
|
|
|
|
|
fileCreatedAt: '2020-01-01T00:00:00.000Z',
|
|
|
|
|
|
},
|
|
|
|
|
|
{ id: 'a3', originalFileName: 'a3.jpg', fileCreatedAt: '2021-07-04T09:15:00.000Z' },
|
|
|
|
|
|
] as unknown as ImmichAsset[];
|
|
|
|
|
|
|
|
|
|
|
|
const res = await start(freshIp(), assets);
|
|
|
|
|
|
const times = entryTimes(new Uint8Array(await res.arrayBuffer()));
|
|
|
|
|
|
|
|
|
|
|
|
expect(times).toEqual(['2024-05-01 14:30:10', '2019-12-24 18:00:00', '2021-07-04 09:15:00']);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('falls back to the default for an asset with no usable date', () => {
|
|
|
|
|
|
expect(entryDate({ fileCreatedAt: 'not a date' } as unknown as ImmichAsset)).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|