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>
This commit is contained in:
DongHyeonka
2026-08-18 05:31:01 +09:00
co-authored by Claude Opus 5
parent 54d9bf9120
commit 98649585e6
12 changed files with 571 additions and 106 deletions
+186 -18
View File
@@ -17,8 +17,10 @@ import {
} 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";
@@ -253,12 +255,12 @@ test("reports a selection failure and never calls uploadAsset when no file is ch
calls += 1;
return READY as never;
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
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").textContent, "파일을 선택하지 못했습니다.");
assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일을 선택하지 못했습니다.");
assert.equal(calls, 0);
});
@@ -267,15 +269,15 @@ test("shows an uploading status and disables the file input while the transport
const gateway = uploadOnlyGateway(
() => new Promise<Asset>((resolve) => { resolveUpload = resolve; }),
);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드 중입니다."));
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").textContent, "업로드했습니다."));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
});
test("only calls onUploaded and shows success once the server returns READY", async () => {
@@ -285,7 +287,6 @@ test("only calls onUploaded and shows success once the server returns READY", as
<AssetUploadDialog
gateway={gateway}
kind="DIAGRAM"
idempotencyKey="k1"
onUploaded={(asset) => uploaded.push(asset)}
onClose={() => {}}
/>,
@@ -293,7 +294,7 @@ test("only calls onUploaded and shows success once the server returns READY", as
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드했습니다."));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "업로드했습니다."));
assert.equal(uploaded.length, 1);
assert.equal(uploaded[0]!.assetKey, "fetch-strategy-boundary");
});
@@ -302,12 +303,12 @@ test("a QUARANTINED server outcome never calls onUploaded, even though the trans
const uploaded: Asset[] = [];
const gateway = uploadOnlyGateway(async () => ({ ...READY, managementStatus: "QUARANTINED" }) as never);
render(
<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "보안 검사에서 격리되어 사용할 수 없습니다."));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "보안 검사에서 격리되어 사용할 수 없습니다."));
assert.equal(uploaded.length, 0);
});
@@ -315,12 +316,12 @@ 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" idempotencyKey="k1" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={(asset) => uploaded.push(asset)} onClose={() => {}} />,
);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "서버 검증에서 거절되어 사용할 수 없습니다."));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "서버 검증에서 거절되어 사용할 수 없습니다."));
assert.equal(uploaded.length, 0);
});
@@ -334,11 +335,11 @@ test("a PAYLOAD_TOO_LARGE transport rejection shows the size-exceeded message",
code: "PAYLOAD_TOO_LARGE",
});
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "파일 크기가 허용 범위를 넘었습니다."));
await waitFor(() => assert.equal(screen.getByRole("status", { name: "업로드 상태" }).textContent, "파일 크기가 허용 범위를 넘었습니다."));
});
test("an UNSUPPORTED_MEDIA_TYPE transport rejection shows the unsupported-type message", async () => {
@@ -351,29 +352,29 @@ test("an UNSUPPORTED_MEDIA_TYPE transport rejection shows the unsupported-type m
code: "UNSUPPORTED_MEDIA_TYPE",
});
});
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "지원하지 않는 파일 형식입니다."));
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" idempotencyKey="k1" onUploaded={() => {}} onClose={() => {}} />);
render(<AssetUploadDialog gateway={gateway} kind="IMAGE" onUploaded={() => {}} onClose={() => {}} />);
chooseFile(SAMPLE_FILE);
await waitFor(() => assert.equal(screen.getByRole("status").textContent, "업로드를 전송하지 못했습니다."));
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" idempotencyKey="k1" onUploaded={() => {}} onClose={() => (closed += 1)} />);
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: "닫기" });
@@ -441,3 +442,170 @@ test("inserts the directive at the saved cursor position and the live preview re
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"));
});