Files
tech-log-frontend/tests/features/tech-log/asset-picker.test.tsx
T
DongHyeonkaandClaude Opus 5 98649585e6 fix: generate fresh upload idempotency keys and reconcile mock preview/validation on evidence keys
Fix round 1 (review of 54d9bf9):

I1: AssetUploadDialog reused one idempotency key across every upload
attempt in a session, generated once when the dialog opened. Every
terminal state re-enables the file input, so retrying with a different
file after a failure sent two distinct payloads under the same key.
The key is now generated fresh inside submit() on each call, matching
every other mutation call site in the repo, and dropped from the
dialog's public props entirely (it was never in the task's own
"Produces" interface).

I2: the mock's validateDocument gate only recognized the one legacy
hardcoded evidence key, completely disconnected from the Asset system
Instant Preview now consults -- so a directive the Picker or upload
dialog inserted always previewed live and then failed validation with
EVIDENCE_UNSUPPORTED for every other key. Extracted the Asset-to-
CatalogEntry mapping (evidenceCatalogEntriesFromAssets) and the
domain's one EVIDENCE-catalog matching rule (evidenceCatalogEntryFor)
into shared domain modules that both the preview path
(instant-preview.tsx) and the validation path (validate-working-copy.ts,
the mock's own createPreview) now call. Reconciled the underlying MOCK
studioSource gap that caused this: built a mock asset gateway
(mock-studio-asset-gateway.ts) sharing one in-memory Asset store with
the mock document gateway, wired per composition-root instance in
create-tech-log-feature-input.ts, so an Asset the editor actually
loaded is visible to validation too, while a key backed by nothing
still fails both paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 05:31:01 +09:00

612 lines
24 KiB
TypeScript

// @vitest-environment jsdom
import assert from "node:assert/strict";
import { afterAll, beforeAll, test } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import {
AssetPicker,
buildEvidenceDirective,
} from "../../../src/features/tech-log/presentation/studio/components/asset-picker.tsx";
import {
AssetUploadDialog,
stateForError,
stateForUploaded,
} from "../../../src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx";
import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.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 { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.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 type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
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";
// jsdom does not implement `<dialog>` -- same polyfill `studio-save-navigation.test.tsx`
// and `studio-decision-authoring.test.tsx` already use for the other Studio dialogs.
// `beforeAll`/`afterAll` (not `beforeEach`/`afterEach`) so the polyfill stays in place
// for the whole file's run, including the global `cleanup()` from `tests/setup.ts`
// that unmounts components (and so runs dialog close-on-unmount effects) between tests.
const originalShowModal = HTMLDialogElement.prototype.showModal;
const originalClose = HTMLDialogElement.prototype.close;
beforeAll(() => {
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
configurable: true,
value(this: HTMLDialogElement) {
this.setAttribute("open", "");
},
});
Object.defineProperty(HTMLDialogElement.prototype, "close", {
configurable: true,
value(this: HTMLDialogElement) {
this.removeAttribute("open");
this.dispatchEvent(new Event("close"));
},
});
});
afterAll(() => {
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
configurable: true,
value: originalShowModal,
});
Object.defineProperty(HTMLDialogElement.prototype, "close", {
configurable: true,
value: originalClose,
});
});
const READY = {
id: "11111111-1111-4111-8111-111111111111",
assetKey: "fetch-strategy-boundary",
kind: "DIAGRAM",
mediaType: "image/svg+xml",
originalFilename: "boundary.svg",
byteSize: 4096,
width: 1080,
height: 420,
altText: "Fetch Join 경계",
decorative: false,
managementStatus: "READY",
publicPath: "/media/fetch-strategy-boundary.svg",
usageCount: 1,
version: 1,
createdAt: "2026-08-14T01:00:00.000Z",
updatedAt: "2026-08-14T01:00:00.000Z",
};
const QUARANTINED = { ...READY, id: "22222222-2222-4222-8222-222222222222", assetKey: "unsafe", managementStatus: "QUARANTINED" };
function gatewayOf(items: unknown[]): StudioAssetGateway {
return {
async listAssets() {
return { items, nextCursor: null } as never;
},
async uploadAsset() {
throw new Error("not used");
},
async getAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
throw new Error("not used");
},
async deleteAsset() {},
};
}
test("builds the evidence directive with escaped attribute values", () => {
assert.equal(
buildEvidenceDirective({
assetKey: "fetch-strategy-boundary",
alt: "Fetch Join 경계",
caption: "그림 1",
zoom: true,
}),
':::evidence key="fetch-strategy-boundary" alt="Fetch Join 경계" caption="그림 1" zoom="true"\n:::',
);
});
test("strips a double quote and folds a newline in an attribute value", () => {
assert.equal(
buildEvidenceDirective({
assetKey: "fetch-strategy-boundary",
alt: 'he said "hi"\nsecond line',
caption: "c",
zoom: false,
}),
':::evidence key="fetch-strategy-boundary" alt="he said hi second line" caption="c" zoom="false"\n:::',
);
});
test("inserts the directive for the chosen asset", async () => {
const user = userEvent.setup();
const inserted: string[] = [];
render(<AssetPicker gateway={gatewayOf([READY])} onInsert={(value) => inserted.push(value)} />);
await user.click(await screen.findByRole("button", { name: /fetch-strategy-boundary/ }));
assert.equal(inserted.length, 1);
assert.ok(inserted[0]!.includes('key="fetch-strategy-boundary"'));
assert.ok(inserted[0]!.startsWith(":::evidence "));
});
test("does not offer a QUARANTINED asset for insertion", async () => {
render(<AssetPicker gateway={gatewayOf([READY, QUARANTINED])} onInsert={() => {}} />);
await screen.findByRole("button", { name: /fetch-strategy-boundary/ });
assert.equal(screen.queryByRole("button", { name: /unsafe/ }), null);
});
test("reports the loaded Asset list once listAssets resolves", async () => {
const loaded: readonly Asset[][] = [];
render(
<AssetPicker
gateway={gatewayOf([READY, QUARANTINED])}
onInsert={() => {}}
onLoaded={(assets) => (loaded as Asset[][]).push([...assets])}
/>,
);
await screen.findByRole("button", { name: /fetch-strategy-boundary/ });
assert.equal(loaded.length, 1);
assert.equal(loaded[0]!.length, 2);
});
// --- upload outcome/error classification: pure functions, tested apart from the dialog ---
test("stateForUploaded maps a READY asset to the READY outcome", () => {
const asset = { ...READY } as unknown as Asset;
assert.deepEqual(stateForUploaded(asset), { kind: "READY", asset });
});
test("stateForUploaded maps a QUARANTINED asset to the QUARANTINED outcome", () => {
const asset = { ...READY, managementStatus: "QUARANTINED" } as unknown as Asset;
assert.deepEqual(stateForUploaded(asset), { kind: "QUARANTINED", asset });
});
test("stateForUploaded maps a REJECTED asset to the REJECTED outcome", () => {
const asset = { ...READY, managementStatus: "REJECTED" } as unknown as Asset;
assert.deepEqual(stateForUploaded(asset), { kind: "REJECTED", asset });
});
test("stateForUploaded maps an ARCHIVED asset to the REJECTED outcome", () => {
const asset = { ...READY, managementStatus: "ARCHIVED" } as unknown as Asset;
assert.deepEqual(stateForUploaded(asset), { kind: "REJECTED", asset });
});
test("stateForError maps PAYLOAD_TOO_LARGE to TOO_LARGE", () => {
const error = new StudioGatewayError({
type: "https://techlog.local/problems/payload-too-large",
title: "PAYLOAD_TOO_LARGE",
status: 413,
detail: "파일이 너무 큽니다.",
code: "PAYLOAD_TOO_LARGE",
});
assert.deepEqual(stateForError(error), { kind: "TOO_LARGE" });
});
test("stateForError maps UNSUPPORTED_MEDIA_TYPE to UNSUPPORTED_TYPE", () => {
const error = new StudioGatewayError({
type: "https://techlog.local/problems/unsupported-media-type",
title: "UNSUPPORTED_MEDIA_TYPE",
status: 415,
detail: "지원하지 않는 형식입니다.",
code: "UNSUPPORTED_MEDIA_TYPE",
});
assert.deepEqual(stateForError(error), { kind: "UNSUPPORTED_TYPE" });
});
test("stateForError maps any other gateway error to TRANSPORT_FAILED with the server detail", () => {
const error = new StudioGatewayError({
type: "https://techlog.local/problems/studio-unavailable",
title: "STUDIO_UNAVAILABLE",
status: 503,
detail: "Studio가 잠시 응답하지 않습니다.",
code: "STUDIO_UNAVAILABLE",
});
assert.deepEqual(stateForError(error), {
kind: "TRANSPORT_FAILED",
message: "Studio가 잠시 응답하지 않습니다.",
});
});
test("stateForError maps a non-gateway error to a generic TRANSPORT_FAILED", () => {
assert.deepEqual(stateForError(new Error("network down")), {
kind: "TRANSPORT_FAILED",
message: "업로드를 전송하지 못했습니다.",
});
});
// --- AssetUploadDialog: the 8 upload/outcome states the design calls for ---
function uploadOnlyGateway(
uploadAsset: StudioAssetGateway["uploadAsset"],
): StudioAssetGateway {
return {
async listAssets() {
return { items: [], nextCursor: null };
},
uploadAsset,
async getAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
throw new Error("not used");
},
async deleteAsset() {},
};
}
function chooseFile(file: File) {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [file] } });
}
const SAMPLE_FILE = new File(["<svg/>"], "boundary.svg", { type: "image/svg+xml" });
test("reports a selection failure and never calls uploadAsset when no file is chosen", () => {
let calls = 0;
const gateway = uploadOnlyGateway(async () => {
calls += 1;
return READY as never;
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [] } });
assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일을 선택하지 못했습니다.");
assert.equal(calls, 0);
});
test("shows an uploading status and disables the file input while the transport is pending", async () => {
let resolveUpload!: (asset: Asset) => void;
const gateway = uploadOnlyGateway(
() => new Promise<Asset>((resolve) => { resolveUpload = resolve; }),
);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드 중입니다."));
assert.equal((document.querySelector('input[type="file"]') as HTMLInputElement).disabled, true);
resolveUpload(READY as unknown as Asset);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
});
test("only calls onUploaded and shows success once the server returns READY", async () => {
const uploaded: Asset[] = [];
const gateway = uploadOnlyGateway(async () => READY as never);
render(
<AssetUploadDialog
gateway={gateway}
kind="DIAGRAM"
onUploaded={(asset) => uploaded.push(asset)}
onClose={() => {}}
/>,
);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
assert.equal(uploaded.length, 1);
assert.equal(uploaded[0]!.assetKey, "fetch-strategy-boundary");
});
test("a QUARANTINED server outcome never calls onUploaded, even though the transport succeeded", async () => {
const uploaded: Asset[] = [];
const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "QUARANTINED" }) as never);
render(
<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "보안 검사에서 격리되어 사용할 수 없습니다."));
assert.equal(uploaded.length, 0);
});
test("a REJECTED server outcome never calls onUploaded", async () => {
const uploaded: Asset[] = [];
const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "REJECTED" }) as never);
render(
<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "서버 검증에서 거절되어 사용할 수 없습니다."));
assert.equal(uploaded.length, 0);
});
test("a PAYLOAD_TOO_LARGE transport rejection shows the size-exceeded message", async () => {
const gateway = uploadOnlyGateway(async () => {
throw new StudioGatewayError({
type: "https://techlog.local/problems/payload-too-large",
title: "PAYLOAD_TOO_LARGE",
status: 413,
detail: "파일이 너무 큽니다.",
code: "PAYLOAD_TOO_LARGE",
});
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다."));
});
test("an UNSUPPORTED_MEDIA_TYPE transport rejection shows the unsupported-type message", async () => {
const gateway = uploadOnlyGateway(async () => {
throw new StudioGatewayError({
type: "https://techlog.local/problems/unsupported-media-type",
title: "UNSUPPORTED_MEDIA_TYPE",
status: 415,
detail: "지원하지 않는 형식입니다.",
code: "UNSUPPORTED_MEDIA_TYPE",
});
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "지원하지 않는 파일 형식입니다."));
});
test("a plain network failure shows the generic transport-failed message", async () => {
const gateway = uploadOnlyGateway(async () => {
throw new Error("offline");
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드를 전송하지 못했습니다."));
});
test("focuses the file input on open, traps Tab inside the dialog, and calls onClose from the close button", async () => {
const user = userEvent.setup();
let closed = 0;
const gateway = uploadOnlyGateway(async () => READY as never);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => (closed += 1)} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const closeButton = screen.getByRole("button", { name: "닫기" });
assert.equal(document.activeElement, input);
input.focus();
await user.tab();
assert.equal(document.activeElement, closeButton);
await user.tab();
assert.equal(document.activeElement, input);
await user.click(closeButton);
assert.equal(closed, 1);
});
// --- CaseFields end-to-end: cursor-preserving insertion + Instant Preview resolves it live ---
test("inserts the directive at the saved cursor position and the live preview renders it without an error panel", async () => {
const user = userEvent.setup();
const gateway = createMockStudioGateway();
const DIAGRAM_ASSET = {
...READY,
id: "33333333-3333-4333-8333-333333333331",
assetKey: "cursor-test-diagram",
altText: "커서 삽입 테스트 다이어그램",
publicPath: "/media/cursor-test-diagram.svg",
};
const assetGateway = gatewayOf([DIAGRAM_ASSET]);
render(
<MemoryRouter initialEntries={[`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={FIXTURE_IDS.redisAdapterCase} />
</StudioProvider>
</MemoryRouter>,
);
const textarea = (await screen.findByLabelText("본문 Markdown")) as HTMLTextAreaElement;
const body = textarea.value;
const cursor = body.indexOf("\n\n");
assert.ok(cursor > 0);
textarea.focus();
textarea.setSelectionRange(cursor, cursor);
await user.click(await screen.findByRole("button", { name: /cursor-test-diagram/ }));
const expectedDirective = buildEvidenceDirective({
assetKey: "cursor-test-diagram",
alt: "커서 삽입 테스트 다이어그램",
caption: "",
zoom: true,
});
assert.equal(
textarea.value,
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
);
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
assert.equal(within(panel).queryByRole("alert"), null);
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
// the zoom trigger, once inside the (closed) zoom dialog -- both share the
// same alt text, so assert on the first (the visible trigger figure).
const [image] = within(panel).getAllByAltText("커서 삽입 테스트 다이어그램");
assert.equal(image!.getAttribute("src"), "/media/cursor-test-diagram.svg");
});
// --- Fix round 1 ---
// I1. `uploadKey`/`idempotencyKey` used to be generated once when the dialog
// opened and reused for every subsequent `submit()` call. Every terminal
// state re-enables the file input, so a user can pick file A, hit
// TOO_LARGE, then pick a *different* file B -- both attempts must not carry
// the same idempotency key.
test("generates a fresh idempotency key for each upload attempt, even after a failure", async () => {
const keys: string[] = [];
const gateway = uploadOnlyGateway(async (_form, options) => {
keys.push(options.idempotencyKey);
if (keys.length === 1) {
throw new StudioGatewayError({
type: "https://techlog.local/problems/payload-too-large",
title: "PAYLOAD_TOO_LARGE",
status: 413,
detail: "파일이 너무 큽니다.",
code: "PAYLOAD_TOO_LARGE",
});
}
return READY as never;
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(new File(["a"], "a.svg", { type: "image/svg+xml" }));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다."));
chooseFile(new File(["b"], "b.svg", { type: "image/svg+xml" }));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
assert.equal(keys.length, 2);
assert.notEqual(keys[0], keys[1]);
});
// I2. `validate-working-copy.ts`'s evidence gate used to be driven entirely
// by the one hardcoded legacy key, disconnected from the Asset system --
// so every directive the Picker/upload dialog inserted previewed live and
// then failed mock validation with EVIDENCE_UNSUPPORTED/EVIDENCE_NOT_FOUND.
// This exercises the real MOCK composition end to end (not a hand-rolled
// gateway): upload through the UI, then validate through the SAME
// composition's document gateway.
test("an asset uploaded through the mock composition previews live and passes mock validation for the same key", async () => {
const user = userEvent.setup();
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const gateway = installed.createStudioGateway();
const assetGateway = installed.createStudioAssetGateway();
const created = await gateway.createDocument(
{
kind: "CASE",
title: "업로드 검증",
slug: "upload-validation-check",
summary: "업로드한 Asset이 검증도 통과하는지 확인합니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: "## 제목\n\n본문입니다.",
},
{ idempotencyKey: "upload-validation-create" },
);
render(
<MemoryRouter initialEntries={[`/studio/documents/${created.id}/edit`]}>
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
<DocumentEditorScreen documentId={created.id} />
</StudioProvider>
</MemoryRouter>,
);
await screen.findByLabelText("본문 Markdown");
await user.click(screen.getByRole("button", { name: "Asset 업로드" }));
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, {
target: { files: [new File(["<svg/>"], "boundary-check.svg", { type: "image/svg+xml" })] },
});
// case-fields.tsx's design decision: a successful upload auto-inserts the
// directive at the cursor and closes the dialog, all in the same update --
// so the reliable thing to await is the body's own final content, not a
// transient dialog status (the dialog itself may already be gone here;
// `StudioProvider` also always renders its own permanently-mounted
// `UnsavedLeaveDialog`, which carries the same native `role="dialog"`, so
// asserting on dialog presence/absence is not a safe signal either way).
await waitFor(() => {
const value = (screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement).value;
assert.ok(value.includes('key="boundary-check"'), value);
});
const textarea = screen.getByLabelText("본문 Markdown") as HTMLTextAreaElement;
// The upload form (asset-picker.tsx's AssetUploadDialog) has no altText
// field, so the auto-inserted directive carries `alt=""` -- fill it in
// here the way an author would by editing the textarea, so the only
// remaining validation question is the evidence key itself (I2's target),
// not the unrelated EVIDENCE_ALT_REQUIRED rule.
const authoredBody = textarea.value.replace('alt=""', 'alt="업로드 확인용 대체 텍스트"');
assert.notEqual(authoredBody, textarea.value);
const currentDraft = { ...created } as Record<string, unknown>;
delete currentDraft.id;
delete currentDraft.version;
delete currentDraft.updatedAt;
const saved = await gateway.saveDocument(
created.id,
{
expectedVersion: created.version,
document: { ...currentDraft, bodyMarkdown: authoredBody } as never,
},
{ idempotencyKey: "upload-validation-save" },
);
const report = await gateway.validateDocument(
created.id,
{ expectedVersion: saved.document.version },
{ idempotencyKey: "upload-validation-validate" },
);
assert.ok(
!report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED" || issue.code === "EVIDENCE_NOT_FOUND"),
JSON.stringify(report.issues),
);
assert.equal(report.status, "VALID", JSON.stringify(report.issues));
const preview = await gateway.createPreview(
created.id,
{ expectedVersion: saved.document.version, validationId: report.validationId },
{ idempotencyKey: "upload-validation-preview" },
);
assert.equal(preview.renderModel.kind, "CASE");
});
// I2. An unknown key (backed by no loaded Asset and no catalog row) must
// still fail on both paths -- the reconciliation must not accidentally open
// the gate for a genuinely dangling reference.
test("a key backed by no asset and no catalog entry still fails mock validation", async () => {
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
const gateway = installed.createStudioGateway();
const created = await gateway.createDocument(
{
kind: "CASE",
title: "미지원 근거",
slug: "dangling-evidence-check",
summary: "근거 없는 키는 여전히 거부됩니다.",
topicId: FIXTURE_IDS.topicJpa,
projectId: FIXTURE_IDS.projectBackend,
relations: [],
problem: "문제",
conclusion: "결론",
environment: "env",
reproduction: "repro",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: ':::evidence key="never-uploaded" alt="근거" caption="근거" zoom="false"\n:::',
},
{ idempotencyKey: "dangling-evidence-create" },
);
const report = await gateway.validateDocument(
created.id,
{ expectedVersion: created.version },
{ idempotencyKey: "dangling-evidence-validate" },
);
assert.ok(report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"));
});