feat: compose TechLog static and mock adapters
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
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"),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import {
|
||||
caseDocument,
|
||||
caseSections,
|
||||
documentHeadings,
|
||||
focusItems,
|
||||
latestEntries,
|
||||
} from "../../../src/features/tech-log/adapters/static/content.ts";
|
||||
import {
|
||||
profile,
|
||||
projects,
|
||||
publicRecords,
|
||||
releases,
|
||||
} from "../../../src/features/tech-log/adapters/static/public-content.ts";
|
||||
import {
|
||||
getHomeFocusItems,
|
||||
getProject,
|
||||
getProjectActivity,
|
||||
getProjectDecisions,
|
||||
getProjectRecords,
|
||||
getRecord,
|
||||
getRelease,
|
||||
listRecords,
|
||||
searchPublicContent,
|
||||
} from "../../../src/features/tech-log/adapters/static/public-query.ts";
|
||||
|
||||
test("filters the canonical record set by kind, topic, project, and open-question state", () => {
|
||||
assert.deepEqual(
|
||||
listRecords({ kind: "CASE" }).map((item) => item.slug),
|
||||
["collection-fetch-join-pagination", "redis-adapter-ttl-boundary"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listRecords({ topic: " jPa ", project: " BACKEND-SKELETON " }).map(
|
||||
(item) => item.slug,
|
||||
),
|
||||
[
|
||||
"collection-fetch-join-pagination",
|
||||
"jpa-list-fetch-strategy",
|
||||
"collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listRecords({ topic: "JPA", project: " Backend Skeleton " }).map(
|
||||
(item) => item.slug,
|
||||
),
|
||||
[
|
||||
"collection-fetch-join-pagination",
|
||||
"jpa-list-fetch-strategy",
|
||||
"collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(listRecords({ topic: " " }), []);
|
||||
assert.deepEqual(listRecords({ project: " " }), []);
|
||||
assert.deepEqual(
|
||||
listRecords({ topic: "JPA" }).map((item) => item.slug),
|
||||
[
|
||||
"collection-fetch-join-pagination",
|
||||
"jpa-list-fetch-strategy",
|
||||
"collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listRecords({ project: "backend-skeleton" }).map((item) => item.slug),
|
||||
[
|
||||
"collection-fetch-join-pagination",
|
||||
"jpa-list-fetch-strategy",
|
||||
"redis-adapter-ttl-boundary",
|
||||
"collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listRecords({ kind: "QUESTION", openQuestionsOnly: true }).map(
|
||||
(item) => item.slug,
|
||||
),
|
||||
["validate-edge-token-again"],
|
||||
);
|
||||
});
|
||||
|
||||
test("searches public entities by title, summary, topic, and project without duplicate paths", () => {
|
||||
assert.deepEqual(
|
||||
searchPublicContent("JPA").map((item) => item.path),
|
||||
[
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
"/projects/backend-skeleton",
|
||||
],
|
||||
);
|
||||
assert.equal(searchPublicContent("Backend Skeleton").length, 5);
|
||||
assert.deepEqual(
|
||||
searchPublicContent("Keycloak").map((item) => item.path),
|
||||
["/projects/auth-lab"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
searchPublicContent("Storage").map((item) => item.path),
|
||||
["/projects/backend-skeleton"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
searchPublicContent("요청 위조 방지").map((item) => item.path),
|
||||
["/references/state-and-nonce-boundary"],
|
||||
);
|
||||
|
||||
const allPaths = searchPublicContent("").map((item) => item.path);
|
||||
assert.equal(allPaths.length, 9);
|
||||
assert.equal(new Set(allPaths).size, allPaths.length);
|
||||
});
|
||||
|
||||
test("looks up canonical records, projects, and releases while leaving unknown identifiers absent", () => {
|
||||
assert.equal(
|
||||
getRecord("CASE", "collection-fetch-join-pagination")?.title,
|
||||
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
);
|
||||
assert.equal(
|
||||
getRecord("QUESTION", "validate-edge-token-again")?.questionStatus,
|
||||
"OPEN",
|
||||
);
|
||||
assert.equal(getRecord("CASE", "missing"), undefined);
|
||||
assert.equal(getProject("backend-skeleton")?.title, "Backend Skeleton");
|
||||
assert.equal(getProject("missing"), undefined);
|
||||
assert.equal(getRelease("0.1.0")?.version, "0.1.0");
|
||||
assert.equal(getRelease("9.9.9"), undefined);
|
||||
});
|
||||
|
||||
test("derives project records, decisions, and activity from canonical relationships", () => {
|
||||
assert.equal(getProjectRecords("backend-skeleton").length, 4);
|
||||
assert.deepEqual(
|
||||
getProjectDecisions("backend-skeleton").map((item) => item.id),
|
||||
["storage-port-unification", "feed-pagination-boundary"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getProjectActivity("auth-lab").map((item) => item.id),
|
||||
["state-nonce-reference", "edge-trust-boundary"],
|
||||
);
|
||||
assert.deepEqual(getProjectRecords("missing"), []);
|
||||
assert.deepEqual(getProjectDecisions("missing"), []);
|
||||
assert.deepEqual(getProjectActivity("missing"), []);
|
||||
});
|
||||
|
||||
test("keeps the approved canonical corpus and grounded profile", () => {
|
||||
assert.equal(publicRecords.length, 6);
|
||||
assert.equal(projects.length, 2);
|
||||
assert.equal(releases.length, 1);
|
||||
assert.equal(profile.name, "동현");
|
||||
assert.equal(profile.email, undefined);
|
||||
assert.deepEqual(profile.topics, [
|
||||
"Backend Architecture",
|
||||
"JPA",
|
||||
"Authentication",
|
||||
"Redis",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
publicRecords.map((item) => item.path),
|
||||
[
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
"/references/state-and-nonce-boundary",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/questions/validate-edge-token-again",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves the existing home timeline and Fetch Join Case compatibility exactly", () => {
|
||||
assert.deepEqual(
|
||||
latestEntries.map(({ typeLabel, title, path }) => ({
|
||||
typeLabel,
|
||||
title,
|
||||
path,
|
||||
})),
|
||||
[
|
||||
{
|
||||
typeLabel: "CASE",
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
},
|
||||
{
|
||||
typeLabel: "PROJECT ACTIVITY",
|
||||
title: "파일 저장소 계약을 하나로 통합했습니다",
|
||||
path: "/projects/backend-skeleton/activity#storage-contract",
|
||||
},
|
||||
{
|
||||
typeLabel: "REFERENCE",
|
||||
title: "Authorization Code Flow에서 state와 nonce의 경계",
|
||||
path: "/references/state-and-nonce-boundary",
|
||||
},
|
||||
{
|
||||
typeLabel: "PROJECT ACTIVITY",
|
||||
title: "oauth2-proxy 뒤에서 토큰을 다시 검증할 것인가",
|
||||
path: "/projects/auth-lab/activity#edge-trust-boundary",
|
||||
},
|
||||
{
|
||||
typeLabel: "CASE",
|
||||
title: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
|
||||
path: "/cases/redis-adapter-ttl-boundary",
|
||||
},
|
||||
{
|
||||
typeLabel: "RELEASE",
|
||||
title: "TechLog Public·Studio 경계를 확정했습니다",
|
||||
path: "/releases/0.1.0",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
caseDocument.summary,
|
||||
"반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정을 측정하고, 목록의 페이지 경계를 다시 세웠습니다.",
|
||||
);
|
||||
assert.equal(
|
||||
caseDocument.problem,
|
||||
"FeedItem 20건을 요청했지만 컬렉션 Fetch Join 때문에 DB LIMIT가 사라지고, 전체 부모와 자식 행을 읽은 뒤 메모리에서 20건만 남겼습니다.",
|
||||
);
|
||||
assert.equal(
|
||||
caseDocument.conclusion,
|
||||
"목록 페이징 쿼리와 컬렉션 로딩을 분리하고, 현재 페이지의 부모 키만 IN 배치로 조회합니다.",
|
||||
);
|
||||
assert.equal(
|
||||
caseDocument.dataset,
|
||||
"Dataset: FeedItem 100개, Zipf 편중 Highlight/Mention",
|
||||
);
|
||||
|
||||
const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination");
|
||||
assert.ok(fetchJoin);
|
||||
assert.equal("headings" in fetchJoin, false);
|
||||
assert.deepEqual(
|
||||
documentHeadings,
|
||||
fetchJoin.sections.map(({ id, title }) => ({ id, label: title })),
|
||||
);
|
||||
assert.equal(caseSections, fetchJoin.sections);
|
||||
});
|
||||
|
||||
test("derives public home focus and Case verification labels from canonical content", () => {
|
||||
const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination");
|
||||
const derivedFocusItems = getHomeFocusItems();
|
||||
|
||||
assert.equal(fetchJoin?.lastVerifiedLabel, "2026.08.11");
|
||||
assert.equal(
|
||||
caseDocument.dates,
|
||||
"게시 2026.08.11 · 마지막 검증 2026.08.11",
|
||||
);
|
||||
assert.deepEqual(derivedFocusItems, [
|
||||
{
|
||||
key: "current",
|
||||
label: "현재 작업",
|
||||
title: "Backend Skeleton",
|
||||
summary:
|
||||
"저장소·Redis·JPA·MongoDB 같은 기술을 붙일 때 애플리케이션 경계를 다시 만들지 않도록 공통 계약을 정리하는 프로젝트입니다.",
|
||||
details: [
|
||||
{ label: "단계", value: "DESIGN" },
|
||||
{
|
||||
label: "현재 목표",
|
||||
value: "Filesystem과 Object Storage를 하나의 StoragePort로 통합",
|
||||
},
|
||||
{
|
||||
label: "다음 작업",
|
||||
value: "MinIO Adapter와 공통 계약 테스트 연결",
|
||||
},
|
||||
],
|
||||
targetPath: "/projects/backend-skeleton",
|
||||
},
|
||||
{
|
||||
key: "question",
|
||||
label: "열린 질문",
|
||||
title: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?",
|
||||
summary:
|
||||
"Edge에서 인증한 뒤 Resource Server가 무엇을 신뢰할지 결정하기 위한 열린 질문입니다.",
|
||||
details: [
|
||||
{
|
||||
label: "확인한 사실",
|
||||
value:
|
||||
"oauth2-proxy는 Access Token 또는 사용자 식별 헤더를 upstream에 전달할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "남은 미지수",
|
||||
value:
|
||||
"내부 요청에서 사용자 헤더 위조를 어떤 계층이 차단할지 아직 확정하지 않았습니다.",
|
||||
},
|
||||
{
|
||||
label: "다음 검증",
|
||||
value:
|
||||
"토큰 전달안과 신뢰 헤더안을 위협 모델로 비교하고 Edge 우회 요청을 포함한 통합 테스트를 실행합니다.",
|
||||
},
|
||||
],
|
||||
targetPath: "/questions/validate-edge-token-again",
|
||||
},
|
||||
{
|
||||
key: "decision",
|
||||
label: "최근 결정",
|
||||
title:
|
||||
"Filesystem과 Object Storage는 하나의 StoragePort와 서로 다른 Adapter로 구성합니다.",
|
||||
summary:
|
||||
"애플리케이션이 요구하는 저장 의미는 같고 실제 저장 방식만 달라지므로 호출 계약을 기술별로 나누지 않습니다.",
|
||||
details: [
|
||||
{
|
||||
label: "영향",
|
||||
value: "로컬과 MinIO 구현이 같은 계약 테스트를 통과해야 합니다.",
|
||||
},
|
||||
{
|
||||
label: "근거",
|
||||
value: "파일 저장소 계약을 하나로 통합했습니다",
|
||||
},
|
||||
],
|
||||
targetPath:
|
||||
"/projects/backend-skeleton/decisions#storage-port-unification",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(focusItems, derivedFocusItems);
|
||||
});
|
||||
|
||||
test("keeps template-specific evidence instead of reducing records to search cards", () => {
|
||||
const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination");
|
||||
const stateNonce = getRecord("REFERENCE", "state-and-nonce-boundary");
|
||||
const edgeTrust = getRecord("QUESTION", "validate-edge-token-again");
|
||||
|
||||
assert.equal(fetchJoin?.kind, "CASE");
|
||||
if (fetchJoin?.kind === "CASE") {
|
||||
assert.match(fetchJoin.problem, /DB LIMIT/);
|
||||
assert.match(fetchJoin.conclusion, /IN 배치/);
|
||||
}
|
||||
|
||||
assert.equal(stateNonce?.kind, "REFERENCE");
|
||||
if (stateNonce?.kind === "REFERENCE") {
|
||||
assert.equal(stateNonce.rules.length, 3);
|
||||
assert.match(stateNonce.rules[0].body, /state/);
|
||||
}
|
||||
|
||||
assert.equal(edgeTrust?.kind, "QUESTION");
|
||||
if (edgeTrust?.kind === "QUESTION") {
|
||||
assert.equal(edgeTrust.questionStatus, "OPEN");
|
||||
assert.ok(edgeTrust.facts.length >= 2);
|
||||
assert.match(edgeTrust.nextValidation, /위협 모델/);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
projects[0].currentGoal,
|
||||
"Filesystem과 Object Storage를 하나의 StoragePort로 통합",
|
||||
);
|
||||
assert.match(releases[0].changes[0], /Public/);
|
||||
assert.ok(profile.principles.length >= 3);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts";
|
||||
import { createInstalledFeatureInputs } from "../../../src/features/installed-feature-adapters.ts";
|
||||
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||
import type { TechLogFeatureInput } from "../../../src/features/tech-log/application/tech-log-feature-input.ts";
|
||||
import type { WorkingCopy } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
|
||||
type Equal<Left, Right> =
|
||||
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() =>
|
||||
Value extends Right ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
type Expect<Value extends true> = Value;
|
||||
|
||||
type InstalledInputs = ReturnType<typeof createInstalledFeatureInputs>;
|
||||
type _InstalledIdsAreExact = Expect<
|
||||
Equal<keyof InstalledInputs, "reference-feature" | "tech-log">
|
||||
>;
|
||||
type _TechLogRegistryValueIsExact = Expect<
|
||||
Equal<ApplicationFeatureInputs["tech-log"], TechLogFeatureInput>
|
||||
>;
|
||||
void (0 as unknown as _InstalledIdsAreExact);
|
||||
void (0 as unknown as _TechLogRegistryValueIsExact);
|
||||
|
||||
function inputOf(document: WorkingCopy) {
|
||||
const { id, version, updatedAt, ...input } = document;
|
||||
void id;
|
||||
void version;
|
||||
void updatedAt;
|
||||
return input;
|
||||
}
|
||||
|
||||
function installedInputs(): InstalledInputs {
|
||||
return createInstalledFeatureInputs({
|
||||
contractOperations: {
|
||||
async execute() {
|
||||
throw new Error("reference executor is not used by composition tests");
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("installs TechLog beside the retained reference feature through application-facing inputs", () => {
|
||||
const installed = installedInputs();
|
||||
|
||||
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]);
|
||||
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
|
||||
"createStudioGateway",
|
||||
"publicContent",
|
||||
]);
|
||||
assert.equal(Object.isFrozen(installed), true);
|
||||
assert.equal(Object.isFrozen(installed["tech-log"]), true);
|
||||
assert.equal(Object.isFrozen(installed["tech-log"].publicContent), true);
|
||||
assert.equal(
|
||||
installed["tech-log"].publicContent.getRelease("0.1.0")?.title,
|
||||
"TechLog Public·Studio 경계를 확정했습니다",
|
||||
);
|
||||
});
|
||||
|
||||
test("each createStudioGateway call owns an isolated mutable Studio session", async () => {
|
||||
const installed = installedInputs();
|
||||
const first = installed["tech-log"].createStudioGateway();
|
||||
const second = installed["tech-log"].createStudioGateway();
|
||||
|
||||
assert.notEqual(first, second);
|
||||
const firstBefore = await first.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||
const secondBefore = await second.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||
assert.equal(firstBefore.document.title, secondBefore.document.title);
|
||||
|
||||
const saved = await first.saveDocument(
|
||||
firstBefore.document.id,
|
||||
{
|
||||
expectedVersion: firstBefore.document.version,
|
||||
document: {
|
||||
...inputOf(firstBefore.document),
|
||||
title: "첫 번째 세션에서만 수정",
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: "session-isolation" },
|
||||
);
|
||||
|
||||
assert.equal(saved.document.title, "첫 번째 세션에서만 수정");
|
||||
assert.equal(
|
||||
(await second.getDocument(FIXTURE_IDS.fetchJoinCase)).document.title,
|
||||
secondBefore.document.title,
|
||||
);
|
||||
assert.equal(
|
||||
installed["tech-log"].publicContent.getRecord(
|
||||
"CASE",
|
||||
"collection-fetch-join-pagination",
|
||||
)?.title,
|
||||
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import type {
|
||||
PublicationAggregate,
|
||||
PublicPreview,
|
||||
ValidationReport,
|
||||
WorkingCopy,
|
||||
WorkingCopyDetail,
|
||||
} from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
import {
|
||||
deriveDocumentState,
|
||||
deriveNextAction,
|
||||
derivePreviewState,
|
||||
deriveValidationState,
|
||||
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
|
||||
import { createLocalId } from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
||||
|
||||
const now = "2026-08-14T12:00:00.000Z";
|
||||
const validUntil = "2026-08-14T12:30:00.000Z";
|
||||
const expiredAt = "2026-08-14T12:00:00.000Z";
|
||||
const futureExpiry = "2026-08-14T12:45:00.000Z";
|
||||
|
||||
function documentAt(
|
||||
version = 7,
|
||||
overrides: Partial<WorkingCopy> = {},
|
||||
): WorkingCopy {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
kind: "CASE",
|
||||
version,
|
||||
title: "Fetch Join 경계",
|
||||
slug: "fetch-join-boundary",
|
||||
summary: "목록 경계를 검증합니다.",
|
||||
topicId: "22222222-2222-4222-8222-222222222222",
|
||||
projectId: null,
|
||||
relations: [],
|
||||
updatedAt: now,
|
||||
problem: "문제",
|
||||
conclusion: "결론",
|
||||
environment: "PostgreSQL",
|
||||
reproduction: "재현",
|
||||
lastVerifiedOn: "2026-08-14",
|
||||
bodyMarkdown: "## 본문",
|
||||
...overrides,
|
||||
} as WorkingCopy;
|
||||
}
|
||||
|
||||
function validationAt(
|
||||
version = 7,
|
||||
overrides: Partial<ValidationReport> = {},
|
||||
): ValidationReport {
|
||||
return {
|
||||
validationId: "33333333-3333-4333-8333-333333333333",
|
||||
documentId: "11111111-1111-4111-8111-111111111111",
|
||||
validatedVersion: version,
|
||||
status: "VALID",
|
||||
issues: [],
|
||||
validatedAt: "2026-08-14T11:50:00.000Z",
|
||||
validUntil,
|
||||
dependencyRevision: "catalog-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function previewAt(
|
||||
version = 7,
|
||||
validationId = validationAt(version).validationId,
|
||||
overrides: Partial<PublicPreview> = {},
|
||||
): PublicPreview {
|
||||
return {
|
||||
previewId: "44444444-4444-4444-8444-444444444444",
|
||||
documentId: "11111111-1111-4111-8111-111111111111",
|
||||
previewVersion: version,
|
||||
validationId,
|
||||
createdAt: "2026-08-14T11:55:00.000Z",
|
||||
expiresAt: futureExpiry,
|
||||
renderModel: {
|
||||
kind: "CASE",
|
||||
title: "Fetch Join 경계",
|
||||
slug: "fetch-join-boundary",
|
||||
summary: "목록 경계를 검증합니다.",
|
||||
topic: {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
label: "JPA",
|
||||
publicPath: null,
|
||||
},
|
||||
project: null,
|
||||
relations: [],
|
||||
blocks: [],
|
||||
},
|
||||
...overrides,
|
||||
} as PublicPreview;
|
||||
}
|
||||
|
||||
function publishedAt(version = 7): PublicationAggregate {
|
||||
return {
|
||||
publicationId: "55555555-5555-4555-8555-555555555555",
|
||||
documentId: "11111111-1111-4111-8111-111111111111",
|
||||
status: "PUBLISHED",
|
||||
publishedVersion: version,
|
||||
publicationRevision: 1,
|
||||
latestEventId: "66666666-6666-4666-8666-666666666666",
|
||||
publicPath: "/cases/fetch-join-boundary",
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
type InputOverrides = Partial<Parameters<typeof deriveNextAction>[0]>;
|
||||
|
||||
function input(overrides: InputOverrides = {}) {
|
||||
return {
|
||||
document: documentAt(),
|
||||
validation: validationAt(),
|
||||
preview: previewAt(),
|
||||
publication: null,
|
||||
now,
|
||||
dependencyRevision: "catalog-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("same published version is NONE before stale validation checks", () => {
|
||||
assert.equal(
|
||||
deriveNextAction({
|
||||
document: documentAt(7),
|
||||
validation: validationAt(7, { validUntil: expiredAt }),
|
||||
preview: previewAt(7, "other-validation", { expiresAt: expiredAt }),
|
||||
publication: publishedAt(7),
|
||||
now,
|
||||
dependencyRevision: "catalog-2",
|
||||
}),
|
||||
"NONE",
|
||||
);
|
||||
});
|
||||
|
||||
test("validation freshness requires the saved version, a future expiry, and matching dependencies", () => {
|
||||
assert.deepEqual(deriveValidationState(input({ validation: null })), {
|
||||
result: "NOT_RUN",
|
||||
freshness: "NONE",
|
||||
});
|
||||
assert.deepEqual(
|
||||
deriveValidationState(input({ validation: validationAt(6) })),
|
||||
{ result: "VALID", freshness: "STALE" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
deriveValidationState(
|
||||
input({ validation: validationAt(7, { validUntil: now }) }),
|
||||
),
|
||||
{ result: "VALID", freshness: "STALE" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
deriveValidationState(input({ dependencyRevision: "catalog-2" })),
|
||||
{ result: "VALID", freshness: "STALE" },
|
||||
);
|
||||
assert.deepEqual(deriveValidationState(input()), {
|
||||
result: "VALID",
|
||||
freshness: "CURRENT",
|
||||
});
|
||||
});
|
||||
|
||||
test("preview expiry takes precedence and all remaining freshness checks are enforced", () => {
|
||||
const validation = validationAt();
|
||||
assert.equal(derivePreviewState(input({ preview: null })), "NONE");
|
||||
assert.equal(
|
||||
derivePreviewState(
|
||||
input({ preview: previewAt(6, validation.validationId) }),
|
||||
),
|
||||
"STALE",
|
||||
);
|
||||
assert.equal(
|
||||
derivePreviewState(input({ preview: previewAt(7, "other-validation") })),
|
||||
"STALE",
|
||||
);
|
||||
assert.equal(
|
||||
derivePreviewState(
|
||||
input({
|
||||
preview: previewAt(6, "other-validation", { expiresAt: now }),
|
||||
}),
|
||||
),
|
||||
"EXPIRED",
|
||||
);
|
||||
assert.equal(derivePreviewState(input()), "CURRENT");
|
||||
assert.equal(
|
||||
derivePreviewState(
|
||||
input({ validation: validationAt(7, { validUntil: now }) }),
|
||||
),
|
||||
"STALE",
|
||||
);
|
||||
assert.equal(
|
||||
derivePreviewState(input({ dependencyRevision: "catalog-2" })),
|
||||
"STALE",
|
||||
);
|
||||
});
|
||||
|
||||
test("save-reset, dependency staleness, invalid reports, warnings and preview gaps are ordered", () => {
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({ document: documentAt(7, { title: "", slug: "", summary: "" }) }),
|
||||
),
|
||||
"CONTINUE_EDITING",
|
||||
);
|
||||
assert.equal(deriveNextAction(input({ validation: null })), "VALIDATE");
|
||||
assert.equal(
|
||||
deriveNextAction(input({ dependencyRevision: "catalog-2" })),
|
||||
"VALIDATE",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({ validation: validationAt(7, { status: "INVALID" }) }),
|
||||
),
|
||||
"FIX_VALIDATION",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({
|
||||
validation: validationAt(7, { status: "WARNINGS" }),
|
||||
preview: null,
|
||||
}),
|
||||
),
|
||||
"CREATE_PREVIEW",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({ validation: validationAt(7, { status: "WARNINGS" }) }),
|
||||
),
|
||||
"PUBLISH",
|
||||
);
|
||||
assert.deepEqual(
|
||||
deriveValidationState(
|
||||
input({
|
||||
validation: validationAt(7, { status: "WARNINGS" }),
|
||||
dependencyRevision: "catalog-2",
|
||||
}),
|
||||
),
|
||||
{ result: "WARNINGS", freshness: "STALE" },
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({
|
||||
validation: validationAt(7, { status: "WARNINGS" }),
|
||||
dependencyRevision: "catalog-2",
|
||||
}),
|
||||
),
|
||||
"VALIDATE",
|
||||
);
|
||||
});
|
||||
|
||||
test("saving retains an old preview as stale and returns to validation", () => {
|
||||
const previousValidation = validationAt(7);
|
||||
const previousPreview = previewAt(7, previousValidation.validationId);
|
||||
const state = deriveDocumentState(
|
||||
input({
|
||||
document: documentAt(8),
|
||||
validation: null,
|
||||
preview: previousPreview,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(state.validation, {
|
||||
result: "NOT_RUN",
|
||||
freshness: "NONE",
|
||||
});
|
||||
assert.equal(state.preview, "STALE");
|
||||
assert.equal(state.nextAction, "VALIDATE");
|
||||
});
|
||||
|
||||
test("unpublished or old-version publications follow the normal next-action flow", () => {
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({ publication: { ...publishedAt(), status: "UNPUBLISHED" } }),
|
||||
),
|
||||
"PUBLISH",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(input({ publication: publishedAt(6) })),
|
||||
"PUBLISH",
|
||||
);
|
||||
});
|
||||
|
||||
test("deriveDocumentState is consistent with a WorkingCopyDetail aggregate", () => {
|
||||
const detail: WorkingCopyDetail = {
|
||||
document: documentAt(),
|
||||
currentValidation: validationAt(),
|
||||
latestPreview: previewAt(),
|
||||
currentPublication: publishedAt(),
|
||||
dependencyRevision: "catalog-1",
|
||||
};
|
||||
const explicit = deriveDocumentState({
|
||||
document: detail.document,
|
||||
validation: detail.currentValidation,
|
||||
preview: detail.latestPreview,
|
||||
publication: detail.currentPublication,
|
||||
dependencyRevision: detail.dependencyRevision,
|
||||
now,
|
||||
});
|
||||
const fromDetail = deriveDocumentState({ ...detail, now });
|
||||
|
||||
assert.deepEqual(explicit, {
|
||||
validation: { result: "VALID", freshness: "CURRENT" },
|
||||
preview: "CURRENT",
|
||||
nextAction: "NONE",
|
||||
});
|
||||
assert.deepEqual(fromDetail, explicit);
|
||||
});
|
||||
|
||||
test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs when present", () => {
|
||||
assert.equal(
|
||||
createLocalId("preview", null, () => 1234, () => 0.25),
|
||||
"preview-1234-250000000",
|
||||
);
|
||||
assert.equal(
|
||||
createLocalId("save", { randomUUID: () => "fixture-uuid" }),
|
||||
"save-fixture-uuid",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user