From d84b57bb3fc4856f0fbeca0eae18c6901715db94 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 18:44:53 +0900 Subject: [PATCH] fix: let the mock's dependency revision observe the Asset store `createMockStudioGateway`'s default `dependencyRevision.current()` returned a literal constant and never consulted `dependencies.assets`, so the staleness guards in `createStudioPreview` and `publishStudioDocument` could not fire for an asset-store mutation between validate and preview. The live case: a document references an evidence key with `alt=""` and the store holds only a `decorative: true` Asset for it, so validation is correctly VALID with zero issues. A newer `decorative: false` Asset then wins that key. Preview succeeds, the figure resolves to `decorative: false, alt: ""`, and publish snapshots it verbatim -- a meaningful image with no accessible name, validated clean, with nothing anywhere reporting an error. The default now folds the Asset store into the revision. Each Asset is reduced to the fields the mock's own validation and projection read -- identity and resolution order (`id`, `assetKey`, `updatedAt`), resolvability (`managementStatus`, `publicPath`), the alt rule (`decorative`, `altText`), and what the published `ResolvedAsset` carries (`mediaType`, `width`, `height`) -- canonicalized with `stableStringify`, sorted, and folded into a 128-bit FNV-1a digest. Sorting the canonical strings is what makes it order-independent, which this mock's reproducibility across the suite depends on. It is a projection rather than the whole record because the excluded fields cost sensitivity without buying any. `usageCount` is the clearest: it counts referencing documents, 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 renderer reads. An empty store still reports the bare catalog constant -- that is the world the seeded fixtures were validated against, and `fixtures.ts` now shares the one definition rather than retyping the literal. `findResolvableAsset` is untouched: it is a single-point-in-time predicate and is correct as it stands. Seeing a change *between* two points is the revision's job. A caller-supplied `dependencyRevision` still wins outright. The asset-picker test that reached `failureOf`'s `ContentFormatError` branch did so only because the revision could not move; it now pins its own revision to keep reaching the projection, and asserts the problem detail so the two 409 paths cannot be confused. Co-Authored-By: Claude Opus 5 (1M context) --- .../adapters/06-tech-log-asset-upload.md | 29 ++ .../adapters/mock/dependency-revision.ts | 109 +++++++ .../tech-log/adapters/mock/fixtures.ts | 9 +- .../adapters/mock/mock-studio-gateway.ts | 28 +- tests/features/tech-log/asset-picker.test.tsx | 18 +- .../tech-log/mock-dependency-revision.test.ts | 272 ++++++++++++++++++ 6 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 src/features/tech-log/adapters/mock/dependency-revision.ts create mode 100644 tests/features/tech-log/mock-dependency-revision.test.ts 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"); +});