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 { createHttpStudioAssetGateway } from "../../../src/features/tech-log/adapters/http/http-studio-asset-gateway.ts"; import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.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([""], "b.svg", { type: "image/svg+xml" }); // wire format은 봉투다 (ADR-006) — `uploadStudioAsset`도 다른 operation과 // 같은 `AssetEnvelope`/`ErrorEnvelope`를 쓴다. const META = { requestId: "r", traceId: "t", correlationId: null, page: null }; const dataEnvelope = (data: unknown) => ({ success: true, data, meta: META }); const errorEnvelope = ( error: Readonly<{ code: string; category: string; message: string; retryable: boolean }>, ) => ({ success: false, error, meta: META }); 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(dataEnvelope({ 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(dataEnvelope({ 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( errorEnvelope({ code: "PAYLOAD_TOO_LARGE", category: "VALIDATION", message: "파일이 너무 큽니다.", retryable: false, }), { status: 413 }, ), ), ); 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( errorEnvelope({ code: "UNSUPPORTED_MEDIA_TYPE", category: "VALIDATION", message: "지원하지 않는 형식입니다.", retryable: false, }), { status: 415 }, ), ), ); 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; }); }); // M2 (fix round 1). The two "don't invent a domain error" fallback branches // had no test coverage — the code was already correct, but nothing pinned it. test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead of inventing one", async () => { server.use( http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.json( // 봉투 뼈대는 정상이지만 `code`가 계약 밖이다 — envelope 검증은 // 통과하되(구조는 맞음) `apiErrorSchema`의 code enum에서 걸린다. errorEnvelope({ code: "IM_A_TEAPOT", category: "INTERNAL", message: "이 서버는 커피를 내릴 수 없습니다.", retryable: false, }), { status: 418 }, ), ), ); await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { assert.ok(isStudioGatewayError(error)); assert.equal(error.code, "STUDIO_UNAVAILABLE"); return true; }); }); test("falls back to STUDIO_UNAVAILABLE when the error body cannot be parsed as JSON", async () => { server.use( http.post( `${BASE}/api/v1/studio/assets`, () => new HttpResponse("not json", { status: 500 }), ), ); await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { assert.ok(isStudioGatewayError(error)); assert.equal(error.code, "STUDIO_UNAVAILABLE"); return true; }); }); // M1 (fix round 1). A malformed 201 body must not throw a raw SyntaxError out // of a port whose contract is StudioGatewayError. test("falls back to STUDIO_UNAVAILABLE when a success body cannot be parsed as JSON", async () => { server.use( http.post( `${BASE}/api/v1/studio/assets`, () => new HttpResponse("not json", { status: 201 }), ), ); await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { assert.ok(isStudioGatewayError(error)); assert.equal(error.code, "STUDIO_UNAVAILABLE"); return true; }); }); // M3 (fix round 1). A cancelled/deadline-exceeded upload must read the same // as the JSON path's CANCELLED mapping: not retryable. test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async () => { server.use( http.post(`${BASE}/api/v1/studio/assets`, async () => { await new Promise((resolve) => setTimeout(resolve, 50)); return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 }); }), ); const controller = new AbortController(); const pending = transport().upload( { file: svg(), kind: "IMAGE" }, {}, { signal: controller.signal }, ); controller.abort(); await assert.rejects(pending, (error: unknown) => { assert.ok(isStudioGatewayError(error)); assert.equal(error.code, "STUDIO_UNAVAILABLE"); assert.equal(error.status, 499); assert.equal(error.retryable, false); return true; }); }); // Fix round 2, item 2. The uncontracted-status fallback previously hardcoded // status 503, discarding the real HTTP status the server sent. // http-studio-asset-gateway.ts's uploadAsset() only invalidates the cached // CSRF token when `error.status === 401 || error.status === 403` — an // uncontracted 401/403 body silently became "not 401/403" (503) and never // triggered invalidation. test("passes the real HTTP status through for an uncontracted status instead of hardcoding 503", async () => { server.use( http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.json({ message: "token rejected" }, { status: 401 }), ), ); await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { assert.ok(isStudioGatewayError(error)); assert.equal(error.code, "STUDIO_UNAVAILABLE"); assert.equal(error.status, 401); return true; }); }); test("an uncontracted 401 body still invalidates the cached CSRF token end to end", async () => { server.use( http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.json({ message: "token rejected" }, { status: 401 }), ), ); let executions = 0; const csrf = createCsrfTokenProvider({ async execute() { executions += 1; return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" }; }, }); const gateway = createHttpStudioAssetGateway({ operations: { async execute() { throw new Error("not used by this test"); }, }, csrf, upload: transport(), }); await assert.rejects( gateway.uploadAsset( { file: svg(), kind: "IMAGE" }, { idempotencyKey: "up-1" }, ), ); // The upload itself already consumed one fetch. assert.equal(executions, 1); // A fresh token() call after the failure must re-fetch, not replay the // (now-rejected) cached value — proving the gateway actually saw status // 401, not the transport's old default of 503. await csrf.token(); assert.equal(executions, 2); });