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> = {}, ) { return { kind: "CASE", title: "", slug: "", summary: "", topicId: null, projectId: null, relations: [], problem: "", conclusion: "", environment: "", reproduction: "", lastVerifiedOn: null, bodyMarkdown: "", ...overrides, } satisfies Extract; } 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; 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"), ); }); 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 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"); });