fix: un-collapse evidence gate 1 from gate 2 to close a raw-Error escape
Fix round 2 (review of 9864958):
Round 1's I2 fix made gate 1 (supportsEvidenceKeyIn) read the same
merged catalog gate 2 already checks, so gate 1 stopped asking "does a
real Asset (or the legacy key) back this key" and started asking the
identical question gate 2 asks ("is there any EVIDENCE catalog row for
it"). A real EVIDENCE catalog row that exists for something other than
media (fixtures.ts's row for a QUESTION resolution target) then passed
gate 1 with nothing backing it as evidence. validateDocument reported
VALID; createPreview's resolver -- which only ever knew the legacy key
and real Assets -- threw a raw, unwrapped Error, violating the port's
StudioGatewayError-only contract. I2's disagreement reproduced in the
opposite direction.
Rebuilt gate 1 in all three callers (validate-working-copy.ts,
adapters/mock/project-public-render-model.ts, instant-preview.tsx) as
"legacy key OR an Asset-derived catalog entry only" -- never the
merged document catalog -- while gate 2 keeps reading the merged
catalog as before. Also stopped emitting the real Asset UUID as the
synthesized CatalogEntry's id (prefixed instead), closing a path where
pasting an Asset's real id -- never something the Picker itself
produces -- would have resolved as an evidence key.
Restored content-format.test.ts's domain tests to exercise the same
gate-1 composition production callers now use instead of a bare
hand-injected predicate, and added coverage for: a real non-asset
EVIDENCE row still being rejected, an Asset-backed key being accepted
with no document-catalog row at all, the fixture-UUID case failing
both mock paths with no raw Error crossing the gateway port, and the
EVIDENCE_ALT_REQUIRED decorative-Asset pairing that had no test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
98649585e6
commit
7ff9728a5c
@@ -22,18 +22,36 @@ type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
|
|||||||
// and Instant Preview use, so this mock's own `createPreview` agrees with
|
// and Instant Preview use, so this mock's own `createPreview` agrees with
|
||||||
// `validateDocument` about which evidence keys exist), so it resolves the
|
// `validateDocument` about which evidence keys exist), so it resolves the
|
||||||
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
|
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
|
||||||
|
//
|
||||||
|
// Fix round 2 (I2 regression). Gate 1 (`supportsEvidenceKey` below) must ask
|
||||||
|
// "does a real Asset (or the legacy key) back this", not "is there any
|
||||||
|
// EVIDENCE catalog row for it" -- `catalog` carries EVIDENCE rows unrelated
|
||||||
|
// to media (e.g. a QUESTION resolution-target row). Handing gate 1 the
|
||||||
|
// merged catalog let a key backed by nothing pass the projection and reach
|
||||||
|
// the resolver below, whose own `isSupportedEvidenceKey`-only fallback then
|
||||||
|
// threw a raw, unwrapped `Error` -- the disagreement in the opposite
|
||||||
|
// direction from I2 (preview crashing instead of `validateDocument`
|
||||||
|
// rejecting), and a contract violation on top (`StudioGatewayError` is what
|
||||||
|
// may leave this port, never a bare `Error`). With gate 1 narrowed to
|
||||||
|
// asset-backing/legacy only, any key the resolver would fail to resolve was
|
||||||
|
// already rejected before `projectWorkingCopyWithEvidence` returns, so the
|
||||||
|
// resolver's fallback throw below is unreachable for every key that made it
|
||||||
|
// this far.
|
||||||
export function projectWorkingCopy(
|
export function projectWorkingCopy(
|
||||||
input: ProjectArguments[0],
|
input: ProjectArguments[0],
|
||||||
catalog: ProjectArguments[1],
|
catalog: ProjectArguments[1],
|
||||||
context: ProjectArguments[2],
|
context: ProjectArguments[2],
|
||||||
assets: readonly Asset[] = [],
|
assets: readonly Asset[] = [],
|
||||||
) {
|
) {
|
||||||
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
|
const assetEntries = evidenceCatalogEntriesFromAssets(assets);
|
||||||
|
const evidenceCatalog = [...catalog, ...assetEntries];
|
||||||
|
const supportsEvidenceKey = (key: string) =>
|
||||||
|
isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key);
|
||||||
const model = projectWorkingCopyWithEvidence(
|
const model = projectWorkingCopyWithEvidence(
|
||||||
input,
|
input,
|
||||||
evidenceCatalog,
|
evidenceCatalog,
|
||||||
context,
|
context,
|
||||||
supportsEvidenceKeyIn(evidenceCatalog),
|
supportsEvidenceKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return resolveCaseEvidenceAssets(model, (key) => {
|
return resolveCaseEvidenceAssets(model, (key) => {
|
||||||
@@ -52,6 +70,10 @@ export function projectWorkingCopy(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!isSupportedEvidenceKey(key)) {
|
if (!isSupportedEvidenceKey(key)) {
|
||||||
|
// Defensive only -- gate 1 above already rejects any key that would
|
||||||
|
// reach here without a resolvable Asset or the legacy key. Kept as a
|
||||||
|
// guard rather than removed so a future gate regression fails loudly
|
||||||
|
// here too, not silently.
|
||||||
throw new Error(`Unknown local evidence asset: ${key}`);
|
throw new Error(`Unknown local evidence asset: ${key}`);
|
||||||
}
|
}
|
||||||
return resolveEvidenceAssetDescriptor(key);
|
return resolveEvidenceAssetDescriptor(key);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
} from "../../domain/content-format/asset-evidence-catalog.ts";
|
||||||
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
|
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
|
||||||
import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts";
|
import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts";
|
||||||
|
import { isSupportedEvidenceKey } from "../static/evidence-assets.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
type WorkingCopyInput = components["schemas"]["WorkingCopyInput"];
|
type WorkingCopyInput = components["schemas"]["WorkingCopyInput"];
|
||||||
@@ -23,6 +24,11 @@ export type ValidationDependencies = {
|
|||||||
* failed validation. Merged into `catalog` via
|
* failed validation. Merged into `catalog` via
|
||||||
* `evidenceCatalogEntriesFromAssets`, the same function Instant Preview
|
* `evidenceCatalogEntriesFromAssets`, the same function Instant Preview
|
||||||
* uses, so both paths agree on which keys a document may reference.
|
* uses, so both paths agree on which keys a document may reference.
|
||||||
|
*
|
||||||
|
* Fix round 2: only used for gate 2 (the merged-catalog lookup) and the
|
||||||
|
* `EVIDENCE_ALT_REQUIRED` decorative check. Gate 1 (`supportsEvidenceKey`
|
||||||
|
* below) intentionally does NOT read the merged catalog -- see that
|
||||||
|
* variable's comment.
|
||||||
*/
|
*/
|
||||||
assets: ReadonlyArray<Asset>;
|
assets: ReadonlyArray<Asset>;
|
||||||
};
|
};
|
||||||
@@ -176,8 +182,19 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
|||||||
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
||||||
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||||
else try {
|
else try {
|
||||||
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
const assetEntries = evidenceCatalogEntriesFromAssets(dependencies.assets);
|
||||||
const supportsEvidenceKey = supportsEvidenceKeyIn(evidenceCatalog);
|
const evidenceCatalog = [...dependencies.catalog, ...assetEntries];
|
||||||
|
// Fix round 2 (I2 regression). Gate 1 must ask "does a real Asset (or
|
||||||
|
// the legacy key) back this" -- NOT "is there any EVIDENCE catalog row
|
||||||
|
// for it". `dependencies.catalog` carries EVIDENCE rows unrelated to
|
||||||
|
// media (e.g. a QUESTION resolution-target row), so handing gate 1 the
|
||||||
|
// merged catalog (as fix round 1 did) let a key backed by nothing
|
||||||
|
// resolve here while `createPreview`'s resolver -- which only ever
|
||||||
|
// knew about the legacy key and real Assets -- threw a raw, unwrapped
|
||||||
|
// Error. Gate 2 (`evidenceCatalogEntryFor` below) still reads the
|
||||||
|
// merged catalog; only gate 1 narrows.
|
||||||
|
const supportsEvidenceKey = (key: string) =>
|
||||||
|
isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key);
|
||||||
const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset]));
|
const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset]));
|
||||||
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
|
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
|
||||||
if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
||||||
|
|||||||
@@ -17,14 +17,22 @@ type CatalogEntry = components["schemas"]["CatalogEntry"];
|
|||||||
* preview perfectly and then fail validation. Both paths now call this one
|
* preview perfectly and then fail validation. Both paths now call this one
|
||||||
* function instead.
|
* function instead.
|
||||||
*
|
*
|
||||||
* Every `EVIDENCE` `CatalogEntry` the document catalog issues today already
|
* Fix round 2 (I2 regression): this module supplies the *building blocks* for
|
||||||
* carries the referenced key in `label` (see the one pre-existing fixture:
|
* gate 1 (`supportsEvidenceKey` — "may this document reference this key at
|
||||||
* `{ type: "EVIDENCE", label: "fetch-strategy-boundary" }`), so a `READY`
|
* all") and gate 2 (`evidenceCatalogEntryFor` — "does some catalog actually
|
||||||
* Asset is mapped the same way: `label` <- `assetKey`. A key backed by no
|
* carry it"); it deliberately does not hand callers a single combined
|
||||||
* loaded `READY` Asset and no catalog row synthesizes no entry here, so
|
* predicate. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)` for BOTH
|
||||||
* `evidenceCatalogEntryFor` (the domain's one matching rule, shared by both
|
* gates, which made gate 1 ask the identical question gate 2 already asks —
|
||||||
* gates) still rejects it -- this cannot forge a pass for a dangling
|
* so any `EVIDENCE` catalog row backing something other than an Asset (e.g.
|
||||||
* reference.
|
* the pre-existing "Fetch Join Case" row that exists for QUESTION resolution
|
||||||
|
* targets, not media) passed gate 1 even though no Asset and no legacy key
|
||||||
|
* back it. Callers must instead compose gate 1 from *asset-backing only*:
|
||||||
|
* `(key) => isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))(key)`,
|
||||||
|
* then hand gate 2 the merged catalog (`[...documentCatalog,
|
||||||
|
* ...evidenceCatalogEntriesFromAssets(assets)]`) separately. See
|
||||||
|
* `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`,
|
||||||
|
* and `instant-preview.tsx` for the three compositions (each has its own
|
||||||
|
* notion of "legacy key" per its layer).
|
||||||
*/
|
*/
|
||||||
export function evidenceCatalogEntriesFromAssets(
|
export function evidenceCatalogEntriesFromAssets(
|
||||||
assets: readonly Asset[],
|
assets: readonly Asset[],
|
||||||
@@ -32,7 +40,14 @@ export function evidenceCatalogEntriesFromAssets(
|
|||||||
return assets
|
return assets
|
||||||
.filter((asset) => asset.managementStatus === "READY")
|
.filter((asset) => asset.managementStatus === "READY")
|
||||||
.map((asset) => ({
|
.map((asset) => ({
|
||||||
id: asset.id,
|
// Not `asset.id` (fix round 2): `evidenceCatalogEntryFor` also matches
|
||||||
|
// on `id`, so reusing the real Asset UUID here would let anyone who
|
||||||
|
// has seen that UUID elsewhere (an API response, an admin view) type
|
||||||
|
// it directly as a directive key and have it resolve — a path the
|
||||||
|
// Picker never produces (it always emits `assetKey`) and that widens
|
||||||
|
// the reference surface for no benefit. Prefixed so it can never
|
||||||
|
// collide with a real `asset.id` a caller might paste in.
|
||||||
|
id: `asset-evidence:${asset.id}`,
|
||||||
type: "EVIDENCE",
|
type: "EVIDENCE",
|
||||||
label: asset.assetKey,
|
label: asset.assetKey,
|
||||||
publicPath: asset.publicPath ?? undefined,
|
publicPath: asset.publicPath ?? undefined,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
projectWorkingCopy,
|
projectWorkingCopy,
|
||||||
resolveCaseEvidenceAssets,
|
resolveCaseEvidenceAssets,
|
||||||
} from "../../../domain/content-format/project-public-render-model.ts";
|
} from "../../../domain/content-format/project-public-render-model.ts";
|
||||||
|
import type { SupportsEvidenceKey } from "../../../domain/public-render-content.ts";
|
||||||
import { createAssetCatalogResolver } from "../../shared/public-render/asset-resolvers.ts";
|
import { createAssetCatalogResolver } from "../../shared/public-render/asset-resolvers.ts";
|
||||||
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
@@ -19,16 +20,29 @@ type PublicRenderModel = components["schemas"]["PublicRenderModel"];
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The one legacy key predates the Asset gateway entirely: the document
|
* The one legacy key predates the Asset gateway entirely: the document
|
||||||
* catalog carries a fixture `EVIDENCE` row for it (so the gates above pass
|
* catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record
|
||||||
* it), but no `Asset` record backs it, so `assets` never resolves it. Kept
|
* backs it, so `assets` never resolves it. This is also gate 1's "legacy"
|
||||||
* narrow and separate from `supportsEvidenceKeyIn` -- that function decides
|
* half now (fix round 2) -- see `supportsEvidenceKeyForAssets` below.
|
||||||
* whether a document is *allowed* to reference a key; this one only fills in
|
|
||||||
* the one pre-existing key's pixels when nothing else can.
|
|
||||||
*/
|
*/
|
||||||
function isLegacyStaticEvidenceKey(key: string): boolean {
|
function isLegacyStaticEvidenceKey(key: string): boolean {
|
||||||
return key === "fetch-strategy-boundary";
|
return key === "fetch-strategy-boundary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate 1: "may this document reference this key at all" -- the legacy key,
|
||||||
|
* or a `READY` Asset actually backs it. Deliberately narrower than gate 2
|
||||||
|
* (the merged-catalog lookup `projectWorkingCopy` runs internally): fix
|
||||||
|
* round 1 collapsed this into `supportsEvidenceKeyIn(effectiveCatalog)`,
|
||||||
|
* which made gate 1 ask the same question as gate 2 and let any `EVIDENCE`
|
||||||
|
* catalog row backing something other than an Asset (e.g. a QUESTION
|
||||||
|
* resolution-target row) pass gate 1 with no Asset and no legacy key behind
|
||||||
|
* it. See `asset-evidence-catalog.ts`'s module doc for the shared shape.
|
||||||
|
*/
|
||||||
|
function supportsEvidenceKeyForAssets(assets: readonly Asset[]): SupportsEvidenceKey {
|
||||||
|
const assetOnly = supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets));
|
||||||
|
return (key: string) => isLegacyStaticEvidenceKey(key) || assetOnly(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
|
* `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
|
||||||
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
|
* model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
|
||||||
@@ -97,7 +111,7 @@ export function InstantPreview({
|
|||||||
draft,
|
draft,
|
||||||
effectiveCatalog,
|
effectiveCatalog,
|
||||||
{ mode: "PREVIEW", publishedAt: null },
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
supportsEvidenceKeyIn(effectiveCatalog),
|
supportsEvidenceKeyForAssets(assets),
|
||||||
),
|
),
|
||||||
resolveAssetDescriptor(assets),
|
resolveAssetDescriptor(assets),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { DocumentEditorScreen } from "../../../src/features/tech-log/presentatio
|
|||||||
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||||
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
||||||
|
import { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts";
|
||||||
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||||
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts";
|
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts";
|
||||||
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
|
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
|
||||||
@@ -609,3 +610,181 @@ test("a key backed by no asset and no catalog entry still fails mock validation"
|
|||||||
);
|
);
|
||||||
assert.ok(report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"));
|
assert.ok(report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Fix round 2 ---
|
||||||
|
|
||||||
|
// Round 1's `supportsEvidenceKeyIn(mergedCatalog)` made gate 1 ask the exact
|
||||||
|
// question gate 2 asks, so a real `EVIDENCE` catalog row backing something
|
||||||
|
// other than media (fixtures.ts:37 -- a row that exists for QUESTION
|
||||||
|
// resolution targets, not an Asset) passed gate 1 with no Asset and no
|
||||||
|
// legacy key behind it. `validateDocument` said VALID; `createPreview`'s
|
||||||
|
// resolver, which only ever knew the legacy key, then threw a raw
|
||||||
|
// `Error` -- I2's disagreement in the opposite direction, plus a bare
|
||||||
|
// `Error` escaping a port whose contract is `StudioGatewayError` only.
|
||||||
|
test("a directive key equal to a non-asset EVIDENCE catalog row's id is rejected on both mock paths, never as a raw Error", async () => {
|
||||||
|
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
||||||
|
const gateway = installed.createStudioGateway();
|
||||||
|
|
||||||
|
const created = await gateway.createDocument(
|
||||||
|
{
|
||||||
|
kind: "CASE",
|
||||||
|
title: "카탈로그 행 오용",
|
||||||
|
slug: "catalog-row-misuse-check",
|
||||||
|
summary: "카탈로그에 EVIDENCE 행이 있다고 해서 Asset이 있는 건 아닙니다.",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: FIXTURE_IDS.projectBackend,
|
||||||
|
relations: [],
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "env",
|
||||||
|
reproduction: "repro",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
// FIXTURE_IDS.fetchJoinCase backs an EVIDENCE catalog row too
|
||||||
|
// (fixtures.ts:37, label "Fetch Join Case") -- that row is for
|
||||||
|
// QUESTION resolution targets, not media.
|
||||||
|
bodyMarkdown: `:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: "catalog-row-misuse-create" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = await gateway.validateDocument(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version },
|
||||||
|
{ idempotencyKey: "catalog-row-misuse-validate" },
|
||||||
|
);
|
||||||
|
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
|
||||||
|
assert.ok(
|
||||||
|
report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"),
|
||||||
|
JSON.stringify(report.issues),
|
||||||
|
);
|
||||||
|
|
||||||
|
// createPreview requires a current, error-free validation -- an INVALID
|
||||||
|
// one must reject with a StudioGatewayError (VALIDATION_STALE), never let
|
||||||
|
// a raw Error escape the port.
|
||||||
|
await assert.rejects(
|
||||||
|
gateway.createPreview(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version, validationId: report.validationId },
|
||||||
|
{ idempotencyKey: "catalog-row-misuse-preview" },
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(
|
||||||
|
error instanceof StudioGatewayError,
|
||||||
|
`expected a StudioGatewayError, got ${String(error)}`,
|
||||||
|
);
|
||||||
|
assert.equal(error.code, "VALIDATION_STALE");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same case pinned at the lower level: the mock's own `projectWorkingCopy`
|
||||||
|
// wrapper (adapters/mock/project-public-render-model.ts) must reject at the
|
||||||
|
// gate -- a `ContentFormatError`-flavored "supported local evidence key not
|
||||||
|
// found" -- rather than ever reaching the resolver's `isSupportedEvidenceKey`
|
||||||
|
// fallback and its raw `Error("Unknown local evidence asset: ...")`. This
|
||||||
|
// pins the fix even if some future change lets `createPreview`'s own
|
||||||
|
// staleness guard stop being the thing that prevents the call in practice.
|
||||||
|
test("the mock's projectWorkingCopy wrapper rejects a non-asset EVIDENCE catalog row at the gate, not at the resolver", () => {
|
||||||
|
const catalog = [
|
||||||
|
{ id: FIXTURE_IDS.topicJpa, type: "TOPIC" as const, label: "JPA", dependencyRevision: "r1" },
|
||||||
|
{
|
||||||
|
id: FIXTURE_IDS.fetchJoinCase,
|
||||||
|
type: "EVIDENCE" as const,
|
||||||
|
label: "Fetch Join Case",
|
||||||
|
publicPath: "/cases/collection-fetch-join-pagination",
|
||||||
|
dependencyRevision: "r1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const input = {
|
||||||
|
kind: "CASE" as const,
|
||||||
|
title: "t",
|
||||||
|
slug: "wrapper-gate-check",
|
||||||
|
summary: "s",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: null,
|
||||||
|
relations: [],
|
||||||
|
problem: "p",
|
||||||
|
conclusion: "c",
|
||||||
|
environment: "e",
|
||||||
|
reproduction: "r",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown: `:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="a" caption="c" zoom="false"\n:::`,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
mockProjectWorkingCopy(
|
||||||
|
input as never,
|
||||||
|
catalog as never,
|
||||||
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
/supported local evidence key not found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The rode-along fix (validate-working-copy.ts's EVIDENCE_ALT_REQUIRED check
|
||||||
|
// now reads Asset.decorative): a decorative Asset's empty alt must pass, a
|
||||||
|
// non-decorative one's must still fail. No repo test covered this pair
|
||||||
|
// before round 2.
|
||||||
|
test("EVIDENCE_ALT_REQUIRED respects Asset.decorative -- empty alt passes for a decorative Asset, fails for a non-decorative one", async () => {
|
||||||
|
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
||||||
|
const gateway = installed.createStudioGateway();
|
||||||
|
const assetGateway = installed.createStudioAssetGateway();
|
||||||
|
|
||||||
|
const decorative = await assetGateway.uploadAsset(
|
||||||
|
{ file: new File(["<svg/>"], "deco.svg", { type: "image/svg+xml" }), kind: "IMAGE", decorative: true },
|
||||||
|
{ idempotencyKey: "decorative-alt-upload" },
|
||||||
|
);
|
||||||
|
const informative = await assetGateway.uploadAsset(
|
||||||
|
{ file: new File(["<svg/>"], "info.svg", { type: "image/svg+xml" }), kind: "IMAGE" },
|
||||||
|
{ idempotencyKey: "informative-alt-upload" },
|
||||||
|
);
|
||||||
|
assert.equal(decorative.decorative, true);
|
||||||
|
assert.equal(informative.decorative, false);
|
||||||
|
|
||||||
|
async function validateBody(bodyMarkdown: string, slugSuffix: string) {
|
||||||
|
const created = await gateway.createDocument(
|
||||||
|
{
|
||||||
|
kind: "CASE",
|
||||||
|
title: "대체 텍스트 규칙",
|
||||||
|
slug: `alt-rule-check-${slugSuffix}`,
|
||||||
|
summary: "decorative 여부에 따라 대체 텍스트 요구가 달라집니다.",
|
||||||
|
topicId: FIXTURE_IDS.topicJpa,
|
||||||
|
projectId: FIXTURE_IDS.projectBackend,
|
||||||
|
relations: [],
|
||||||
|
problem: "문제",
|
||||||
|
conclusion: "결론",
|
||||||
|
environment: "env",
|
||||||
|
reproduction: "repro",
|
||||||
|
lastVerifiedOn: "2026-08-14",
|
||||||
|
bodyMarkdown,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: `alt-rule-create-${slugSuffix}` },
|
||||||
|
);
|
||||||
|
return gateway.validateDocument(
|
||||||
|
created.id,
|
||||||
|
{ expectedVersion: created.version },
|
||||||
|
{ idempotencyKey: `alt-rule-validate-${slugSuffix}` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decorativeReport = await validateBody(
|
||||||
|
`:::evidence key="${decorative.assetKey}" alt="" caption="" zoom="false"\n:::`,
|
||||||
|
"decorative",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!decorativeReport.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"),
|
||||||
|
JSON.stringify(decorativeReport.issues),
|
||||||
|
);
|
||||||
|
|
||||||
|
const informativeReport = await validateBody(
|
||||||
|
`:::evidence key="${informative.assetKey}" alt="" caption="" zoom="false"\n:::`,
|
||||||
|
"informative",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
informativeReport.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"),
|
||||||
|
JSON.stringify(informativeReport.issues),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts";
|
||||||
|
import { evidenceCatalogEntriesFromAssets } from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts";
|
||||||
import {
|
import {
|
||||||
ContentFormatError,
|
ContentFormatError,
|
||||||
parseCaseContent,
|
parseCaseContent,
|
||||||
} from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
} from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
|
||||||
import { projectWorkingCopy } from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts";
|
import {
|
||||||
|
evidenceCatalogEntryFor,
|
||||||
|
projectWorkingCopy,
|
||||||
|
} from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts";
|
||||||
import { serializeCaseContent } from "../../../src/features/tech-log/domain/content-format/serialize-case-content.ts";
|
import { serializeCaseContent } from "../../../src/features/tech-log/domain/content-format/serialize-case-content.ts";
|
||||||
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
|
import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts";
|
||||||
|
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix round 2. Mirrors the gate-1 composition every production caller now
|
||||||
|
* uses (`validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`,
|
||||||
|
* `instant-preview.tsx`): the legacy key, or an actually-loaded Asset backs
|
||||||
|
* it -- never "is there any EVIDENCE catalog row for it" (that's gate 2,
|
||||||
|
* `evidenceCatalogEntryFor`, a separate question `projectWorkingCopy` asks
|
||||||
|
* of its own `catalog` argument). Round 1 collapsed gate 1 into gate 2 in
|
||||||
|
* every production caller, which let a real EVIDENCE catalog row backing
|
||||||
|
* something other than media (e.g. a QUESTION resolution-target row) pass
|
||||||
|
* gate 1 with no Asset and no legacy key behind it. Tests below pass no
|
||||||
|
* `assets`, so this reduces to the legacy check alone for them -- the same
|
||||||
|
* outcome bare `isSupportedEvidenceKey` gave before, just now visibly tied
|
||||||
|
* to the real composition instead of standing in for it by coincidence.
|
||||||
|
*/
|
||||||
|
function supportsEvidenceKey(
|
||||||
|
key: string,
|
||||||
|
assets: readonly Asset[] = [],
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
isSupportedEvidenceKey(key) ||
|
||||||
|
Boolean(evidenceCatalogEntryFor(evidenceCatalogEntriesFromAssets(assets), key))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rich = `## 측정 결과 {#measurements}
|
const rich = `## 측정 결과 {#measurements}
|
||||||
|
|
||||||
@@ -354,7 +383,7 @@ describe("Public render model projection", () => {
|
|||||||
generatedAt: "2026-08-14T00:00:00Z",
|
generatedAt: "2026-08-14T00:00:00Z",
|
||||||
dependencyRevision: "r1",
|
dependencyRevision: "r1",
|
||||||
},
|
},
|
||||||
isSupportedEvidenceKey,
|
supportsEvidenceKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(model.kind).toBe("CASE");
|
expect(model.kind).toBe("CASE");
|
||||||
@@ -390,7 +419,7 @@ describe("Public render model projection", () => {
|
|||||||
input,
|
input,
|
||||||
catalog,
|
catalog,
|
||||||
{ mode: "PREVIEW", publishedAt: null },
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
isSupportedEvidenceKey,
|
supportsEvidenceKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(model.kind).toBe("QUESTION");
|
expect(model.kind).toBe("QUESTION");
|
||||||
@@ -411,7 +440,7 @@ describe("Public render model projection", () => {
|
|||||||
},
|
},
|
||||||
catalog,
|
catalog,
|
||||||
{ mode: "PREVIEW", publishedAt: null },
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
isSupportedEvidenceKey,
|
supportsEvidenceKey,
|
||||||
),
|
),
|
||||||
).toThrow(/EVIDENCE catalog/);
|
).toThrow(/EVIDENCE catalog/);
|
||||||
});
|
});
|
||||||
@@ -440,7 +469,7 @@ describe("Public render model projection", () => {
|
|||||||
caseWithEvidence(key),
|
caseWithEvidence(key),
|
||||||
unsupportedEvidenceCatalog,
|
unsupportedEvidenceCatalog,
|
||||||
{ mode: "PREVIEW", publishedAt: null },
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
isSupportedEvidenceKey,
|
supportsEvidenceKey,
|
||||||
),
|
),
|
||||||
).toThrow(/supported local evidence key/);
|
).toThrow(/supported local evidence key/);
|
||||||
}
|
}
|
||||||
@@ -456,4 +485,58 @@ describe("Public render model projection", () => {
|
|||||||
it("rejects a catalog-matching unsupported evidence label", () => {
|
it("rejects a catalog-matching unsupported evidence label", () => {
|
||||||
expectUnsupportedEvidence("unsupported-label");
|
expectUnsupportedEvidence("unsupported-label");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fix round 2 regression coverage. `catalog` above already carries
|
||||||
|
// "resolution-evidence" -- a real `EVIDENCE` row that exists for a
|
||||||
|
// QUESTION resolution target (see "requires EVIDENCE" above), not media.
|
||||||
|
// A production caller whose gate 1 collapsed into gate 2 (round 1's bug)
|
||||||
|
// would let this key through with no Asset and no legacy key backing it;
|
||||||
|
// this is the exact class of catalog row the reviewer used to reproduce
|
||||||
|
// that regression.
|
||||||
|
it("rejects a real EVIDENCE catalog row that exists for a different purpose than media (round 2 regression)", () => {
|
||||||
|
expect(() =>
|
||||||
|
projectWorkingCopy(
|
||||||
|
caseWithEvidence("resolution-evidence"),
|
||||||
|
catalog,
|
||||||
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
|
supportsEvidenceKey,
|
||||||
|
),
|
||||||
|
).toThrow(/supported local evidence key/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a key backed by a loaded READY Asset even though the document catalog carries no matching row", () => {
|
||||||
|
const assets: Asset[] = [
|
||||||
|
{
|
||||||
|
id: "99999999-9999-4999-8999-999999999999",
|
||||||
|
assetKey: "boundary-check",
|
||||||
|
kind: "DIAGRAM",
|
||||||
|
mediaType: "image/svg+xml",
|
||||||
|
originalFilename: "boundary.svg",
|
||||||
|
byteSize: 10,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
altText: null,
|
||||||
|
decorative: false,
|
||||||
|
managementStatus: "READY",
|
||||||
|
publicPath: "/media/boundary-check.svg",
|
||||||
|
usageCount: 0,
|
||||||
|
version: 1,
|
||||||
|
createdAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// `catalog` alone has no row for "boundary-check" -- gate 2 only passes
|
||||||
|
// because the caller merges in `evidenceCatalogEntriesFromAssets`, the
|
||||||
|
// same way every production caller does.
|
||||||
|
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
|
||||||
|
|
||||||
|
const model = projectWorkingCopy(
|
||||||
|
caseWithEvidence("boundary-check"),
|
||||||
|
evidenceCatalog,
|
||||||
|
{ mode: "PREVIEW", publishedAt: null },
|
||||||
|
(key: string) => supportsEvidenceKey(key, assets),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.kind).toBe("CASE");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user