Files
tech-log-frontend/tests/features/tech-log/mock-studio-gateway.test.ts
T
DongHyeonka 1801414592 fix: show the reason the server gave for a failed action
Every failure in the management screens printed a guess. Deleting a working
copy said "it may be published, or something may reference it, or someone may
have edited it first" — three maybes, while the server had answered with
exactly one: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다". A version
conflict read as "in use" because the same sentence covered both, and an author
watching some deletions succeed and others fail had no way to tell them apart.

The gateway now carries the server's client-safe message and the screens show
it. The canned sentences remain only as a fallback for a failure that never
reached the server.

The topic status label said "사용 중" for every active topic, including one
created seconds earlier that nothing references. Next to a refusal about
records that use a topic, the two read as the same statement. It says "활성"
now, which is what the status means.

Publishing also stops demanding a finished document — the mock validator moves
with the real one, so what an author sees against fixtures matches production.
2026-08-21 19:29:04 +09:00

697 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" },
);
// 비어 있음은 더 이상 게시를 막지 않는다 — 무엇을 얼마나 쓸지는 작성자가 정한다. 그래서 이
// 문서는 INVALID 가 아니라 WARNINGS 다. 항목이 사라지는 것이 아니라 심각도만 내려간다는 것이
// 여기서 확인해야 할 점이고, 그래서 코드 목록은 그대로 둔다.
assert.equal(invalid.status, "WARNINGS");
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",
});
});