Files
tech-log-frontend/tests/features/tech-log/mock-studio-gateway.test.ts
T
DongHyeonkaandClaude Opus 5 d23a18f659 fix: ProblemDetails를 전송 계층이 실제로 만드는 모양 하나로 합친다
Task 3 리뷰 finding(Important): contract.ts의 손수 유지되는 ProblemDetails가
tech-log-studio-contract-contribution.ts의 envelopeError()가 실제로
반환하는 모양과 별도로 정의돼 있었다. envelopeError()는 ApiError의
type/title/status/detail/code/retryable/category/details만 채우므로
ProblemDetails가 갖고 있던 옛 평면 필드(instance/traceId/fieldErrors/
latestDocument/latestPublication/conflictingFields)는 production에서
항상 undefined였다 — mock만 채워서 mock이 아무것도 검증하지 못하는
상태였다.

- contract.ts: ProblemDetails에서 옛 평면 필드를 제거하고 details를
  wire와 같은 union 타입(ValidationErrorDetails | VersionConflictDetails
  | PublicationConflictDetails | null)으로 정확히 준다. 세 타입을 이제
  개별 export한다.
- tech-log-studio-contract-contribution.ts: envelopeError()가
  contract.ts의 ProblemDetails를 그대로 반환 타입으로 쓴다(로컬
  StudioProblemShape 제거) — 이제 한 곳에만 정의가 있다.
- mock-studio-gateway.ts / cursor.ts: fieldErrors/latestDocument/
  conflictingFields/latestPublication을 wire와 같은 자리(details 안)로
  옮긴다.
- mock-studio-gateway.test.ts: 위 이동에 맞춰 details를 캐스트로 좁혀
  읽도록 갱신.

retryable은 optional로 유지했다 — 여러 테스트가 생략하고 만들며, 이번
finding과 무관해 required로 좁히면 관련 없는 파일들이 깨진다.

리뷰가 보류한 2건(asset-upload-transport.ts의 CODES.has 중복 검사,
apiErrorSchema.category가 enum이 아닌 것)은 손대지 않았다.

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

694 lines
22 KiB
TypeScript

import assert from "node:assert/strict";
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";
import {
cursorBinding,
decodeCursor,
encodeCursor,
} from "../../../src/features/tech-log/adapters/mock/cursor.ts";
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
import {
createMockStudioGateway,
createMockStudioState,
} from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
import { stableStringify } from "../../../src/features/tech-log/adapters/stable-stringify.ts";
const NOW = "2026-08-14T01:00:00.000Z";
function ids() {
let value = 1000;
return {
next: () =>
`00000000-0000-4000-8000-${String(value++).padStart(12, "0")}`,
};
}
function gatewayAt(now = NOW) {
return createMockStudioGateway({
clock: { now: () => new Date(now) },
idGenerator: ids(),
dependencyRevision: { current: () => "catalog-2026-08-14" },
});
}
function inputOf(document: WorkingCopy): WorkingCopyInput {
const { id, version, updatedAt, ...input } = document;
void id;
void version;
void updatedAt;
return input;
}
function emptyCase(
overrides: Partial<Extract<WorkingCopyInput, { kind: "CASE" }>> = {},
) {
return {
kind: "CASE",
title: "",
slug: "",
summary: "",
topicId: null,
projectId: null,
relations: [],
problem: "",
conclusion: "",
environment: "",
reproduction: "",
lastVerifiedOn: null,
bodyMarkdown: "",
...overrides,
} satisfies Extract<WorkingCopyInput, { kind: "CASE" }>;
}
function isProblem(status: number, code: string, paths?: string[]) {
return (error: unknown) => {
assert.ok(isStudioGatewayError(error));
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(
details.fieldErrors.map(({ path }) => path),
paths,
);
}
return true;
};
}
test("stable stringify canonicalizes nested object keys and cursors encode the canonical binding deterministically", () => {
assert.equal(
stableStringify({
z: undefined,
b: { d: 2, c: 1 },
a: [{ b: 2, a: 1 }, undefined],
}),
'{"a":[{"a":1,"b":2},null],"b":{"c":1,"d":2}}',
);
const binding = cursorBinding({ sort: "TITLE_ASC", q: "" });
assert.equal(binding, '{"q":"","sort":"TITLE_ASC"}');
const payload = {
binding,
lastValue: "A",
lastId: "11111111-1111-4111-8111-111111111111",
};
const cursor = encodeCursor(payload);
assert.equal(
cursor,
"eyJiaW5kaW5nIjoie1wicVwiOlwiXCIsXCJzb3J0XCI6XCJUSVRMRV9BU0NcIn0iLCJsYXN0SWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJsYXN0VmFsdWUiOiJBIn0",
);
assert.deepEqual(decodeCursor(cursor, binding), payload);
});
test("save, replay, conflict, clone isolation, deterministic IDs, and abort follow the gateway contract", async () => {
const gateway = gatewayAt();
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
const after = await gateway.saveDocument(
before.document.id,
{
expectedVersion: before.document.version,
document: { ...inputOf(before.document), title: "수정된 제목" },
},
{ idempotencyKey: "save-1" },
);
assert.equal(after.document.version, before.document.version + 1);
assert.equal(after.currentValidation, null);
assert.equal(
after.latestPreview?.previewId,
before.latestPreview?.previewId,
);
after.document.title = "consumer mutation";
assert.notEqual(
(await gateway.getDocument(before.document.id)).document.title,
after.document.title,
);
const draft = emptyCase();
const first = await gateway.createDocument(draft, {
idempotencyKey: "create-1",
});
assert.equal(first.id, "00000000-0000-4000-8000-000000001000");
assert.deepEqual(
await gateway.createDocument(draft, { idempotencyKey: "create-1" }),
first,
);
await assert.rejects(
gateway.createDocument(
{ ...draft, title: "different" },
{ idempotencyKey: "create-1" },
),
isProblem(409, "IDEMPOTENCY_KEY_REUSED"),
);
const controller = new AbortController();
controller.abort();
await assert.rejects(gateway.getDashboard({ signal: controller.signal }), {
name: "AbortError",
});
const post = new AbortController();
const pending = gateway.getDashboard({ signal: post.signal });
post.abort();
await assert.rejects(pending, { name: "AbortError" });
const a = createMockStudioState();
const b = createMockStudioState();
a.documents.clear();
assert.ok(b.documents.size > 0);
});
test("runtime OpenAPI validation enforces discriminators, extras, dates, UUIDs and Unicode bounds", async () => {
const gateway = gatewayAt();
const question = {
kind: "QUESTION",
title: "질문",
slug: "runtime-question",
summary: "런타임 계약",
topicId: null,
projectId: null,
relations: [],
questionStatus: "OPEN",
facts: [],
assumptions: [],
unknowns: [],
constraints: [],
options: [],
nextValidation: "다음 검증",
resolution: null,
} satisfies Extract<WorkingCopyInput, { kind: "QUESTION" }>;
for (const [value, pointer] of [
[{ ...question, kind: "ARTICLE" }, "/kind"],
[{ ...question, extra: true }, "/extra"],
[{ ...question, questionStatus: "PAUSED" }, "/questionStatus"],
[
(({ summary, ...rest }) => {
void summary;
return rest;
})(question),
"/summary",
],
[
{ ...question, facts: [{ id: "bad", text: "사실", order: 0 }] },
"/facts/0/id",
],
] as Array<[unknown, string]>) {
await assert.rejects(
gateway.createDocument(value as WorkingCopyInput, {
idempotencyKey: `shape-${pointer}`,
}),
(error) => {
assert.ok(isStudioGatewayError(error));
const details = error.problem.details as ValidationErrorDetails;
assert.ok(details.fieldErrors.some(({ path }) => path === pointer));
return true;
},
);
}
const reference = await gateway.getDocument(FIXTURE_IDS.stateNonceReference);
await assert.rejects(
gateway.saveDocument(
reference.document.id,
{
expectedVersion: reference.document.version,
document: {
...inputOf(reference.document),
verifiedOn: "2026-99-99",
} as WorkingCopyInput,
},
{ idempotencyKey: "bad-date" },
),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/verifiedOn"]),
);
const emoji = "🧪";
const accepted = await gateway.createDocument(
emptyCase({ title: emoji.repeat(120) }),
{ idempotencyKey: emoji.repeat(200) },
);
assert.equal([...accepted.title].length, 120);
await gateway.listDocuments({ q: emoji.repeat(100) });
await assert.rejects(
gateway.createDocument(emptyCase({ title: emoji.repeat(121) }), {
idempotencyKey: "title-over",
}),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/title"]),
);
await assert.rejects(
gateway.createDocument(emptyCase(), {
idempotencyKey: emoji.repeat(201),
}),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/idempotencyKey"]),
);
await assert.rejects(
gateway.listDocuments({ q: emoji.repeat(101) }),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/q"]),
);
await assert.rejects(
gateway.listDocuments({ projectId: "bad" }),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/projectId"]),
);
await assert.rejects(
gateway.getCatalog({} as { type: "TOPIC" }),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/type"]),
);
});
test("structural maximums accept the boundary and reject long fields, repeated items and relations", async () => {
const gateway = gatewayAt();
let current = (await gateway.getDocument(FIXTURE_IDS.fetchJoinCase)).document;
for (const field of [
"problem",
"conclusion",
"environment",
"reproduction",
"bodyMarkdown",
] as const) {
current = (
await gateway.saveDocument(
current.id,
{
expectedVersion: current.version,
document: {
...inputOf(current),
[field]: "x".repeat(100_000),
} as WorkingCopyInput,
},
{ idempotencyKey: `max-${field}` },
)
).document;
await assert.rejects(
gateway.saveDocument(
current.id,
{
expectedVersion: current.version,
document: {
...inputOf(current),
[field]: "x".repeat(100_001),
} as WorkingCopyInput,
},
{ idempotencyKey: `over-${field}` },
),
isProblem(422, "REQUEST_VALIDATION_FAILED", [`/${field}`]),
);
}
const reference = await gateway.getDocument(FIXTURE_IDS.stateNonceReference);
const base = inputOf(reference.document) as Extract<
WorkingCopyInput,
{ kind: "REFERENCE" }
>;
const item = (index: number) => ({
id: `10000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
text: "내용",
order: index,
});
const fifty = Array.from({ length: 50 }, (_, index) => item(index));
await gateway.saveDocument(
reference.document.id,
{
expectedVersion: reference.document.version,
document: { ...base, applyWhen: fifty },
},
{ idempotencyKey: "items-50" },
);
await assert.rejects(
gateway.saveDocument(
reference.document.id,
{
expectedVersion: reference.document.version + 1,
document: { ...base, applyWhen: [...fifty, item(50)] },
},
{ idempotencyKey: "items-51" },
),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/applyWhen"]),
);
await assert.rejects(
gateway.saveDocument(
reference.document.id,
{
expectedVersion: reference.document.version + 1,
document: { ...base, applyWhen: [{ ...item(0), text: "" }] },
},
{ idempotencyKey: "empty-item" },
),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/applyWhen/0/text"]),
);
const fetch = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
const relations = Array.from({ length: 20 }, (_, index) => ({
id: `20000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
targetId: `30000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
reason: "관계",
order: index,
}));
await gateway.saveDocument(
fetch.document.id,
{
expectedVersion: fetch.document.version,
document: { ...inputOf(fetch.document), relations },
},
{ idempotencyKey: "relations-20" },
);
await assert.rejects(
gateway.saveDocument(
fetch.document.id,
{
expectedVersion: fetch.document.version + 1,
document: {
...inputOf(fetch.document),
relations: [
...relations,
{
...relations[0],
id: "40000000-0000-4000-8000-000000000000",
order: 20,
},
],
},
},
{ idempotencyKey: "relations-21" },
),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/relations"]),
);
});
test("validation issue order, evidence, preview freshness and warning-set publication transitions are deterministic", async () => {
let now = new Date(NOW);
let revision = "catalog-2026-08-14";
const gateway = createMockStudioGateway({
clock: { now: () => new Date(now) },
idGenerator: ids(),
dependencyRevision: { current: () => revision },
});
const invalid = await gateway.validateDocument(
FIXTURE_IDS.edgeTokenQuestion,
{ expectedVersion: 2 },
{ idempotencyKey: "invalid" },
);
assert.equal(invalid.status, "INVALID");
assert.deepEqual(
invalid.issues.map(({ code }) => code),
["QUESTION_FACT_REQUIRED", "QUESTION_OPTIONS_FEWER_THAN_TWO"],
);
const unsupported = await gateway.createDocument(
emptyCase({
title: "근거",
slug: "unsupported-evidence",
summary: "근거 검증",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
problem: "문제",
conclusion: "결론",
lastVerifiedOn: "2026-08-14",
bodyMarkdown:
':::evidence key="unknown" alt="근거" caption="근거" zoom="true"\n:::',
}),
{ idempotencyKey: "unsupported-create" },
);
const unsupportedReport = await gateway.validateDocument(
unsupported.id,
{ expectedVersion: 1 },
{ idempotencyKey: "unsupported-validation" },
);
assert.ok(
unsupportedReport.issues.some(
({ code }) => code === "EVIDENCE_UNSUPPORTED",
),
);
const document = await gateway.createDocument(
emptyCase({
title: "경고",
slug: "warning-order",
summary: "경고 순서",
topicId: FIXTURE_IDS.topicJpa,
problem: "문제",
conclusion: "결론",
bodyMarkdown: "본문",
lastVerifiedOn: "2026-01-01",
}),
{ idempotencyKey: "publish-create" },
);
const report = await gateway.validateDocument(
document.id,
{ expectedVersion: 1 },
{ idempotencyKey: "publish-validation" },
);
const preview = await gateway.createPreview(
document.id,
{ expectedVersion: 1, validationId: report.validationId },
{ idempotencyKey: "publish-preview" },
);
const codes = report.issues
.filter(({ severity }) => severity === "WARNING")
.map(({ code }) => code);
assert.deepEqual(codes, [
"PROJECT_MISSING",
"VERIFICATION_OLDER_THAN_30_DAYS",
]);
const command = {
expectedVersion: 1,
validationId: report.validationId,
previewId: preview.previewId,
};
const published = await gateway.publishDocument(
document.id,
{ ...command, acknowledgedWarningCodes: codes },
{ idempotencyKey: "publish" },
);
assert.equal(published.event.type, "PUBLISHED");
assert.deepEqual(
await gateway.publishDocument(
document.id,
{ ...command, acknowledgedWarningCodes: [...codes].reverse() },
{ idempotencyKey: "publish" },
),
published,
);
revision = "catalog-next";
assert.equal((await gateway.getCurrentPreview(document.id)).state, "STALE");
revision = "catalog-2026-08-14";
now = new Date("2026-08-14T01:35:00.000Z");
assert.equal((await gateway.getCurrentPreview(document.id)).state, "EXPIRED");
});
test("document and catalog lists support the Studio screen filters and deterministic paging", async () => {
const gateway = gatewayAt();
const first = await gateway.listDocuments({
q: "경계",
sort: "TITLE_ASC",
limit: 1,
});
assert.ok(first.nextCursor);
const firstItem = first.items[0];
assert.ok(firstItem);
const second = await gateway.listDocuments({
q: " 경계 ",
sort: "TITLE_ASC",
limit: 1,
cursor: first.nextCursor,
});
assert.notEqual(second.items[0]?.id, firstItem.id);
await assert.rejects(
gateway.listDocuments({
q: "other",
sort: "TITLE_ASC",
limit: 1,
cursor: first.nextCursor,
}),
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/cursor"]),
);
assert.ok((await gateway.getCatalog({ type: "TOPIC" })).items.length > 0);
});
test("publication history, immutable snapshots, conflict fixtures and 404 bodies stay precise", async () => {
const gateway = gatewayAt();
const redis = await gateway.getDocument(FIXTURE_IDS.redisAdapterCase);
assert.ok(redis.currentPublication);
const noOp = await gateway.publishDocument(
redis.document.id,
{
expectedVersion: redis.document.version,
validationId: FIXTURE_IDS.stateValidation,
previewId: FIXTURE_IDS.fetchPreview,
acknowledgedWarningCodes: [],
},
{ idempotencyKey: "redis-noop" },
);
assert.equal(
noOp.publication.publicationRevision,
redis.currentPublication.publicationRevision,
);
const snapshot = await gateway.getPublicationSnapshot(
FIXTURE_IDS.fetchPublishedEvent,
);
snapshot.renderModel.summary = "consumer mutation";
assert.notEqual(
(
await gateway.getPublicationSnapshot(FIXTURE_IDS.fetchPublishedEvent)
).renderModel.summary,
snapshot.renderModel.summary,
);
const unpublish = await gateway.unpublishPublication(
redis.currentPublication.publicationId,
{
expectedPublicationRevision:
redis.currentPublication.publicationRevision,
},
{ idempotencyKey: "unpublish" },
);
assert.equal(
unpublish.event.sourcePublishedEventId,
FIXTURE_IDS.redisPublishedEvent,
);
const rows = await gateway.listPublications({ limit: 100 });
assert.ok(
rows.items.some(
({ event, availableActions }) =>
event.type === "UNPUBLISHED" &&
availableActions.includes("VIEW_SOURCE_SNAPSHOT"),
),
);
const conflict = await gateway.getDocument(FIXTURE_IDS.conflictCase);
await assert.rejects(
gateway.saveDocument(
conflict.document.id,
{
expectedVersion: conflict.document.version,
document: inputOf(conflict.document),
},
{ idempotencyKey: "conflict" },
),
(error) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "VERSION_CONFLICT");
const details = error.problem.details as VersionConflictDetails;
assert.deepEqual(details.conflictingFields, ["/title", "/summary"]);
assert.equal(
details.latestDocument.document.version,
conflict.document.version + 1,
);
return true;
},
);
const unknown = "99999999-9999-4999-8999-999999999999";
await assert.rejects(
gateway.getDocument(unknown),
isProblem(404, "DOCUMENT_NOT_FOUND"),
);
await assert.rejects(
gateway.getPublicationSnapshot(unknown),
isProblem(404, "PUBLICATION_EVENT_NOT_FOUND"),
);
});
test("dashboard totals.needsValidation matches the documents whose next action is to validate or fix validation", async () => {
const gateway = gatewayAt();
const dashboard = await gateway.getDashboard();
const documents = await gateway.listDocuments({ limit: 100 });
const expected = documents.items.filter(
(item) =>
item.nextAction === "VALIDATE" || item.nextAction === "FIX_VALIDATION",
).length;
// Cross-checked against `listDocuments`, not against the dashboard's own
// arithmetic, so a broken `needsValidation` filter shows up as a mismatch
// instead of being restated. Both branches (>0 and <total) must hold, or a
// filter that always returns 0 or the full count would still pass silently.
assert.ok(expected > 0);
assert.ok(expected < documents.items.length);
assert.equal(dashboard.totals.needsValidation, expected);
});
test("getDocument's nextAction reflects the assembled WorkingCopyDetail, not a cached or default value", async () => {
const gateway = gatewayAt();
// Published at exactly the current version (fixtures.ts: publishedVersion 4
// === document version 4) is deriveNextAction's first, clock-independent
// branch: nextAction must be "NONE" regardless of validation or preview state.
const redis = await gateway.getDocument(FIXTURE_IDS.redisAdapterCase);
assert.equal(redis.document.version, 4);
assert.equal(redis.currentPublication?.status, "PUBLISHED");
assert.equal(redis.currentPublication?.publishedVersion, 4);
assert.equal(redis.nextAction, "NONE");
// Not published, and validated INVALID at the current version within the
// validity window: nextAction must be "FIX_VALIDATION", which only follows
// from currentValidation actually being read and its freshness actually
// computed - a value a hardcoded or wrongly-sourced default would not produce.
const edgeToken = await gateway.getDocument(FIXTURE_IDS.edgeTokenQuestion);
assert.equal(edgeToken.document.version, 2);
assert.equal(edgeToken.currentPublication, null);
assert.equal(edgeToken.currentValidation?.status, "INVALID");
assert.equal(edgeToken.currentValidation?.validatedVersion, 2);
assert.equal(edgeToken.nextAction, "FIX_VALIDATION");
});
// Final fix wave, item 6. `idempotent()` normalizes anything `work()` throws
// into a `StudioGatewayError` because that is the port's whole contract --
// but its `read()` sibling did not, so an uncharacterized internal failure on
// any of the seven read operations escaped the port as a raw `Error` and
// reached UI code written to catch `StudioGatewayError`. An aborted request
// must still surface as `AbortError`: `boundary()` runs outside the wrap, the
// same way `idempotent()` already arranges it.
test("read() normalizes an uncharacterized internal failure, and still lets an abort through", async () => {
let failing = false;
const gateway = createMockStudioGateway({
clock: { now: () => new Date(NOW) },
idGenerator: ids(),
dependencyRevision: {
current: () => {
if (failing) throw new Error("의존성 리비전을 읽지 못했습니다.");
return "catalog-2026-08-14";
},
},
});
const created = await gateway.createDocument(
emptyCase({ title: "읽기 경계", slug: "read-boundary", summary: "요약" }),
{ idempotencyKey: "read-boundary-create" },
);
failing = true;
for (const [label, call] of [
["getDocument", () => gateway.getDocument(created.id)],
["getDashboard", () => gateway.getDashboard()],
["listDocuments", () => gateway.listDocuments({})],
] as const) {
await assert.rejects(call(), (error: unknown) => {
assert.ok(
isStudioGatewayError(error),
`${label}: expected a StudioGatewayError, got ${String(error)}`,
);
assert.equal(error.code, "STUDIO_UNAVAILABLE");
assert.equal(error.status, 500);
assert.equal(error.retryable, true);
return true;
});
}
const controller = new AbortController();
controller.abort();
await assert.rejects(gateway.getDocument(created.id, { signal: controller.signal }), {
name: "AbortError",
});
});