import assert from "node:assert/strict"; import { test } from "vitest"; import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts"; import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts"; // The staleness guards in `createPreview`/`publishDocument` compare the // validation's `dependencyRevision` against `dependencies.dependencyRevision // .current()`. While that default was a literal constant, it could not // observe the Asset store at all -- so an Asset mutation between validate and // preview was invisible to it, and the only thing standing between an // author and a published figure with no accessible name was whatever the // projection happened to re-derive. function caseDraft( overrides: Readonly<{ slug: string; bodyMarkdown: string; title?: string }>, ) { return { kind: "CASE", title: overrides.title ?? "의존성 리비전 확인", slug: overrides.slug, summary: "검증과 미리보기 사이에서 Asset이 바뀌면 검증이 낡아야 합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], problem: "문제", conclusion: "결론", environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", bodyMarkdown: overrides.bodyMarkdown, } as never; } function assetFixture(overrides: Partial): Asset { return { id: "99999999-9999-4999-8999-999999999991", assetKey: "revision-check", kind: "IMAGE", mediaType: "image/svg+xml", originalFilename: "revision-check.svg", byteSize: 10, width: null, height: null, altText: null, decorative: false, managementStatus: "READY", publicPath: "/media/revision-check.svg", usageCount: 0, version: 1, createdAt: "2026-08-14T00:00:00.000Z", updatedAt: "2026-08-14T00:00:00.000Z", ...overrides, }; } function expectStale(error: unknown) { assert.ok( error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`, ); assert.equal(error.code, "VALIDATION_STALE"); assert.equal(error.status, 409); return true; } // The reported scenario, end to end through the composition the MOCK // `studioSource` actually builds: one shared Asset store behind both mock // gateways. test("an Asset that turns non-decorative between validate and preview makes the validation stale", async () => { const { input } = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT); const assetGateway = input.createStudioAssetGateway(); const gateway = input.createStudioGateway(); const uploaded = await assetGateway.uploadAsset( { file: new File([""], "race2.svg", { type: "image/svg+xml", }), kind: "IMAGE", decorative: true, }, { idempotencyKey: "race2-upload" }, ); assert.equal(uploaded.assetKey, "race2"); assert.equal(uploaded.decorative, true); const created = await gateway.createDocument( caseDraft({ slug: "asset-turns-non-decorative", bodyMarkdown: ':::evidence key="race2" alt="" caption="근거" zoom="false"\n:::', }), { idempotencyKey: "race2-create" }, ); // A decorative Asset may legitimately carry an empty alt, so this is VALID // and stays VALID -- the defect is not here. const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "race2-validate" }, ); assert.equal(report.status, "VALID", JSON.stringify(report.issues)); assert.deepEqual(report.issues, []); // The state the validation depended on changes underneath it: the same // evidence key now resolves to a meaningful image. const updated = await assetGateway.updateAssetMetadata( uploaded.id, { expectedVersion: uploaded.version, decorative: false }, { idempotencyKey: "race2-update" }, ); assert.equal(updated.decorative, false); assert.equal(updated.altText, null); await assert.rejects( gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: "race2-preview" }, ), expectStale, ); // ...and the author is told the real problem when they re-validate, rather // than being sent around a loop with no diagnosis. const revalidated = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "race2-revalidate" }, ); assert.equal(revalidated.status, "INVALID", JSON.stringify(revalidated.issues)); assert.ok( revalidated.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), JSON.stringify(revalidated.issues), ); }); // The literal shape the reviewer reported: a second Asset sharing one // `assetKey`, newer and not decorative, arriving in the shared Map. The Map // is keyed by whatever the caller chooses -- the gateway only reads // `.values()` -- so `id` is what lets two rows share a key. test("a newer non-decorative duplicate for the same assetKey makes the validation stale", async () => { const decorative = assetFixture({ id: "99999999-9999-4999-8999-999999999991", assetKey: "race2", publicPath: "/media/race2-decorative.svg", decorative: true, updatedAt: "2026-08-14T00:00:00.000Z", }); const meaningful = assetFixture({ id: "99999999-9999-4999-8999-999999999992", assetKey: "race2", publicPath: "/media/race2-meaningful.svg", decorative: false, updatedAt: "2026-08-14T00:00:01.000Z", }); const assets = new Map([[decorative.id, decorative]]); const gateway = createMockStudioGateway({ assets }); const created = await gateway.createDocument( caseDraft({ slug: "duplicate-key-arrives-later", bodyMarkdown: ':::evidence key="race2" alt="" caption="근거" zoom="false"\n:::', }), { idempotencyKey: "dup-create" }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "dup-validate" }, ); assert.equal(report.status, "VALID", JSON.stringify(report.issues)); assets.set(meaningful.id, meaningful); await assert.rejects( gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: "dup-preview" }, ), expectStale, ); }); // Reproducibility is load-bearing for this mock: the same logical Asset set // must produce the same revision however the caller happened to insert it. test("the same asset set in two insertion orders yields the same dependency revision", async () => { const first = assetFixture({ id: "99999999-9999-4999-8999-99999999999a", assetKey: "order-a", publicPath: "/media/order-a.svg", }); const second = assetFixture({ id: "99999999-9999-4999-8999-99999999999b", assetKey: "order-b", publicPath: "/media/order-b.svg", }); const third = assetFixture({ id: "99999999-9999-4999-8999-99999999999c", assetKey: "order-c", publicPath: "/media/order-c.svg", }); const revisionOf = async (...assets: Asset[]) => { const gateway = createMockStudioGateway({ assets: new Map(assets.map((asset) => [asset.id, asset])), }); return (await gateway.getDocument(FIXTURE_IDS.fetchJoinCase)).dependencyRevision; }; const forward = await revisionOf(first, second, third); const shuffled = await revisionOf(third, first, second); const reversed = await revisionOf(third, second, first); assert.equal(forward, shuffled); assert.equal(forward, reversed); // ...and a set that differs only in what the render and validation paths // read is a different revision. const changed = await revisionOf(first, second, assetFixture({ ...third, decorative: true })); assert.notEqual(forward, changed); }); // The regression the guard must not cause: the normal authoring flow changes // no Asset, so it must still complete. test("validate -> preview -> publish with no asset change still succeeds", async () => { const asset = assetFixture({ assetKey: "steady", publicPath: "/media/steady.svg" }); const gateway = createMockStudioGateway({ assets: new Map([[asset.assetKey, asset]]), }); const created = await gateway.createDocument( caseDraft({ slug: "steady-authoring-loop", bodyMarkdown: ':::evidence key="steady" alt="근거 그림" caption="근거" zoom="false"\n:::', }), { idempotencyKey: "steady-create" }, ); const report = await gateway.validateDocument( created.id, { expectedVersion: created.version }, { idempotencyKey: "steady-validate" }, ); assert.equal(report.status, "VALID", JSON.stringify(report.issues)); const preview = await gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: "steady-preview" }, ); assert.equal(preview.dependencyRevision, report.dependencyRevision); const published = await gateway.publishDocument( created.id, { expectedVersion: created.version, validationId: report.validationId, previewId: preview.previewId, acknowledgedWarningCodes: report.issues .filter((issue) => issue.severity === "WARNING") .map((issue) => issue.code), }, { idempotencyKey: "steady-publish" }, ); assert.equal(published.event.type, "PUBLISHED"); });