feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user