diff --git a/docs/reviews/adapters/06-tech-log-asset-upload.md b/docs/reviews/adapters/06-tech-log-asset-upload.md index 96208f9..abdd838 100644 --- a/docs/reviews/adapters/06-tech-log-asset-upload.md +++ b/docs/reviews/adapters/06-tech-log-asset-upload.md @@ -63,6 +63,34 @@ canonical Studio API 전체는 19개 operation이다. `tech-log-studio-contract- 두 경로 모두 `StudioAssetUploadTransport`/`StudioAssetGateway` 포트 경계 뒤에서 일어나므로, presentation 계층(Task 11의 Asset Library UI)은 재작성하지 않는다. +## MOCK 의존성 리비전은 계약의 요구가 아니라 mock의 구현이다 + +`createMockStudioGateway`의 기본 `dependencyRevision.current()`가 무엇을 관찰하는지 — 그리고 그것이 **계약이 요구하는 계산이 아니라는 점** — 을 여기에 남긴다. 나중에 mock의 구현을 계약의 요구로 오독하지 않기 위해서다. + +### 실제 백엔드 + +`DependencyRevision`(`studio-api.openapi.yaml` / `generated.ts`)은 값의 **형식**만 계약이다: 불투명한 문자열. 계약이 요구하는 것은 값이 아니라 규칙 하나뿐이다 — *검증에 사용한 dependency set을 publish 시 다시 계산해 값이 다르면 `VALIDATION_STALE`로 거절한다.* 무엇을 dependency set에 넣을지(Topic/Project 존재, relation target 상태, Asset READY/QUARANTINED 상태, slug/route ownership, catalog revision, 필요 시 renderer/content-format version), 그리고 그것을 어떻게 정규화·hash할지는 **서버가 스스로 정한다.** 프론트엔드는 이 값을 생성하지도, 해석하지도, 비교하지도 않는다. `ValidationReport.dependencyRevision`을 받아 그대로 되돌려 보내고, 서버가 내린 `VALIDATION_STALE` 판정을 표시할 뿐이다. HTTP gateway(`http-studio-gateway.ts`)에는 리비전을 계산하는 코드가 없다 — 있어서도 안 된다. + +### MOCK + +MOCK `studioSource`에는 그 서버가 없으므로, mock이 같은 규칙을 스스로 만족시켜야 한다. 기본 리비전은 `dependency-revision.ts`의 `mockDependencyRevision`이 계산한다. + +- **catalog 성분**: `MOCK_CATALOG_REVISION`(`"catalog-2026-08-14"`) 상수. `createMockStudioState`가 고정 fixture catalog 하나를 싣고 변경하지 않으므로 catalog의 기여는 실제로 상수다. `fixtures.ts`의 seed validation/preview도 같은 정의를 import해 쓴다 — 두 값이 갈라지면 seed된 문서가 전부 조용히 stale이 된다. +- **asset 성분**: Asset store를 정규화해 만든 128비트 digest. 각 Asset을 **투영(projection)** 으로 줄이고(`id`, `assetKey`, `managementStatus`, `publicPath`, `updatedAt`, `decorative`, `altText`, `mediaType`, `width`, `height`), `stableStringify`로 정규 문자열을 만든 뒤 정렬해 접는다. 따라서 `Map` 삽입 순서와 무관하게 같은 논리적 Asset 집합은 항상 같은 리비전을 낸다 — 이 mock의 재현성은 저장소 전체 테스트가 의존하는 성질이다. +- 빈 store는 성분을 더하지 않아 `MOCK_CATALOG_REVISION` 그대로다. seed fixture가 Asset이 없는 세계에서 만들어졌고 그 문자열을 그대로 싣기 때문이다. + +레코드 전체가 아니라 투영을 hash하는 이유: `usageCount`는 그 Asset을 참조하는 문서 수라 실제 백엔드였다면 **아무 문서나 publish할 때마다** 다른 저자의 진행 중인 검증이 전부 무효가 된다 — 검증기도 렌더러도 읽지 않는 필드인데도. `version`은 이 mock에서 `updatedAt`과 함께 움직여 신호를 더하지 않고, `kind`·`originalFilename`·`byteSize`·`createdAt`은 검증에도 render model에도 도달하지 않는다. + +### 이 기본값이 닫는 구멍 + +기본 리비전이 리터럴 상수였을 때, `createStudioPreview`/`publishStudioDocument`의 staleness guard는 Asset store를 전혀 관찰하지 못했다. 그래서 **validate와 preview 사이의 Asset 변경이 guard에게 보이지 않았다.** 구체적으로: 어떤 evidence key의 Asset이 `decorative: true`뿐이면 `alt=""`인 directive는 정당하게 VALID다(장식용 이미지는 대체 텍스트가 없어도 된다). 그 사이에 같은 key에 `decorative: false`인 더 새로운 Asset이 도착하면, `createStudioPreview`는 성공하고 figure는 `decorative: false, alt: ""`로 해석되며 `publishDocument`가 그 render model을 그대로 snapshot한다. **의미 있는 이미지가 접근 가능한 이름 없이, 검증은 깨끗한 채로, 아무도 오류를 보고하지 않은 채 공개된다.** 이제 그 변경이 리비전을 움직여 guard가 `VALIDATION_STALE`을 내고, 저자가 재검증하면 `EVIDENCE_ALT_REQUIRED`로 진짜 문제를 듣는다. + +수정은 `findResolvableAsset`이 아니라 리비전에 있다. `findResolvableAsset`은 *한 시점의* 술어이고 그 자체로는 옳다 — 두 시점 사이의 변화를 보는 것은 리비전의 일이다. + +**주의**: 위 필드 목록은 이 mock이 스스로 무엇을 읽는지에 대한 서술이지, 서버가 무엇을 dependency set에 넣어야 하는지에 대한 요구가 아니다. 서버는 프론트엔드가 볼 수 없는 것(예: relation target의 게시 상태, route ownership)까지 포함할 수 있고 그래야 한다. 이 mock을 계약의 참조 구현으로 삼지 말 것. + +고정 테스트: `tests/features/tech-log/mock-dependency-revision.test.ts`(보고된 시나리오 end-to-end, 순서 무관 결정성, 무변경 authoring loop 안정성). + ## 검증 ``` @@ -71,6 +99,7 @@ corepack pnpm exec vitest run tests/features/tech-log/studio-asset-gateway.test. corepack pnpm exec vitest run tests/features/tech-log/runtime-composition.test.ts corepack pnpm exec vitest run tests/features/tech-log/studio-csrf-composition.test.ts corepack pnpm exec vitest run tests/features/tech-log/studio-session-csrf.test.ts +corepack pnpm exec vitest run tests/features/tech-log/mock-dependency-revision.test.ts corepack pnpm check:types corepack pnpm test:tech-log ``` diff --git a/src/features/tech-log/adapters/mock/dependency-revision.ts b/src/features/tech-log/adapters/mock/dependency-revision.ts new file mode 100644 index 0000000..94236e1 --- /dev/null +++ b/src/features/tech-log/adapters/mock/dependency-revision.ts @@ -0,0 +1,109 @@ +import type { Asset } from "../../contracts/studio/contract.ts"; +import { stableStringify } from "../stable-stringify.ts"; + +/** + * The catalog half of the mock's dependency revision. `createMockStudioState` + * loads one frozen fixture catalog and never mutates it, so the catalog's + * contribution is a constant -- exactly what it has always been. It stays the + * leading component (rather than being dropped as "constant anyway") because + * it is what the seeded fixture validations and previews carry, and because a + * future mutable catalog has an obvious place to attach. + */ +export const MOCK_CATALOG_REVISION = "catalog-2026-08-14"; + +/** + * The Asset fields the mock's own validation and projection actually read. + * + * This is a *projection*, not the whole `Asset` record, and the choice is + * load-bearing in both directions. + * + * Under-sensitivity leaves the hole this exists to close: `decorative`, + * `altText`, `publicPath` and `managementStatus` decide whether a figure + * needs an accessible name and whether it renders at all, while `id`, + * `assetKey` and `updatedAt` decide *which* Asset a key resolves to + * (`findResolvableAsset` is newest-wins, `id`-tiebroken), so a change to any + * of them can turn a clean validation into a wrong publication. + * `mediaType`/`width`/`height` are copied verbatim into the published + * `ResolvedAsset`, so a change to them changes the snapshot a publish would + * freeze. + * + * Over-sensitivity costs spurious `VALIDATION_STALE`, and hashing the whole + * record buys exactly that. `usageCount` is the clearest case: it counts the + * documents referencing the Asset, so on a real backend publishing *any* + * document that uses an Asset would invalidate every other author's in-flight + * validation -- while changing nothing the validator or the renderer reads. + * `version` moves with `updatedAt` on every mutation this mock performs, so + * it adds no signal; `kind`, `originalFilename`, `byteSize` and `createdAt` + * reach neither validation nor the render model at all (`kind` only filters + * the Picker's displayed list). + */ +function dependedUponFields(asset: Asset) { + return { + id: asset.id, + assetKey: asset.assetKey, + managementStatus: asset.managementStatus, + publicPath: asset.publicPath, + updatedAt: asset.updatedAt, + decorative: asset.decorative, + altText: asset.altText, + mediaType: asset.mediaType, + width: asset.width, + height: asset.height, + }; +} + +/** FNV-1a, the same 32-bit construction `adapters/static/evidence-assets.ts` uses. */ +function fnv1a(input: string, seed: number): number { + let hash = (seed >>> 0) || 0x811c9dc5; + for (const character of input) { + hash ^= character.codePointAt(0) ?? 0; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +const hex8 = (value: number) => (value >>> 0).toString(16).padStart(8, "0"); + +/** + * Four independently-seeded 32-bit FNV-1a passes concatenated into 128 bits. + * A single 32-bit digest would collide often enough for a store of a few + * hundred Assets to be a real (if unlikely) source of a missed staleness; + * 128 bits removes that from consideration without pulling in a hash the + * browser bundle would otherwise not carry, and it mirrors the construction + * already used elsewhere in this feature. + */ +function digest(input: string): string { + return ( + hex8(fnv1a(input, 0x811c9dc5)) + + hex8(fnv1a(`${input}:b`, 0x01000193)) + + hex8(fnv1a(`${input}:c`, 0x9e3779b9)) + + hex8(fnv1a(`${input}:d`, 0x85ebca6b)) + ); +} + +/** + * The revision the mock's staleness guards compare across a validate -> + * preview -> publish sequence. + * + * Deterministic and order-independent by construction: each Asset is reduced + * to a canonical string (`stableStringify` sorts keys and drops `undefined`), + * and the resulting strings are sorted with the default comparator -- UTF-16 + * code-unit order, which does not vary with the ambient locale -- before + * being folded together. Two callers holding the same logical Asset set in + * different `Map` insertion orders therefore read the same revision, which is + * what keeps this mock reproducible across the suite. + * + * An empty store contributes no component. That is not a special case for its + * own sake: the seeded fixture validations and previews in `fixtures.ts` were + * produced against a world with no Assets, and they carry + * `MOCK_CATALOG_REVISION` verbatim. Deleting the last Asset from a store + * returns to the same string, so the mapping stays consistent in both + * directions. + */ +export function mockDependencyRevision(assets: Iterable): string { + const canonical = [...assets] + .map((asset) => stableStringify(dependedUponFields(asset))) + .sort(); + if (canonical.length === 0) return MOCK_CATALOG_REVISION; + return `${MOCK_CATALOG_REVISION}+assets-${digest(stableStringify(canonical))}`; +} diff --git a/src/features/tech-log/adapters/mock/fixtures.ts b/src/features/tech-log/adapters/mock/fixtures.ts index 2007dc1..d499f89 100644 --- a/src/features/tech-log/adapters/mock/fixtures.ts +++ b/src/features/tech-log/adapters/mock/fixtures.ts @@ -1,9 +1,16 @@ import type { components } from "../../contracts/studio/generated.ts"; import type { PublicationAggregate, PublicationEvent, PublicationSnapshot, PublicPreview, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts"; +import { MOCK_CATALOG_REVISION } from "./dependency-revision.ts"; import { projectWorkingCopy } from "./project-public-render-model.ts"; type CatalogEntry = components["schemas"]["CatalogEntry"]; -const REVISION = "catalog-2026-08-14"; +// The seeded validations and previews below are the mock's "already +// validated" world, and the staleness guards compare their +// `dependencyRevision` against whatever the gateway reports now. These +// fixtures carry no Assets, so the value they must carry is the +// empty-store revision -- shared from one definition rather than retyped, +// because drifting from it would silently mark every seeded document stale. +const REVISION = MOCK_CATALOG_REVISION; const CONTENT_FORMAT_VERSION = "1"; const RENDERER_CONTRACT_VERSION = "1"; diff --git a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts index b685c6f..59f481a 100644 --- a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -5,6 +5,7 @@ import type { Asset, CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, P import { ContentFormatError } from "../../domain/content-format/parse-case-content.ts"; import { deriveDocumentState, derivePreviewState } from "../../domain/studio/document-state.ts"; import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts"; +import { mockDependencyRevision } from "./dependency-revision.ts"; import { createMockStudioState, MockStudioState } from "./mock-state.ts"; import { projectWorkingCopy } from "./project-public-render-model.ts"; import { stableStringify } from "../stable-stringify.ts"; @@ -15,6 +16,20 @@ export { createMockStudioState } from "./mock-state.ts"; export type MockStudioDependencies = { clock: { now(): Date }; idGenerator: { next(): string }; + /** + * What the staleness guards in `createPreview`/`publishDocument` compare a + * prior validation against. Injectable, and an injected one still wins + * outright -- a test that wants a revision it controls (to pin one, or to + * make one throw) supplies its own and this module's default never runs. + * + * The default now folds `assets` in (`mockDependencyRevision`) rather than + * returning a literal constant. While it was a constant it could not + * observe the Asset store at all, so an Asset mutated between validate and + * preview was invisible to the guard: a document whose only figure was + * decorative (empty `alt` legitimately VALID) could have that Asset turn + * non-decorative, preview clean, and publish a meaningful image with no + * accessible name, with nothing anywhere reporting an error. + */ dependencyRevision: { current(): string }; /** * Fix round 1 (I2). Shared with a sibling `createMockStudioAssetGateway` @@ -35,9 +50,9 @@ const cp = (value: string) => [...value].length; const clone = (value: T): T => structuredClone(value); const normalizeQ = (value?: string) => (value ?? "").trim().replace(/\s+/g, " ").toLocaleLowerCase("ko-KR"); -function defaults(): MockStudioDependencies { +function defaults(assets: ReadonlyMap): MockStudioDependencies { let id = 5000; - return { clock: { now: () => new Date(DEFAULT_STUDIO_MOCK_NOW) }, idGenerator: { next: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(id++).padStart(12, "0")}` }, dependencyRevision: { current: () => "catalog-2026-08-14" }, assets: new Map() }; + return { clock: { now: () => new Date(DEFAULT_STUDIO_MOCK_NOW) }, idGenerator: { next: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(id++).padStart(12, "0")}` }, dependencyRevision: { current: () => mockDependencyRevision(assets.values()) }, assets }; } function gatewayProblem(status: number, code: ProblemDetails["code"], detail: string, extras: Partial = {}) { @@ -97,8 +112,13 @@ function exactSet(left: string[], right: string[]) { } export function createMockStudioGateway(supplied: Partial = {}): StudioGateway { - const fallback = defaults(); - const dependencies: MockStudioDependencies = { clock: supplied.clock ?? fallback.clock, idGenerator: supplied.idGenerator ?? fallback.idGenerator, dependencyRevision: supplied.dependencyRevision ?? fallback.dependencyRevision, assets: supplied.assets ?? fallback.assets }; + // The fallback revision has to read whichever map this gateway ends up + // with, so the assets are resolved before the defaults are built -- a + // default constructed against a private empty map would keep reporting the + // empty-store revision while the caller's map filled up. + const assets = supplied.assets ?? new Map(); + const fallback = defaults(assets); + const dependencies: MockStudioDependencies = { clock: supplied.clock ?? fallback.clock, idGenerator: supplied.idGenerator ?? fallback.idGenerator, dependencyRevision: supplied.dependencyRevision ?? fallback.dependencyRevision, assets }; const state: MockStudioState = createMockStudioState(); async function boundary(options?: RequestOptions) { diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx index f4c01f7..71f7e18 100644 --- a/tests/features/tech-log/asset-picker.test.tsx +++ b/tests/features/tech-log/asset-picker.test.tsx @@ -945,10 +945,23 @@ test("a READY asset whose publicPath satisfies a different key's legacy conventi // A deterministic content failure between validate and preview is exactly // what VALIDATION_STALE/409 means, and it is not retryable without // re-validating first. +// +// The alignment follow-up that taught the *default* dependency revision to +// read the Asset store closed exactly the gap this test used to reach the +// projection through: deleting the Asset now moves the revision, so the guard +// fires first and `failureOf`'s ContentFormatError branch would never run +// again. That branch still has to hold -- a projection can refuse for reasons +// the revision cannot see (a directive the parser rejects against a catalog +// row) -- so this test pins the revision itself and keeps reaching the +// projection, and asserts the *detail* to prove which of the two paths +// produced the 409. `mock-dependency-revision.test.ts` covers the guard path. test("idempotent() maps a deterministic content failure to VALIDATION_STALE, never lets a raw error escape the port", async () => { const asset = assetFixture({ assetKey: "race-check" }); const assets = new Map([[asset.assetKey, asset]]); - const gateway = createMockStudioGateway({ assets }); + const gateway = createMockStudioGateway({ + assets, + dependencyRevision: { current: () => "pinned-so-the-guard-cannot-fire" }, + }); const created = await gateway.createDocument( { @@ -986,6 +999,9 @@ test("idempotent() maps a deterministic content failure to VALIDATION_STALE, nev assert.equal(error.code, "VALIDATION_STALE"); assert.equal(error.status, 409); assert.equal(error.retryable, false); + // The projection's failure, wrapped by `failureOf` -- not the guard's + // own "Current validation without errors is required." + assert.match(error.problem.detail ?? "", /no longer renders against current dependencies/); return true; }; diff --git a/tests/features/tech-log/mock-dependency-revision.test.ts b/tests/features/tech-log/mock-dependency-revision.test.ts new file mode 100644 index 0000000..89889e3 --- /dev/null +++ b/tests/features/tech-log/mock-dependency-revision.test.ts @@ -0,0 +1,272 @@ +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"); +});