merge: feature/studio-response-envelope — Studio 응답 봉투를 전송 경계에서 언랩
계약 v3.0.0(ADR-006)에 맞춰 재생성하고, envelopeData/envelopeError가
{success,data,meta}를 언랩한다. 앱·도메인 계층과 StudioGateway 포트는 무변경 —
언랩이 전송 경계에서 끝난다.
검증: check:tech-log-contract / test:tech-log 337건 / tsc --noEmit 전부 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,14 @@ const transport = () =>
|
||||
|
||||
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;
|
||||
|
||||
@@ -32,7 +40,7 @@ test("posts multipart form data with the supplied headers", async () => {
|
||||
csrf: request.headers.get("X-CSRF-TOKEN"),
|
||||
key: request.headers.get("Idempotency-Key"),
|
||||
};
|
||||
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 });
|
||||
return HttpResponse.json(dataEnvelope({ id: "a", managementStatus: "READY" }), { status: 201 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -53,7 +61,7 @@ test("does not set content-type itself so the boundary survives", async () => {
|
||||
server.use(
|
||||
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
|
||||
contentType = request.headers.get("content-type");
|
||||
return HttpResponse.json({ id: "a" }, { status: 201 });
|
||||
return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -66,14 +74,13 @@ 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: "파일이 너무 큽니다.",
|
||||
errorEnvelope({
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
},
|
||||
{ status: 413, headers: { "content-type": "application/problem+json" } },
|
||||
category: "VALIDATION",
|
||||
message: "파일이 너무 큽니다.",
|
||||
retryable: false,
|
||||
}),
|
||||
{ status: 413 },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -92,14 +99,13 @@ 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: "지원하지 않는 형식입니다.",
|
||||
errorEnvelope({
|
||||
code: "UNSUPPORTED_MEDIA_TYPE",
|
||||
},
|
||||
{ status: 415, headers: { "content-type": "application/problem+json" } },
|
||||
category: "VALIDATION",
|
||||
message: "지원하지 않는 형식입니다.",
|
||||
retryable: false,
|
||||
}),
|
||||
{ status: 415 },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -128,14 +134,15 @@ test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead
|
||||
server.use(
|
||||
http.post(`${BASE}/api/v1/studio/assets`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
type: "https://techlog.local/problems/teapot",
|
||||
title: "IM_A_TEAPOT",
|
||||
status: 418,
|
||||
detail: "이 서버는 커피를 내릴 수 없습니다.",
|
||||
// 봉투 뼈대는 정상이지만 `code`가 계약 밖이다 — envelope 검증은
|
||||
// 통과하되(구조는 맞음) `apiErrorSchema`의 code enum에서 걸린다.
|
||||
errorEnvelope({
|
||||
code: "IM_A_TEAPOT",
|
||||
},
|
||||
{ status: 418, headers: { "content-type": "application/problem+json" } },
|
||||
category: "INTERNAL",
|
||||
message: "이 서버는 커피를 내릴 수 없습니다.",
|
||||
retryable: false,
|
||||
}),
|
||||
{ status: 418 },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -187,7 +194,7 @@ 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({ id: "a" }, { status: 201 });
|
||||
return HttpResponse.json(dataEnvelope({ id: "a" }), { status: 201 });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
|
||||
|
||||
test("canonical source records the pinned revision and version", () => {
|
||||
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
|
||||
assert.equal(canonicalSource.version, "2.0.0");
|
||||
assert.equal(canonicalSource.version, "3.0.0");
|
||||
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
||||
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
||||
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { test } from "vitest";
|
||||
|
||||
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||
import type {
|
||||
ValidationErrorDetails,
|
||||
VersionConflictDetails,
|
||||
WorkingCopy,
|
||||
WorkingCopyInput,
|
||||
} from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
@@ -71,8 +73,11 @@ function isProblem(status: number, code: string, paths?: string[]) {
|
||||
assert.equal(error.status, status);
|
||||
assert.equal(error.code, code);
|
||||
if (paths) {
|
||||
// wire와 같은 자리: `REQUEST_VALIDATION_FAILED`의 detail은
|
||||
// `ValidationErrorDetails`로 `details`에 있다 (Task 3 fix round 1).
|
||||
const details = error.problem.details as ValidationErrorDetails;
|
||||
assert.deepEqual(
|
||||
error.problem.fieldErrors?.map(({ path }) => path),
|
||||
details.fieldErrors.map(({ path }) => path),
|
||||
paths,
|
||||
);
|
||||
}
|
||||
@@ -203,9 +208,8 @@ test("runtime OpenAPI validation enforces discriminators, extras, dates, UUIDs a
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(isStudioGatewayError(error));
|
||||
assert.ok(
|
||||
error.problem.fieldErrors?.some(({ path }) => path === pointer),
|
||||
);
|
||||
const details = error.problem.details as ValidationErrorDetails;
|
||||
assert.ok(details.fieldErrors.some(({ path }) => path === pointer));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
@@ -575,12 +579,10 @@ test("publication history, immutable snapshots, conflict fixtures and 404 bodies
|
||||
(error) => {
|
||||
assert.ok(isStudioGatewayError(error));
|
||||
assert.equal(error.code, "VERSION_CONFLICT");
|
||||
assert.deepEqual(error.problem.conflictingFields, [
|
||||
"/title",
|
||||
"/summary",
|
||||
]);
|
||||
const details = error.problem.details as VersionConflictDetails;
|
||||
assert.deepEqual(details.conflictingFields, ["/title", "/summary"]);
|
||||
assert.equal(
|
||||
error.problem.latestDocument?.document.version,
|
||||
details.latestDocument.document.version,
|
||||
conflict.document.version + 1,
|
||||
);
|
||||
return true;
|
||||
|
||||
@@ -43,6 +43,16 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
// wire format은 봉투다 (ADR-006) — 이 파일은 실제 platform 계약 런타임
|
||||
// (`createContractHttpExecutor`)을 조립하므로 `outputValidator`
|
||||
// (`envelopeData`)가 그대로 걸린다. 봉투가 아닌 본문은 SUCCESS_SCHEMA_INVALID로
|
||||
// 거절된다.
|
||||
const dataEnvelope = (data: unknown) => ({
|
||||
success: true,
|
||||
data,
|
||||
meta: { requestId: "r", traceId: "t", correlationId: null, page: null },
|
||||
});
|
||||
|
||||
function scopeSnapshot() {
|
||||
return Object.freeze({
|
||||
generation: 1,
|
||||
@@ -152,13 +162,15 @@ test(
|
||||
server.use(
|
||||
http.get(`${BASE}/api/v1/studio/session`, () => {
|
||||
sessionCalls += 1;
|
||||
return HttpResponse.json({
|
||||
authenticated: true,
|
||||
displayName: "테스터",
|
||||
roles: ["editor"],
|
||||
csrfToken: "csrf-token-1",
|
||||
csrfHeaderName: "X-CSRF-TOKEN",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
dataEnvelope({
|
||||
authenticated: true,
|
||||
displayName: "테스터",
|
||||
roles: ["editor"],
|
||||
csrfToken: "csrf-token-1",
|
||||
csrfHeaderName: "X-CSRF-TOKEN",
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -166,10 +178,7 @@ test(
|
||||
server.use(
|
||||
http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => {
|
||||
jsonRequestHeader = request.headers.get("x-csrf-token");
|
||||
return HttpResponse.json({
|
||||
documentTotals: {},
|
||||
workflowSections: [],
|
||||
});
|
||||
return HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -177,7 +186,10 @@ test(
|
||||
server.use(
|
||||
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
|
||||
uploadRequestHeader = request.headers.get("X-CSRF-TOKEN");
|
||||
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 });
|
||||
return HttpResponse.json(
|
||||
dataEnvelope({ id: "a", managementStatus: "READY" }),
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -232,13 +244,15 @@ test(
|
||||
server.use(
|
||||
http.get(`${BASE}/api/v1/studio/session`, () => {
|
||||
sessionCalls += 1;
|
||||
return HttpResponse.json({
|
||||
authenticated: true,
|
||||
displayName: "테스터",
|
||||
roles: ["editor"],
|
||||
csrfToken: `csrf-token-${sessionCalls}`,
|
||||
csrfHeaderName: "X-CSRF-TOKEN",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
dataEnvelope({
|
||||
authenticated: true,
|
||||
displayName: "테스터",
|
||||
roles: ["editor"],
|
||||
csrfToken: `csrf-token-${sessionCalls}`,
|
||||
csrfHeaderName: "X-CSRF-TOKEN",
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -250,7 +264,7 @@ test(
|
||||
// Second call: succeeds with whatever token is presented.
|
||||
return dashboardHeaders.length === 1
|
||||
? new HttpResponse(null, { status: 403 })
|
||||
: HttpResponse.json({ documentTotals: {}, workflowSections: [] });
|
||||
: HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -294,13 +308,15 @@ test(
|
||||
server.use(
|
||||
http.get(`${BASE}/api/v1/studio/session`, () => {
|
||||
sessionCalls += 1;
|
||||
return HttpResponse.json({
|
||||
authenticated: true,
|
||||
displayName: "테스터",
|
||||
roles: ["editor"],
|
||||
csrfToken: `csrf-token-${sessionCalls}`,
|
||||
csrfHeaderName: "X-CSRF-TOKEN",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
dataEnvelope({
|
||||
authenticated: true,
|
||||
displayName: "테스터",
|
||||
roles: ["editor"],
|
||||
csrfToken: `csrf-token-${sessionCalls}`,
|
||||
csrfHeaderName: "X-CSRF-TOKEN",
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -308,7 +324,7 @@ test(
|
||||
server.use(
|
||||
http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => {
|
||||
dashboardHeaders.push(request.headers.get("x-csrf-token"));
|
||||
return HttpResponse.json({ documentTotals: {}, workflowSections: [] });
|
||||
return HttpResponse.json(dataEnvelope({ documentTotals: {}, workflowSections: [] }));
|
||||
}),
|
||||
);
|
||||
server.use(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { envelopeData, envelopeError } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||||
|
||||
describe("studio 봉투 언랩", () => {
|
||||
it("성공 봉투에서 data를 꺼낸다", () => {
|
||||
const result = envelopeData("getStudioSessionOutput").safeParse({
|
||||
success: true,
|
||||
data: { authenticated: true, displayName: "d", roles: [], csrfToken: "t", csrfHeaderName: "X-CSRF-TOKEN" },
|
||||
meta: { requestId: "r", traceId: "t", correlationId: null, page: null },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data).toMatchObject({ displayName: "d" });
|
||||
});
|
||||
|
||||
it("봉투가 아닌 본문을 거절한다", () => {
|
||||
const result = envelopeData("getStudioSessionOutput").safeParse({ displayName: "d" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("오류 봉투를 ProblemDetails 형태로 옮긴다", () => {
|
||||
const result = envelopeError().safeParse({
|
||||
success: false,
|
||||
error: { code: "VERSION_CONFLICT", category: "CONFLICT", message: "conflict", retryable: false, details: null },
|
||||
meta: { requestId: "r", traceId: "tr", correlationId: null, page: null },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.code).toBe("VERSION_CONFLICT");
|
||||
expect(result.data.status).toBe(0);
|
||||
expect(result.data.title).toBe("VERSION_CONFLICT");
|
||||
}
|
||||
});
|
||||
|
||||
it("계약 밖 코드를 거절한다", () => {
|
||||
const result = envelopeError().safeParse({
|
||||
success: false,
|
||||
error: { code: "NOT_A_STUDIO_CODE", category: "INTERNAL", message: "x", retryable: false, details: null },
|
||||
meta: { requestId: "r", traceId: "tr", correlationId: null, page: null },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user