Wires the whole Asset capability into the running application: the multipart upload transport (the contract runtime can only express JSON bodies), a single composition-root-owned CSRF provider shared between the platform's credential collaborator (18 JSON operations) and the upload transport (1 multipart operation), and Studio/StudioShell exposure of the Asset gateway alongside the existing document gateway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
4.0 KiB
TypeScript
121 lines
4.0 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { afterAll, afterEach, beforeAll, test } from "vitest";
|
|
import { http, HttpResponse } from "msw";
|
|
import { setupServer } from "msw/node";
|
|
|
|
import { createAssetUploadTransport } from "../../../src/features/tech-log/adapters/http/asset-upload-transport.ts";
|
|
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
|
|
|
const BASE = "http://api.test";
|
|
const server = setupServer();
|
|
|
|
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
|
afterEach(() => server.resetHandlers());
|
|
afterAll(() => server.close());
|
|
|
|
const transport = () =>
|
|
createAssetUploadTransport({ baseUrl: `${BASE}/`, timeoutMs: 10_000 });
|
|
|
|
const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" });
|
|
|
|
test("posts multipart form data with the supplied headers", async () => {
|
|
let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null;
|
|
|
|
server.use(
|
|
http.post(`${BASE}/api/v1/studio/assets`, async ({ request }) => {
|
|
const form = await request.formData();
|
|
seen = {
|
|
kind: form.get("kind"),
|
|
alt: form.get("altText"),
|
|
csrf: request.headers.get("X-CSRF-TOKEN"),
|
|
key: request.headers.get("Idempotency-Key"),
|
|
};
|
|
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 });
|
|
}),
|
|
);
|
|
|
|
const asset = await transport().upload(
|
|
{ file: svg(), kind: "DIAGRAM", altText: "경계 다이어그램", decorative: false },
|
|
{ "X-CSRF-TOKEN": "csrf", "Idempotency-Key": "up-1" },
|
|
);
|
|
|
|
assert.equal((asset as { id: string }).id, "a");
|
|
assert.equal(seen!.kind, "DIAGRAM");
|
|
assert.equal(seen!.alt, "경계 다이어그램");
|
|
assert.equal(seen!.csrf, "csrf");
|
|
assert.equal(seen!.key, "up-1");
|
|
});
|
|
|
|
test("does not set content-type itself so the boundary survives", async () => {
|
|
let contentType: string | null = "unset";
|
|
server.use(
|
|
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
|
|
contentType = request.headers.get("content-type");
|
|
return HttpResponse.json({ id: "a" }, { status: 201 });
|
|
}),
|
|
);
|
|
|
|
await transport().upload({ file: svg(), kind: "IMAGE" }, {});
|
|
|
|
assert.ok(contentType?.startsWith("multipart/form-data; boundary="));
|
|
});
|
|
|
|
test("maps 413 onto PAYLOAD_TOO_LARGE", async () => {
|
|
server.use(
|
|
http.post(`${BASE}/api/v1/studio/assets`, () =>
|
|
HttpResponse.json(
|
|
{
|
|
type: "https://techlog.local/problems/payload-too-large",
|
|
title: "PAYLOAD_TOO_LARGE",
|
|
status: 413,
|
|
detail: "파일이 너무 큽니다.",
|
|
code: "PAYLOAD_TOO_LARGE",
|
|
},
|
|
{ status: 413, headers: { "content-type": "application/problem+json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
await assert.rejects(
|
|
transport().upload({ file: svg(), kind: "IMAGE" }, {}),
|
|
(error: unknown) => {
|
|
assert.ok(isStudioGatewayError(error));
|
|
assert.equal(error.code, "PAYLOAD_TOO_LARGE");
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("maps 415 onto UNSUPPORTED_MEDIA_TYPE", async () => {
|
|
server.use(
|
|
http.post(`${BASE}/api/v1/studio/assets`, () =>
|
|
HttpResponse.json(
|
|
{
|
|
type: "https://techlog.local/problems/unsupported-media-type",
|
|
title: "UNSUPPORTED_MEDIA_TYPE",
|
|
status: 415,
|
|
detail: "지원하지 않는 형식입니다.",
|
|
code: "UNSUPPORTED_MEDIA_TYPE",
|
|
},
|
|
{ status: 415, headers: { "content-type": "application/problem+json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
|
|
assert.ok(isStudioGatewayError(error));
|
|
assert.equal(error.code, "UNSUPPORTED_MEDIA_TYPE");
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test("maps a network failure onto STUDIO_UNAVAILABLE", async () => {
|
|
server.use(http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.error()));
|
|
|
|
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
|
|
assert.ok(isStudioGatewayError(error));
|
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
|
return true;
|
|
});
|
|
});
|