studio-v1.yaml v3.0.0(ADR-006)에 맞춰 계약을 재생성하고, 성공은
{success,data,meta}, 실패는 {success,error,meta} 봉투를 전송 경계에서
언랩하는 envelopeData/envelopeError validator를 도입한다. 앱·도메인
계층은 기존과 같은 payload/ProblemDetails 모양을 계속 받고,
StudioGateway 포트 시그니처는 무변경이다.
- tech-log-studio-contract-contribution.ts: envelopeData/envelopeError
도입, 18개 operation의 outputValidator를 passthrough에서 envelopeData로
교체
- studio-error-mapping.ts: 봉투 오류의 status(항상 0)를
outcome.metadata.status로 덮는다. SafeResponseMetadata.status가
실제 필드명이며(httpStatus 아님) PROBLEM outcome에서 필수 필드다
- contract.ts: 삭제된 ProblemDetails 생성 스키마를 손으로 유지 — 앱
계층·mock 게이트웨이가 그 모양을 계속 소비한다
- asset-upload-transport.ts: multipart 업로드는 일반 계약 런타임을
거치지 않는 별도 seam이지만 같은 wire 봉투를 쓴다 — envelopeData/
envelopeError를 재사용해 이 경로도 언랩한다 (브리프 파일 목록 밖의
발견, report에 기록)
- 테스트: 신규 studio-envelope-unwrap.test.ts(TDD) + 봉투 뼈대를 직접
만드는 기존 테스트(asset-upload-transport, studio-csrf-composition,
contract-generation)를 봉투 형태로 갱신
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
279 lines
9.4 KiB
TypeScript
279 lines
9.4 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 { 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(["<svg/>"], "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("<html>not json</html>", { 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);
|
|
});
|