Files
DongHyeonkaandClaude Opus 5 7424ed4594 fix: forward every documented Studio query filter and pin canonicalInputIdentity
Two review Minor findings against the brief itself, both closed:

- tests/mocks/handlers/tech-log-studio.ts silently dropped documented
  query filters instead of mirroring the mock gateway it wraps:
  listStudioDocuments forwarded only q/limit (dropping kind,
  publicationStatus, nextAction, projectId, sort, cursor),
  listStudioCatalog forwarded only type (dropping q/cursor/limit), and
  listStudioPublications ignored all four of its parameters outright.
  Added a shared queryParams() helper and forward every field each
  operation's projectRequest actually emits, plus a regression test
  that narrows the fixture set by kind through the real HTTP gateway
  (confirmed it fails without the fix).

- canonicalInputIdentity (on the idempotency-safety path, reused by
  Task 6) had no test. Added three tests against mutationIntent(): a
  large Korean payload stays within the byte bound, the same input
  retried twice yields an identical identity, and a mid-codepoint
  truncation cut leaves no replacement character (confirmed the third
  fails without the strip). The known collision limitation of any
  bounded-length identity scheme is documented in the test file rather
  than solved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:43:51 +09:00

277 lines
11 KiB
TypeScript

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<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");
});
/**
* 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(""));
});