import assert from "node:assert/strict"; import { afterAll, afterEach, beforeAll, test } from "vitest"; import { setupServer } from "msw/node"; import { createHttpStudioGateway } from "../../../src/features/tech-log/adapters/http/http-studio-gateway.ts"; import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; import { createTechLogStudioHandlers } from "../../mocks/handlers/tech-log-studio.ts"; import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; const { handlers } = createTechLogStudioHandlers(); const server = setupServer(...handlers); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); const BASE = "http://api.test"; /** * 전송 계층을 얇게 세운다. 이 테스트가 증명하는 것은 gateway가 canonical * 경로/본문/헤더를 정확히 만들고 응답을 포트 계약으로 되돌린다는 것이다. * 플랫폼 계약 런타임을 실제로 조립하지 않고, 계약 기여의 `projectRequest`로 * 요청을 만들어 `fetch`로 보낸다 — 이 테스트의 대상은 gateway이지 플랫폼 * 전송이 아니다. */ async function realDependencies() { const byId = new Map( TECH_LOG_STUDIO_CONTRIBUTION.http.map((entry) => [entry.contract.operationId, entry]), ); const csrf = createCsrfTokenProvider({ async execute() { const response = await fetch(`${BASE}/api/v1/studio/session`); const body = (await response.json()) as { csrfToken: string; csrfHeaderName: string; }; return { csrfToken: body.csrfToken, csrfHeaderName: body.csrfHeaderName }; }, }); const operations = { async execute( operationId: string, input: unknown, context: { signal?: AbortSignal; intent?: unknown }, ) { const entry = byId.get(operationId); if (!entry) throw new Error(`unregistered operation: ${operationId}`); const { contract } = entry; const projected = contract.projectRequest(input as never); let path = contract.pathTemplate; for (const [name, value] of Object.entries(projected.pathValues)) { path = path.replace(`{${name}}`, encodeURIComponent(value)); } const url = new URL(`${BASE}${path}`); for (const [name, value] of projected.queryEntries) { url.searchParams.append(name, value); } // 실행기가 헤더를 만드는 두 경로를 그대로 재현한다: intent → Idempotency-Key, // credential collaborator → x-csrf-token. const headers: Record = {}; if (contract.retrySemantics === "KEYED") { const intent = (context as { intent?: { idempotencyKey?: string } }).intent; if (intent?.idempotencyKey) headers["Idempotency-Key"] = intent.idempotencyKey; headers["X-CSRF-TOKEN"] = await csrf.token(); } if (contract.requestBody === "JSON") headers["content-type"] = "application/json"; const response = await fetch(url, { method: contract.method, headers, ...(contract.requestBody === "JSON" ? { body: JSON.stringify(projected.body) } : {}), ...(context.signal ? { signal: context.signal } : {}), }); if (contract.acceptedStatuses.includes(response.status)) { const value = contract.responseBody === "NONE" ? null : await response.json(); return { kind: "SUCCESS" as const, value, effect: "APPLIED_CONFIRMED" as const }; } return { kind: "PROBLEM" as const, problem: await response.json(), metadata: { status: response.status }, effect: "NOT_APPLIED" as const, }; }, }; return { operations } as never; } /** * `CreateDocumentInput` IS the `WorkingCopyInput` (no `{ document }` wrapper) * per the port signature, but `CREATE_STUDIO_DOCUMENT`'s `projectRequest` * reads `value.document` off its operation input to build the request body. * The gateway must bridge that mismatch by wrapping the port's raw input as * `{ document: input }` before calling the operation — otherwise the wire * body would be `undefined` instead of the document. */ test("wraps the raw port input into the operation's { document } shape", async () => { const gateway = createHttpStudioGateway(await realDependencies()); const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); const { id, version, updatedAt, ...input } = before.document; void id; void version; void updatedAt; const created = await gateway.createDocument( { ...input, title: "HTTP 경로로 생성" }, { idempotencyKey: "create-1" }, ); assert.equal(created.title, "HTTP 경로로 생성"); assert.equal(created.version, 1); assert.equal(typeof created.id, "string"); }); test("reads the dashboard through the canonical path", async () => { const gateway = createHttpStudioGateway(await realDependencies()); const dashboard = await gateway.getDashboard(); assert.equal(typeof dashboard.totals.documents, "number"); assert.ok(dashboard.totals.documents > 0); }); test("saves with expectedVersion and returns the new version", async () => { const gateway = createHttpStudioGateway(await realDependencies()); const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); const { id, version, updatedAt, ...input } = before.document; void id; void updatedAt; const saved = await gateway.saveDocument( before.document.id, { expectedVersion: version, document: { ...input, title: "HTTP 경로로 저장" } }, { idempotencyKey: "save-1" }, ); assert.equal(saved.document.title, "HTTP 경로로 저장"); assert.equal(saved.document.version, version + 1); }); test("surfaces VERSION_CONFLICT as the port error, not a transport error", async () => { const gateway = createHttpStudioGateway(await realDependencies()); const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); const { id, version, updatedAt, ...input } = before.document; void id; void updatedAt; await assert.rejects( gateway.saveDocument( before.document.id, { expectedVersion: version + 99, document: input }, { idempotencyKey: "conflict-1" }, ), (error: unknown) => { assert.ok(isStudioGatewayError(error)); assert.equal(error.code, "VERSION_CONFLICT"); assert.equal(error.status, 409); return true; }, ); }); test("sends the CSRF header and an Idempotency-Key on every mutation", async () => { const captured: { csrf: string | null; key: string | null }[] = []; server.use( ...createTechLogStudioHandlers().handlers, ); server.events.on("request:start", ({ request }) => { if (request.method === "GET") return; captured.push({ csrf: request.headers.get("X-CSRF-TOKEN"), key: request.headers.get("Idempotency-Key"), }); }); const gateway = createHttpStudioGateway(await realDependencies()); const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); const { id, version, updatedAt, ...input } = before.document; void id; void updatedAt; await gateway.saveDocument( before.document.id, { expectedVersion: version, document: input }, { idempotencyKey: "csrf-1" }, ); assert.equal(captured.length, 1); assert.equal(captured[0]!.csrf, "csrf-test-token"); assert.equal(captured[0]!.key, "csrf-1"); });