Files
clean-architecture-frontend…/tests/unit/resumable-upload-fetch-transport.test.ts
T

448 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
createResumableUploadFetchJsonTransport,
type ResumableUploadFetchTransportDependencies,
} from "../../src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts";
import type {
ResumableUploadControlOperation,
ResumableUploadJsonTransport,
} from "../../src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts";
import { createResumableUploadWebLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
const API_ORIGIN = "https://api.example";
const ENDPOINTS = Object.freeze({
CREATE_SESSION: `${API_ORIGIN}/uploads/create`,
GET_STATUS: `${API_ORIGIN}/uploads/status`,
COMPLETE: `${API_ORIGIN}/uploads/complete`,
ABORT: `${API_ORIGIN}/uploads/abort`,
});
const activeSignal = new AbortController().signal;
function responseAt(
url: string,
body: BodyInit | null,
init: ResponseInit,
): Response {
const response = new Response(body, init);
Object.defineProperty(response, "url", {
configurable: true,
value: url,
});
return response;
}
function jsonResponseAt(
url: string,
value: unknown,
status = 200,
headers: Readonly<Record<string, string>> = {},
): Response {
const body = JSON.stringify(value);
return responseAt(url, body, {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"content-length": String(new TextEncoder().encode(body).byteLength),
...headers,
},
});
}
function createTransport(
fetcher: typeof fetch,
overrides: Partial<ResumableUploadFetchTransportDependencies> = {},
): ResumableUploadJsonTransport {
return createResumableUploadFetchJsonTransport({
endpoints: ENDPOINTS,
allowedOrigins: [API_ORIGIN],
credentials: "same-origin",
fetcher,
timeoutMs: 100,
maxRequestBytes: 4_096,
maxResponseBytes: 4_096,
maxRetryAfterMs: 3_000,
...overrides,
});
}
describe("resumable upload fetch transport", () => {
it("uses a closed operation map and fixed production fetch policy", async () => {
let receivedUrl = "";
let receivedInit: RequestInit | undefined;
const fetcher = vi.fn(
async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
receivedUrl = String(input);
receivedInit = init;
return jsonResponseAt(ENDPOINTS.GET_STATUS, { state: "ok" });
},
) as unknown as typeof fetch;
const transport = createTransport(fetcher, {
requestHeaders: [{ name: "x-runtime-version", value: "v1" }],
});
const result = await transport.execute({
operation: "GET_STATUS",
body: { sessionId: "session_01" },
signal: activeSignal,
});
expect(result).toEqual({
ok: true,
value: { state: "ok" },
});
expect(receivedUrl).toBe(ENDPOINTS.GET_STATUS);
expect(receivedInit).toMatchObject({
method: "POST",
credentials: "same-origin",
redirect: "error",
referrerPolicy: "no-referrer",
cache: "no-store",
mode: "cors",
});
const headers = new Headers(receivedInit?.headers);
expect(headers.get("accept")).toBe("application/json");
expect(headers.get("content-type")).toBe(
"application/json; charset=utf-8",
);
expect(headers.get("x-runtime-version")).toBe("v1");
});
it("rejects a runtime operation outside the allowlist before fetch", async () => {
const fetcher = vi.fn();
const transport = createTransport(
fetcher as unknown as typeof fetch,
);
const execute = transport.execute as (
input: Readonly<{
operation: string;
body: Readonly<Record<string, unknown>>;
signal: AbortSignal;
}>,
) => ReturnType<ResumableUploadJsonTransport["execute"]>;
const result = await execute({
operation: "DELETE_EVERYTHING",
body: {},
signal: activeSignal,
});
expect(result).toMatchObject({
ok: false,
error: { code: "INVALID_INPUT" },
});
expect(fetcher).not.toHaveBeenCalled();
});
it("keeps timeout active while a response body is stalled and cancels it", async () => {
const cancel = vi.fn();
const stalled = new ReadableStream<Uint8Array>({
cancel,
});
const fetcher = vi.fn(async () =>
responseAt(ENDPOINTS.GET_STATUS, stalled, {
status: 200,
headers: { "content-type": "application/json" },
}),
) as unknown as typeof fetch;
const transport = createTransport(fetcher, { timeoutMs: 5 });
const result = await transport.execute({
operation: "GET_STATUS",
body: {},
signal: activeSignal,
});
await Promise.resolve();
expect(result).toMatchObject({
ok: false,
error: {
code: "UNAVAILABLE",
retryable: true,
recovery: "RESUME",
},
});
expect(cancel).toHaveBeenCalledTimes(1);
});
it("propagates parent abort during a stalled body and cancels promptly", async () => {
const cancel = vi.fn();
const stalled = new ReadableStream<Uint8Array>({
cancel,
});
const fetcher = vi.fn(async () =>
responseAt(ENDPOINTS.GET_STATUS, stalled, {
status: 200,
headers: { "content-type": "application/json" },
}),
) as unknown as typeof fetch;
const transport = createTransport(fetcher, { timeoutMs: 1_000 });
const controller = new AbortController();
const pending = transport.execute({
operation: "GET_STATUS",
body: {},
signal: controller.signal,
});
await Promise.resolve();
controller.abort();
const result = await pending;
await Promise.resolve();
expect(result).toMatchObject({
ok: false,
error: { code: "ABORTED", retryable: false },
});
expect(cancel).toHaveBeenCalledTimes(1);
});
it("requires Content-Length to match the bytes actually consumed", async () => {
const body = JSON.stringify({ state: "ok" });
const fetcher = vi.fn(async () =>
responseAt(ENDPOINTS.GET_STATUS, body, {
status: 200,
headers: {
"content-type": "application/json",
"content-length": String(body.length + 1),
},
}),
) as unknown as typeof fetch;
const result = await createTransport(fetcher).execute({
operation: "GET_STATUS",
body: {},
signal: activeSignal,
});
expect(result).toMatchObject({
ok: false,
error: {
code: "INTEGRITY_FAILED",
recovery: "RECONCILE",
},
});
});
it("bounds Retry-After and cancels an unconsumed error body", async () => {
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1]));
},
cancel,
});
const fetcher = vi.fn(async () =>
responseAt(ENDPOINTS.GET_STATUS, body, {
status: 429,
headers: { "retry-after": "2" },
}),
) as unknown as typeof fetch;
const result = await createTransport(fetcher).execute({
operation: "GET_STATUS",
body: {},
signal: activeSignal,
});
await Promise.resolve();
expect(result).toMatchObject({
ok: false,
error: {
code: "UNAVAILABLE",
retryable: true,
retryAfterMs: 2_000,
},
});
expect(cancel).toHaveBeenCalledTimes(1);
});
it.each([500, 599])(
"classifies HTTP %s as retryable; runtime attempt limits remain authoritative",
async (status) => {
const fetcher = vi.fn(async () =>
jsonResponseAt(
ENDPOINTS.GET_STATUS,
{ error: "closed" },
status,
),
) as unknown as typeof fetch;
const result = await createTransport(fetcher).execute({
operation: "GET_STATUS",
body: {},
signal: activeSignal,
});
expect(result).toMatchObject({
ok: false,
error: {
code: "UNAVAILABLE",
retryable: true,
recovery: "RESUME",
},
});
},
);
it("cancels bodies rejected by URL and content-type policy", async () => {
const urlCancel = vi.fn();
const typeCancel = vi.fn();
const responses = [
responseAt(
`${API_ORIGIN}/unexpected`,
new ReadableStream<Uint8Array>({ cancel: urlCancel }),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
responseAt(
ENDPOINTS.GET_STATUS,
new ReadableStream<Uint8Array>({ cancel: typeCancel }),
{
status: 200,
headers: { "content-type": "text/html" },
},
),
];
const fetcher = vi.fn(async () => responses.shift()!) as unknown as typeof fetch;
const transport = createTransport(fetcher);
const wrongUrl = await transport.execute({
operation: "GET_STATUS",
body: {},
signal: activeSignal,
});
const wrongType = await transport.execute({
operation: "GET_STATUS",
body: {},
signal: activeSignal,
});
await Promise.resolve();
expect(wrongUrl).toMatchObject({
ok: false,
error: { code: "POLICY_REJECTED" },
});
expect(wrongType).toMatchObject({
ok: false,
error: { code: "CORRUPT_DATA" },
});
expect(urlCancel).toHaveBeenCalledTimes(1);
expect(typeCancel).toHaveBeenCalledTimes(1);
});
it.each([
"authorization",
"proxy-authorization",
"set-cookie",
"connection",
"transfer-encoding",
])("rejects composition headers that can alter authority: %s", (name) => {
const fetcher = vi.fn() as unknown as typeof fetch;
expect(() =>
createTransport(fetcher, {
requestHeaders: [{ name, value: "forbidden" }],
}),
).toThrow(TypeError);
});
it("rejects unknown expected-success operation keys", () => {
const fetcher = vi.fn() as unknown as typeof fetch;
expect(() =>
createTransport(fetcher, {
expectedSuccessStatuses: {
DELETE_EVERYTHING: 204,
} as unknown as Partial<
Readonly<Record<ResumableUploadControlOperation, number>>
>,
}),
).toThrow(TypeError);
});
});
describe("resumable upload Web Lock", () => {
it("uses one exclusive opaque lock name and serializes mutations", async () => {
const requests: Array<
Readonly<{
name: string;
options: Readonly<{
mode: "exclusive";
signal?: AbortSignal;
}>;
}>
> = [];
let queue = Promise.resolve();
const manager = {
request<Value>(
name: string,
options: Readonly<{
mode: "exclusive";
signal?: AbortSignal;
}>,
callback: (lock: unknown) => Promise<Value>,
): Promise<Value> {
requests.push({ name, options });
const result = queue.then(async () => await callback({ name }));
queue = result.then(
() => undefined,
() => undefined,
);
return result;
},
} as unknown as LockManager;
const lock = createResumableUploadWebLock(
manager,
"upload-runtime-v1",
);
const events: string[] = [];
let releaseFirst!: () => void;
const gate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const first = lock.run(
"upload_key_lock",
activeSignal,
async () => {
events.push("first:start");
await gate;
events.push("first:end");
return 1;
},
);
const second = lock.run(
"upload_key_lock",
activeSignal,
async () => {
events.push("second:start");
return 2;
},
);
await Promise.resolve();
await Promise.resolve();
expect(events).toEqual(["first:start"]);
releaseFirst();
await expect(Promise.all([first, second])).resolves.toEqual([1, 2]);
expect(events).toEqual([
"first:start",
"first:end",
"second:start",
]);
expect(requests).toEqual([
{
name: "upload-runtime-v1:upload_key_lock",
options: { mode: "exclusive", signal: activeSignal },
},
{
name: "upload-runtime-v1:upload_key_lock",
options: { mode: "exclusive", signal: activeSignal },
},
]);
});
});