Files
tech-log-frontend/tests/features/tech-log/mock-dependency-revision.test.ts
T
DongHyeonkaandClaude Opus 5 d84b57bb3f 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>
2026-08-18 18:44:53 +09:00

273 lines
9.7 KiB
TypeScript

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");
});