feat: implement the TechLog Studio HTTP gateway
Adds createHttpStudioGateway, the adapter the Studio UI calls in production. It drives every mutation through mutationIntent() (defineMutationIntent/defineIdempotencyKey, not createBrowserMutationIntentFactory, so the caller-supplied idempotency key is preserved rather than regenerated) and leaves header construction to the executor/credential collaborator entirely - the gateway never sees or sets Idempotency-Key or x-csrf-token itself. Also moves stable-stringify.ts out of adapters/mock/ so the mock and http adapters share one pure function without production code depending on the mock directory, and fixes the resulting import in cursor.ts, mock-studio-gateway.ts, and mock-studio-gateway.test.ts. Adds MSW handlers (tests/mocks/handlers/tech-log-studio.ts) that wrap the reference mock-studio-gateway implementation, and a contract test suite that drives the gateway through a thin fetch-based executor built from the contract's own projectRequest, proving canonical path/body/header construction without assembling the full platform transport. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9313018ef5
commit
1d01a522de
@@ -0,0 +1,199 @@
|
||||
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<string, string> = {};
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user