Fix round 3 (review of 7ff9728):
Round 2's gate 1 (supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets)))
still routed through evidenceCatalogEntryFor, matching on id || label ||
publicPath -- gate 2's question, asked over fewer rows, not the resolver's
actual success condition (assetKey === key && managementStatus === "READY"
&& Boolean(publicPath)). Two Asset shapes made the two predicates disagree:
a READY asset with publicPath null/"", and a READY asset whose publicPath
happens to satisfy the legacy /media/${someOtherKey}.svg convention for a
key that isn't its own assetKey. Both passed gate 1 while the resolver
could not produce real pixels, reproducing the validateDocument-VALID /
createPreview-throws-raw-Error disagreement a second time.
Rebuilt gate 1 in validate-working-copy.ts and
adapters/mock/project-public-render-model.ts directly from a new domain
function, supportsEvidenceKeyFromReadyAssets, that mirrors the resolver's
exact condition -- never through evidenceCatalogEntryFor again. Gate 2
keeps reading the merged catalog. Made the mock resolver total (returns a
placeholder instead of throwing, matching instant-preview.tsx's resolver),
removing a comment that asserted an invariant the code did not hold.
Wrapped mock-studio-gateway.ts's idempotent() so any non-StudioGatewayError
that reaches its catch is normalized before crossing the port -- closing
the class generally, not just this instance.
Reverted instant-preview.tsx's own gate 1 to the merged catalog (unlike
the mock adapters, its resolver is provably total, so a loose gate there
only ever degrades to a placeholder) -- round 2's narrowing there was a
separate regression: a CASE referencing a document-catalog-backed key
outside the editor's currently-loaded Asset list blanked the entire
preview instead of degrading one figure.
Pinned both slip-through shapes failing on both mock paths with the
thrown/rejected error's type asserted (StudioGatewayError, never a raw
Error), pinned idempotent()'s new wrapping via a validate/preview race,
pinned EVIDENCE_NOT_FOUND as reachable through validateWorkingCopy
directly, and pinned Instant Preview's graceful degradation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1062 lines
42 KiB
TypeScript
1062 lines
42 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 { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.ts";
|
|
import { validateWorkingCopy } from "../../../src/features/tech-log/adapters/mock/validate-working-copy.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"));
|
|
});
|
|
|
|
// --- 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),
|
|
);
|
|
});
|
|
|
|
// --- Fix round 3 ---
|
|
|
|
// Round 2's gate 1 (`supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))`)
|
|
// still routed through `evidenceCatalogEntryFor` (id || label || publicPath),
|
|
// which is gate 2's question over fewer rows -- not the resolver's actual
|
|
// success condition (`assetKey === key && READY && publicPath truthy`). Two
|
|
// Asset shapes make the two predicates disagree; both are exercised through
|
|
// `MockStudioDependencies.assets` injection directly, the same way a test or
|
|
// harness (or a future adapter) could construct one, bypassing the mock
|
|
// uploader (which always sets a real, matching `publicPath` and so cannot
|
|
// produce either shape itself).
|
|
function assetFixture(overrides: Partial<Asset>): Asset {
|
|
return {
|
|
id: "77777777-7777-4777-8777-777777777771",
|
|
assetKey: "shape-check",
|
|
kind: "IMAGE",
|
|
mediaType: "image/svg+xml",
|
|
originalFilename: "shape.svg",
|
|
byteSize: 10,
|
|
width: null,
|
|
height: null,
|
|
altText: null,
|
|
decorative: false,
|
|
managementStatus: "READY",
|
|
publicPath: "/media/shape-check.svg",
|
|
usageCount: 0,
|
|
version: 1,
|
|
createdAt: "2026-08-14T00:00:00.000Z",
|
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function expectBothPathsRejectSlipThrough(
|
|
assets: Map<string, Asset>,
|
|
directiveKey: string,
|
|
label: string,
|
|
) {
|
|
const gateway = createMockStudioGateway({ assets });
|
|
|
|
const created = await gateway.createDocument(
|
|
{
|
|
kind: "CASE",
|
|
title: `slip-through 확인 (${label})`,
|
|
slug: `slip-through-${label}`,
|
|
summary: "gate 1과 resolver가 다른 조건을 쓰면 일어나는 문제를 확인합니다.",
|
|
topicId: FIXTURE_IDS.topicJpa,
|
|
projectId: FIXTURE_IDS.projectBackend,
|
|
relations: [],
|
|
problem: "문제",
|
|
conclusion: "결론",
|
|
environment: "env",
|
|
reproduction: "repro",
|
|
lastVerifiedOn: "2026-08-14",
|
|
bodyMarkdown: `:::evidence key="${directiveKey}" alt="근거" caption="근거" zoom="false"\n:::`,
|
|
},
|
|
{ idempotencyKey: `slip-through-create-${label}` },
|
|
);
|
|
|
|
const report = await gateway.validateDocument(
|
|
created.id,
|
|
{ expectedVersion: created.version },
|
|
{ idempotencyKey: `slip-through-validate-${label}` },
|
|
);
|
|
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
|
|
assert.ok(
|
|
report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"),
|
|
JSON.stringify(report.issues),
|
|
);
|
|
|
|
await assert.rejects(
|
|
gateway.createPreview(
|
|
created.id,
|
|
{ expectedVersion: created.version, validationId: report.validationId },
|
|
{ idempotencyKey: `slip-through-preview-${label}` },
|
|
),
|
|
(error: unknown) => {
|
|
assert.ok(
|
|
error instanceof StudioGatewayError,
|
|
`expected a StudioGatewayError, got ${String(error)}`,
|
|
);
|
|
assert.equal(error.code, "VALIDATION_STALE");
|
|
return true;
|
|
},
|
|
);
|
|
}
|
|
|
|
for (const [publicPath, label] of [
|
|
[null, "null-path"],
|
|
["", "empty-path"],
|
|
] as const) {
|
|
test(`a READY asset with a ${label === "null-path" ? "null" : "empty-string"} publicPath fails both mock paths, never as a raw Error`, async () => {
|
|
const key = `${label}-check`;
|
|
const asset = assetFixture({ assetKey: key, publicPath });
|
|
await expectBothPathsRejectSlipThrough(new Map([[asset.assetKey, asset]]), key, label);
|
|
});
|
|
}
|
|
|
|
test("a READY asset whose publicPath satisfies a different key's legacy convention fails both mock paths, never as a raw Error", async () => {
|
|
// assetKey "real-owner" legitimately owns "/media/real-owner.svg", but its
|
|
// publicPath is set to "/media/decoy-key.svg" instead -- which happens to
|
|
// satisfy `evidenceCatalogEntryFor`'s legacy `/media/${key}.svg` match for
|
|
// the UNRELATED key "decoy-key", a key this asset does not own
|
|
// (`assetKey !== "decoy-key"`). The resolver requires `assetKey === key`,
|
|
// so it can never resolve "decoy-key" from this asset.
|
|
const asset = assetFixture({ assetKey: "real-owner", publicPath: "/media/decoy-key.svg" });
|
|
await expectBothPathsRejectSlipThrough(
|
|
new Map([[asset.assetKey, asset]]),
|
|
"decoy-key",
|
|
"mismatched-path",
|
|
);
|
|
});
|
|
|
|
// idempotent()'s new wrapping (part 3 of the ruling) is not exercised by the
|
|
// shapes above -- those are caught by validateDocument's now-correct gate,
|
|
// so createPreview only ever reaches its pre-existing VALIDATION_STALE
|
|
// guard, itself already a StudioGatewayError. To exercise the *new*
|
|
// wrapping, force a genuine internal disagreement: validate while the Asset
|
|
// still backs the key (VALID), then remove it before createPreview, without
|
|
// bumping dependencyRevision -- a narrow but real staleness gap the mock's
|
|
// existing guard does not close on its own. projectWorkingCopy's gate,
|
|
// re-evaluated fresh inside createPreview against the now-mutated assets,
|
|
// correctly rejects -- and that internal ContentFormatError must not leave
|
|
// the port unwrapped.
|
|
test("idempotent() wraps a raw domain error into a StudioGatewayError, never lets it escape the port", async () => {
|
|
const asset = assetFixture({ assetKey: "race-check" });
|
|
const assets = new Map<string, Asset>([[asset.assetKey, asset]]);
|
|
const gateway = createMockStudioGateway({ assets });
|
|
|
|
const created = await gateway.createDocument(
|
|
{
|
|
kind: "CASE",
|
|
title: "검증과 미리보기 사이의 경쟁 상태",
|
|
slug: "validate-preview-race-check",
|
|
summary: "검증 이후 Asset이 사라지면 gate가 새로 평가되어 거부해야 합니다.",
|
|
topicId: FIXTURE_IDS.topicJpa,
|
|
projectId: FIXTURE_IDS.projectBackend,
|
|
relations: [],
|
|
problem: "문제",
|
|
conclusion: "결론",
|
|
environment: "env",
|
|
reproduction: "repro",
|
|
lastVerifiedOn: "2026-08-14",
|
|
bodyMarkdown: `:::evidence key="race-check" alt="근거" caption="근거" zoom="false"\n:::`,
|
|
},
|
|
{ idempotencyKey: "race-create" },
|
|
);
|
|
|
|
const report = await gateway.validateDocument(
|
|
created.id,
|
|
{ expectedVersion: created.version },
|
|
{ idempotencyKey: "race-validate" },
|
|
);
|
|
assert.equal(report.status, "VALID", JSON.stringify(report.issues));
|
|
|
|
assets.delete("race-check");
|
|
|
|
await assert.rejects(
|
|
gateway.createPreview(
|
|
created.id,
|
|
{ expectedVersion: created.version, validationId: report.validationId },
|
|
{ idempotencyKey: "race-preview" },
|
|
),
|
|
(error: unknown) => {
|
|
assert.ok(
|
|
error instanceof StudioGatewayError,
|
|
`expected a StudioGatewayError, got ${String(error)}`,
|
|
);
|
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
// EVIDENCE_NOT_FOUND has been unreachable through `createMockStudioGateway()`
|
|
// on its own: the fixture catalog it always loads already carries a row for
|
|
// the one legacy key, so gate 1 passing (via the legacy key) always implied
|
|
// gate 2 passing too. Pinned directly against `validateWorkingCopy` instead,
|
|
// with a hand-built catalog that omits the row on purpose.
|
|
test("EVIDENCE_NOT_FOUND is reachable through validateWorkingCopy directly: the legacy key with no catalog EVIDENCE row", () => {
|
|
const document = {
|
|
id: "88888888-8888-4888-8888-888888888881",
|
|
version: 1,
|
|
updatedAt: "2026-08-14T00:00:00.000Z",
|
|
kind: "CASE",
|
|
title: "레거시 키인데 카탈로그에 없음",
|
|
slug: "legacy-key-no-catalog-row",
|
|
summary: "레거시 키는 gate 1을 통과하지만 카탈로그에 행이 없으면 gate 2가 거부해야 합니다.",
|
|
topicId: FIXTURE_IDS.topicJpa,
|
|
projectId: FIXTURE_IDS.projectBackend,
|
|
relations: [],
|
|
problem: "문제",
|
|
conclusion: "결론",
|
|
environment: "env",
|
|
reproduction: "repro",
|
|
lastVerifiedOn: "2026-08-14",
|
|
bodyMarkdown: ':::evidence key="fetch-strategy-boundary" alt="근거" caption="근거" zoom="false"\n:::',
|
|
};
|
|
|
|
const report = validateWorkingCopy(document as never, {
|
|
now: new Date("2026-08-14T01:00:00.000Z"),
|
|
validationId: "evidence-not-found-check",
|
|
dependencyRevision: "r1",
|
|
catalog: [
|
|
{ id: FIXTURE_IDS.topicJpa, type: "TOPIC", label: "JPA", dependencyRevision: "r1" },
|
|
{ id: FIXTURE_IDS.projectBackend, type: "PROJECT", label: "Backend", dependencyRevision: "r1" },
|
|
// Deliberately no EVIDENCE row for "fetch-strategy-boundary" -- gate 1
|
|
// (the legacy key) passes, gate 2 (this catalog) must not.
|
|
] as never,
|
|
documents: [document as never],
|
|
assets: [],
|
|
});
|
|
|
|
assert.equal(report.status, "INVALID", JSON.stringify(report.issues));
|
|
assert.ok(
|
|
report.issues.some((issue) => issue.code === "EVIDENCE_NOT_FOUND"),
|
|
JSON.stringify(report.issues),
|
|
);
|
|
});
|
|
|
|
// Low note (reviewer): round 2's assets-only gate 1 in InstantPreview made a
|
|
// CASE referencing a document-catalog-backed key blank the WHOLE preview
|
|
// behind the error panel whenever that key's Asset was not (yet) in the
|
|
// loaded `assets` array -- outside the Picker's 50-item page, or simply
|
|
// because `assets` is still `[]` while `listAssets` is in flight. Fix round
|
|
// 3 reverted InstantPreview's gate 1 to the merged catalog; this pins that
|
|
// such a document now degrades the one unresolved figure to a placeholder
|
|
// instead of failing the whole preview.
|
|
test("Instant Preview degrades to a placeholder for a document-catalog-backed key with no loaded Asset, instead of blanking the whole preview", async () => {
|
|
const user = userEvent.setup();
|
|
const gateway = createMockStudioGateway();
|
|
const assetGateway = gatewayOf([]);
|
|
|
|
const created = await gateway.createDocument(
|
|
{
|
|
kind: "CASE",
|
|
title: "카탈로그로만 뒷받침되는 근거 미리보기",
|
|
slug: "catalog-only-evidence-preview-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:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`,
|
|
},
|
|
{ idempotencyKey: "catalog-only-evidence-preview-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("tab", { name: "즉시 미리보기" }));
|
|
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
|
|
|
assert.equal(within(panel).queryByRole("alert"), null);
|
|
assert.ok(
|
|
within(panel).getByRole("heading", { level: 1, name: "카탈로그로만 뒷받침되는 근거 미리보기" }),
|
|
);
|
|
});
|