import assert from "node:assert/strict"; import { afterAll, afterEach, beforeAll, test } from "vitest"; import { setupServer } from "msw/node"; import { createHttpStudioGateway, mutationIntent, } 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"; import { MUTATION_INTENT_BOUNDS } from "../../../src/contracts/mutation-intent.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"); }); /** * The MSW handlers wrap the reference mock gateway and must forward every * query parameter `LIST_STUDIO_DOCUMENTS`'s `projectRequest` puts on the * wire. A dropped parameter would silently return the unfiltered set — the * fixtures have 7 documents total and exactly 1 QUESTION * (`FIXTURE_IDS.edgeTokenQuestion`), so a `kind` filter that actually * reaches the mock gateway must narrow the page from 7 to 1. */ test("forwards listDocuments query filters instead of dropping them", async () => { const gateway = createHttpStudioGateway(await realDependencies()); const all = await gateway.listDocuments({}); const filtered = await gateway.listDocuments({ kind: "QUESTION" }); assert.ok(all.items.length > 1); assert.equal(filtered.items.length, 1); assert.ok(filtered.items.length < all.items.length); assert.ok(filtered.items.every((item) => item.kind === "QUESTION")); }); 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"); }); /** * `canonicalInputIdentity` sits on the idempotency-safety path and Task 6 * reuses `mutationIntent()`, so its byte-truncation behaviour is pinned here * rather than left implicit. These assert observable properties only — none * of them reimplements the truncation to predict an exact string. * * A large Korean payload is deliberately chosen: each Hangul syllable is 3 * UTF-8 bytes, so a character-count-based truncation (the brief's original * approach) would pass a 16,000-*character* check while still exceeding the * 16,384-*byte* bound `defineMutationIntent` enforces, and would throw. 60,000 * bytes (20,000 repetitions) comfortably exceeds the bound. * * Known/accepted limitation, not fixed here: two different very large inputs * that share a long common prefix can truncate to the same identity and * collide. This is inherent to any bounded-length identity scheme, not * specific to this truncation strategy, so it is left as-is. */ const BIG_KOREAN_PAYLOAD = { document: { bodyMarkdown: "가".repeat(20_000) } }; test("canonicalInputIdentity stays within the byte bound for a large Korean payload", () => { const intent = mutationIntent( "saveStudioDocument", "byte-bound-1", BIG_KOREAN_PAYLOAD, ); const byteLength = new TextEncoder().encode( intent.canonicalInputIdentity, ).byteLength; assert.ok(byteLength <= MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes); }); test("the same large input produces the identical canonicalInputIdentity on retry", () => { const first = mutationIntent( "saveStudioDocument", "retry-attempt-1", BIG_KOREAN_PAYLOAD, ); const second = mutationIntent( "saveStudioDocument", "retry-attempt-2", { document: { bodyMarkdown: "가".repeat(20_000) } }, ); assert.equal(first.canonicalInputIdentity, second.canonicalInputIdentity); }); test("a mid-codepoint truncation cut leaves no replacement character behind", () => { const intent = mutationIntent( "saveStudioDocument", "no-replacement-1", BIG_KOREAN_PAYLOAD, ); assert.ok(!intent.canonicalInputIdentity.includes("�")); });