600 lines
17 KiB
TypeScript
600 lines
17 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 {
|
|
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/mock/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) {
|
|
assert.deepEqual(
|
|
error.problem.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));
|
|
assert.ok(
|
|
error.problem.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");
|
|
assert.deepEqual(error.problem.conflictingFields, [
|
|
"/title",
|
|
"/summary",
|
|
]);
|
|
assert.equal(
|
|
error.problem.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"),
|
|
);
|
|
});
|