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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cf45bcc7dc
commit
d84b57bb3f
@@ -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<string, Asset>([[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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>): 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(["<svg xmlns='http://www.w3.org/2000/svg'/>"], "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<string, Asset>([[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<string, Asset>([[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");
|
||||
});
|
||||
Reference in New Issue
Block a user