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
@@ -2,6 +2,7 @@ import {
TECH_LOG_FEATURE_ID,
type TechLogFeatureInput,
} from "../application/tech-log-feature-input.ts";
import type { Asset } from "../contracts/studio/contract.ts";
import { createAssetUploadTransport } from "./http/asset-upload-transport.ts";
import { createHttpStudioAssetGateway } from "./http/http-studio-asset-gateway.ts";
import {
@@ -9,6 +10,7 @@ import {
type StudioOperationExecutor,
} from "./http/http-studio-gateway.ts";
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
import { publicContentQueries } from "./static/public-query.ts";
@@ -34,22 +36,34 @@ export type TechLogInstallContext = Readonly<{
export function createTechLogFeatureInstalledInput(
context: TechLogInstallContext,
) {
// The Asset gateway is needed even on MOCK: with no backend behind it, the
// list comes back empty and uploads fail with a transport error — the UI
// surfacing that state is the correct behavior, not a bug to route around.
// Fix round 1 (I2). MOCK `studioSource` has no real backend behind either
// gateway, so — unlike HTTP, where the real server is shared, external
// authority for both — the mock document gateway and a mock asset gateway
// must share ONE in-memory Asset store, or the mock's own `validateDocument`
// could never learn about an Asset the Picker just loaded/uploaded. This map
// is scoped to this one `createTechLogFeatureInstalledInput` call (the
// composition root calls it once per app instance), so every
// `createStudioGateway`/`createStudioAssetGateway` pair produced from this
// same `input` — across every `StudioProvider` mount over the app's
// lifetime — shares it, the same way a real backend's asset store outlives
// any one editing session. It is never read on the HTTP branch.
const mockAssets = new Map<string, Asset>();
const createStudioAssetGateway = () =>
createHttpStudioAssetGateway({
operations: context.contractOperations,
csrf: context.csrf,
upload: createAssetUploadTransport({
baseUrl: context.apiBaseUrl,
timeoutMs: context.requestTimeoutMs,
}),
});
context.studioSource === "MOCK"
? createMockStudioAssetGateway(mockAssets)
: createHttpStudioAssetGateway({
operations: context.contractOperations,
csrf: context.csrf,
upload: createAssetUploadTransport({
baseUrl: context.apiBaseUrl,
timeoutMs: context.requestTimeoutMs,
}),
});
const createStudioGateway = () =>
context.studioSource === "MOCK"
? createMockStudioGateway()
? createMockStudioGateway({ assets: mockAssets })
: createHttpStudioGateway({ operations: context.contractOperations });
const input: TechLogFeatureInput = Object.freeze({
@@ -0,0 +1,197 @@
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type {
ListAssetsQuery,
StudioAssetGateway,
UploadAssetForm,
} from "../../application/ports/studio-asset-gateway.ts";
import type { IdempotentOptions, RequestOptions } from "../../application/ports/studio-gateway.ts";
import type {
Asset,
AssetDetail,
AssetPage,
ProblemDetails,
UpdateAssetCommand,
} from "../../contracts/studio/contract.ts";
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
export type MockAssetDependencies = {
now(): Date;
idGenerator: { next(): string };
};
function defaults(): MockAssetDependencies {
return {
now: () => new Date(),
idGenerator: { next: () => globalThis.crypto.randomUUID() },
};
}
function gatewayProblem(status: number, code: ProblemDetails["code"], detail: string): StudioGatewayError {
return new StudioGatewayError({
type: `https://techlog.local/problems/${code.toLowerCase().replaceAll("_", "-")}`,
title: code,
status,
detail,
code,
retryable: false,
});
}
const EXTENSION_BY_MEDIA_TYPE: Readonly<Record<string, string>> = {
"image/svg+xml": "svg",
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
"application/pdf": "pdf",
};
function slugify(filename: string): string {
const base = filename.replace(/\.[^./\\]+$/u, "");
const slug = base
.toLocaleLowerCase("en-US")
.replaceAll(/[^a-z0-9]+/gu, "-")
.replaceAll(/^-+|-+$/gu, "");
return slug || "asset";
}
function uniqueAssetKey(filename: string, taken: ReadonlySet<string>): string {
const base = slugify(filename);
if (!taken.has(base)) return base;
let suffix = 2;
while (taken.has(`${base}-${suffix}`)) suffix += 1;
return `${base}-${suffix}`;
}
/**
* MOCK `studioSource` has no real backend behind either Studio gateway, so
* without this, the mock document gateway (`createMockStudioGateway`) and
* whatever asset gateway a MOCK session used had no way to agree on which
* evidence keys exist -- Fix round 1 (I2). This exists purely to make the
* two mock gateways consistent with each other: it stores uploads in the
* SAME `assets` map `createTechLogFeatureInstalledInput` hands to
* `createMockStudioGateway`, so a key the Asset Picker just inserted
* previews AND validates the same way.
*
* It intentionally does not simulate QUARANTINED/REJECTED server outcomes,
* upload transport failures, or idempotency replay -- `asset-upload-dialog`'s
* own tests already cover that state machine against hand-rolled gateways.
* This mock's one job is to make the default local/testing experience
* actually usable end to end, not to be a second, competing simulation of
* server-side asset review.
*/
export function createMockStudioAssetGateway(
assets: Map<string, Asset>,
supplied: Partial<MockAssetDependencies> = {},
): StudioAssetGateway {
const dependencies: MockAssetDependencies = { ...defaults(), ...supplied };
async function boundary(options?: RequestOptions) {
options?.signal?.throwIfAborted();
await Promise.resolve();
options?.signal?.throwIfAborted();
}
function findById(assetId: string): Asset | undefined {
return [...assets.values()].find((item) => item.id === assetId);
}
function sorted(items: Asset[]): Asset[] {
return [...items].sort(
(left, right) => right.createdAt.localeCompare(left.createdAt) || left.id.localeCompare(right.id),
);
}
return Object.freeze({
async listAssets(query: ListAssetsQuery, options?: RequestOptions): Promise<AssetPage> {
await boundary(options);
const q = (query.q ?? "").trim().toLocaleLowerCase("ko-KR");
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const normalized = { kind: query.kind ?? null, managementStatus: query.managementStatus ?? null, q };
const all = sorted(
[...assets.values()].filter(
(asset) =>
(!normalized.kind || asset.kind === normalized.kind) &&
(!normalized.managementStatus || asset.managementStatus === normalized.managementStatus) &&
(!normalized.q ||
asset.assetKey.toLocaleLowerCase("ko-KR").includes(normalized.q) ||
asset.originalFilename.toLocaleLowerCase("ko-KR").includes(normalized.q)),
),
);
const binding = cursorBinding(normalized);
const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null;
const source = cursor
? all.filter(
(item) =>
item.createdAt.localeCompare(cursor.lastValue) < 0 ||
(item.createdAt === cursor.lastValue && item.id > cursor.lastId),
)
: all;
const selected = source.slice(0, limit);
const last = selected.at(-1);
return {
items: selected,
nextCursor:
selected.length < source.length && last
? encodeCursor({ binding, lastValue: last.createdAt, lastId: last.id })
: null,
};
},
async uploadAsset(form: UploadAssetForm, options: IdempotentOptions): Promise<Asset> {
await boundary(options);
const now = dependencies.now().toISOString();
const assetKey = uniqueAssetKey(form.file.name, new Set(assets.keys()));
const extension = EXTENSION_BY_MEDIA_TYPE[form.file.type] ?? "bin";
const asset: Asset = {
id: dependencies.idGenerator.next(),
assetKey,
kind: form.kind,
mediaType: form.file.type || "application/octet-stream",
originalFilename: form.file.name,
byteSize: form.file.size,
width: null,
height: null,
altText: form.altText ?? null,
decorative: form.decorative ?? false,
managementStatus: "READY",
publicPath: `/media/${assetKey}.${extension}`,
usageCount: 0,
version: 1,
createdAt: now,
updatedAt: now,
};
assets.set(assetKey, asset);
return asset;
},
async getAsset(assetId: string, options?: RequestOptions): Promise<AssetDetail> {
await boundary(options);
const asset = findById(assetId);
if (!asset) throw gatewayProblem(404, "ASSET_NOT_FOUND", `Asset를 찾을 수 없습니다: ${assetId}`);
return { asset, usages: [], hasPublicationHistory: false };
},
async updateAssetMetadata(assetId: string, command: UpdateAssetCommand, options: IdempotentOptions): Promise<Asset> {
await boundary(options);
const asset = findById(assetId);
if (!asset) throw gatewayProblem(404, "ASSET_NOT_FOUND", `Asset를 찾을 수 없습니다: ${assetId}`);
if (asset.version !== command.expectedVersion) {
throw gatewayProblem(409, "VERSION_CONFLICT", "Asset이 이미 변경되었습니다.");
}
const updated: Asset = {
...asset,
kind: command.kind ?? asset.kind,
altText: command.altText === undefined ? asset.altText : command.altText,
decorative: command.decorative ?? asset.decorative,
managementStatus: command.managementStatus ?? asset.managementStatus,
version: asset.version + 1,
updatedAt: dependencies.now().toISOString(),
};
assets.set(updated.assetKey, updated);
return updated;
},
async deleteAsset(assetId: string, options: IdempotentOptions): Promise<void> {
await boundary(options);
const asset = findById(assetId);
if (asset) assets.delete(asset.assetKey);
},
});
}
@@ -1,7 +1,7 @@
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { IdempotentOptions, RequestOptions, StudioGateway } from "../../application/ports/studio-gateway.ts";
import type { components } from "../../contracts/studio/generated.ts";
import type { CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts";
import type { Asset, CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts";
import { deriveDocumentState, derivePreviewState } from "../../domain/studio/document-state.ts";
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
@@ -15,6 +15,14 @@ export type MockStudioDependencies = {
clock: { now(): Date };
idGenerator: { next(): string };
dependencyRevision: { current(): string };
/**
* Fix round 1 (I2). Shared with a sibling `createMockStudioAssetGateway`
* (same `Map` reference) by `create-tech-log-feature-input.ts` so the two
* mock gateways agree on which evidence keys exist. Defaults to a private,
* empty map -- existing direct callers (tests) that never supply one keep
* seeing exactly today's behavior.
*/
assets: ReadonlyMap<string, Asset>;
};
export const DEFAULT_STUDIO_MOCK_NOW = "2026-08-14T01:00:00.000Z";
@@ -28,7 +36,7 @@ const normalizeQ = (value?: string) => (value ?? "").trim().replace(/\s+/g, " ")
function defaults(): MockStudioDependencies {
let id = 5000;
return { clock: { now: () => new Date(DEFAULT_STUDIO_MOCK_NOW) }, idGenerator: { next: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(id++).padStart(12, "0")}` }, dependencyRevision: { current: () => "catalog-2026-08-14" } };
return { clock: { now: () => new Date(DEFAULT_STUDIO_MOCK_NOW) }, idGenerator: { next: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(id++).padStart(12, "0")}` }, dependencyRevision: { current: () => "catalog-2026-08-14" }, assets: new Map() };
}
function gatewayProblem(status: number, code: ProblemDetails["code"], detail: string, extras: Partial<ProblemDetails> = {}) {
@@ -61,7 +69,7 @@ function exactSet(left: string[], right: string[]) {
export function createMockStudioGateway(supplied: Partial<MockStudioDependencies> = {}): StudioGateway {
const fallback = defaults();
const dependencies: MockStudioDependencies = { clock: supplied.clock ?? fallback.clock, idGenerator: supplied.idGenerator ?? fallback.idGenerator, dependencyRevision: supplied.dependencyRevision ?? fallback.dependencyRevision };
const dependencies: MockStudioDependencies = { clock: supplied.clock ?? fallback.clock, idGenerator: supplied.idGenerator ?? fallback.idGenerator, dependencyRevision: supplied.dependencyRevision ?? fallback.dependencyRevision, assets: supplied.assets ?? fallback.assets };
const state: MockStudioState = createMockStudioState();
async function boundary(options?: RequestOptions) {
@@ -118,11 +126,11 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] }); }
const saved = materialize(documentId, current.version + 1, command.document); state.documents.set(documentId, saved); state.validations.delete(documentId); return detail(documentId);
}); },
validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()] }); state.validations.set(documentId, report); return report; }); },
validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()], assets: [...dependencies.assets.values()] }); state.validations.set(documentId, report); return report; }); },
createPreview(documentId, command, options) { return idempotent("preview", documentId, () => command, options, () => {
uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const validation = state.validations.get(documentId); const now = dependencies.clock.now();
if (!validation || validation.validationId !== command.validationId || validation.validatedVersion !== value.version || validation.dependencyRevision !== dependencies.dependencyRevision.current() || now.getTime() >= Date.parse(validation.validUntil) || validation.status === "INVALID") throw gatewayProblem(409, "VALIDATION_STALE", "Current validation without errors is required.");
const createdAt = now.toISOString(); const preview: PublicPreview = { previewId: dependencies.idGenerator.next(), documentId, previewVersion: value.version, validationId: validation.validationId, dependencyRevision: dependencies.dependencyRevision.current(), createdAt, expiresAt: new Date(now.getTime() + 30 * 60_000).toISOString(), renderModel: projectWorkingCopy(inputOf(value), state.catalog, { generatedAt: createdAt, dependencyRevision: dependencies.dependencyRevision.current() }) }; state.previews.set(documentId, preview); return preview;
const createdAt = now.toISOString(); const preview: PublicPreview = { previewId: dependencies.idGenerator.next(), documentId, previewVersion: value.version, validationId: validation.validationId, dependencyRevision: dependencies.dependencyRevision.current(), createdAt, expiresAt: new Date(now.getTime() + 30 * 60_000).toISOString(), renderModel: projectWorkingCopy(inputOf(value), state.catalog, { generatedAt: createdAt, dependencyRevision: dependencies.dependencyRevision.current() }, [...dependencies.assets.values()]) }; state.previews.set(documentId, preview); return preview;
}); },
getCurrentPreview(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); const value = document(documentId); const preview = state.previews.get(documentId); if (!preview) throw gatewayProblem(404, "PREVIEW_NOT_FOUND", "Preview not found."); const validation = state.validations.get(documentId) ?? null; const result = derivePreviewState({ document: value, validation, preview, publication: state.publications.get(documentId) ?? null, dependencyRevision: dependencies.dependencyRevision.current(), now: dependencies.clock.now() }); return { preview, state: result === "NONE" ? "STALE" : result, currentDocumentVersion: value.version, currentValidationId: validation?.validationId ?? null } satisfies PreviewDetail; }); },
publishDocument(documentId, command, options) { return idempotent("publish", documentId, () => ({ ...command, acknowledgedWarningCodes: Array.isArray(command.acknowledgedWarningCodes) ? [...command.acknowledgedWarningCodes].sort() : command.acknowledgedWarningCodes }), options, () => {
@@ -1,3 +1,8 @@
import type { Asset } from "../../contracts/studio/contract.ts";
import {
evidenceCatalogEntriesFromAssets,
supportsEvidenceKeyIn,
} from "../../domain/content-format/asset-evidence-catalog.ts";
import {
projectWorkingCopy as projectWorkingCopyWithEvidence,
resolveCaseEvidenceAssets,
@@ -11,21 +16,41 @@ type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
// The domain projection has no asset catalog access, so a CASE projection's
// EVIDENCE_FIGURE blocks come back without a resolved `asset`. This adapter owns
// the asset catalog (`adapters/static/evidence-assets.ts`), so it resolves the
// the asset catalog (`adapters/static/evidence-assets.ts`, plus -- fix round 1
// (I2) -- whatever `Asset`s the caller passes, merged via
// `evidenceCatalogEntriesFromAssets`, the same function `validate-working-copy.ts`
// and Instant Preview use, so this mock's own `createPreview` agrees with
// `validateDocument` about which evidence keys exist), so it resolves the
// descriptor here to produce a genuine, fully-resolved `PublicRenderModel`.
export function projectWorkingCopy(
input: ProjectArguments[0],
catalog: ProjectArguments[1],
context: ProjectArguments[2],
assets: readonly Asset[] = [],
) {
const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
const model = projectWorkingCopyWithEvidence(
input,
catalog,
evidenceCatalog,
context,
isSupportedEvidenceKey,
supportsEvidenceKeyIn(evidenceCatalog),
);
return resolveCaseEvidenceAssets(model, (key) => {
const asset = assets.find(
(candidate) => candidate.assetKey === key && candidate.managementStatus === "READY",
);
if (asset?.publicPath) {
return {
assetId: asset.id,
assetKey: asset.assetKey,
mediaType: asset.mediaType,
publicPath: asset.publicPath,
width: asset.width,
height: asset.height,
decorative: asset.decorative,
};
}
if (!isSupportedEvidenceKey(key)) {
throw new Error(`Unknown local evidence asset: ${key}`);
}
@@ -1,7 +1,11 @@
import type { components } from "../../contracts/studio/generated.ts";
import type { ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
import {
evidenceCatalogEntriesFromAssets,
supportsEvidenceKeyIn,
} from "../../domain/content-format/asset-evidence-catalog.ts";
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
import { isSupportedEvidenceKey } from "../static/evidence-assets.ts";
import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
type WorkingCopyInput = components["schemas"]["WorkingCopyInput"];
@@ -11,6 +15,16 @@ type ValidationIssue = components["schemas"]["ValidationIssue"];
export type ValidationDependencies = {
now: Date; validationId: string; dependencyRevision: string;
catalog: ReadonlyArray<CatalogEntry>; documents: ReadonlyArray<WorkingCopy>;
/**
* Fix round 1 (I2). Without this, the mock's validate step only ever
* recognized the one hardcoded legacy evidence key -- disconnected from
* the Asset system (Tasks 6/7) the Picker (Task 10) actually inserts keys
* from -- so every Picker-inserted directive previewed live and then
* failed validation. Merged into `catalog` via
* `evidenceCatalogEntriesFromAssets`, the same function Instant Preview
* uses, so both paths agree on which keys a document may reference.
*/
assets: ReadonlyArray<Asset>;
};
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -162,12 +176,17 @@ 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.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
else try {
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
const supportsEvidenceKey = supportsEvidenceKeyIn(evidenceCatalog);
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") {
if (!isSupportedEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
else if (!dependencies.catalog.some((entry) => entry.type === "EVIDENCE" && (entry.id === block.key || entry.label === block.key || entry.publicPath === `/media/${block.key}.svg`))) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`);
else if (block.alt.trim().length === 0) {
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로에서는 서버가
// Asset.decorative로 같은 판정을 한다.
if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
else if (!evidenceCatalogEntryFor(evidenceCatalog, block.key)) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`);
else if (block.alt.trim().length === 0 && !readyAssetsByKey.get(block.key)?.decorative) {
// 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로는 실제
// Asset.decorative로 같은 판정을 한다(decorative Asset은 대체
// 텍스트가 없어도 통과한다 -- asset-picker.tsx가 그렇게 directive를
// 구성한다).
error("EVIDENCE_ALT_REQUIRED", "/bodyMarkdown", `Evidence에 대체 텍스트가 필요합니다: ${block.key}`);
}
}
@@ -0,0 +1,48 @@
import type { components } from "../../contracts/studio/generated.ts";
import type { Asset } from "../../contracts/studio/contract.ts";
import type { SupportsEvidenceKey } from "../public-render-content.ts";
import { evidenceCatalogEntryFor } from "./project-public-render-model.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/**
* The single place that bridges the Asset system (Tasks 6/7) and the
* document catalog `projectWorkingCopy` (Instant Preview) and
* `validateWorkingCopy` (mock validation) gate `EVIDENCE_FIGURE` blocks
* against. Fix round 1 (I2): both paths previously derived their own,
* independently-written notion of "does a loaded Asset make this key
* referenceable" -- Instant Preview learned to accept a freshly loaded
* Asset, but the mock's validate step still only recognized the one
* hardcoded legacy key, so a directive the Asset Picker inserted could
* preview perfectly and then fail validation. Both paths now call this one
* function instead.
*
* Every `EVIDENCE` `CatalogEntry` the document catalog issues today already
* carries the referenced key in `label` (see the one pre-existing fixture:
* `{ type: "EVIDENCE", label: "fetch-strategy-boundary" }`), so a `READY`
* Asset is mapped the same way: `label` <- `assetKey`. A key backed by no
* loaded `READY` Asset and no catalog row synthesizes no entry here, so
* `evidenceCatalogEntryFor` (the domain's one matching rule, shared by both
* gates) still rejects it -- this cannot forge a pass for a dangling
* reference.
*/
export function evidenceCatalogEntriesFromAssets(
assets: readonly Asset[],
): CatalogEntry[] {
return assets
.filter((asset) => asset.managementStatus === "READY")
.map((asset) => ({
id: asset.id,
type: "EVIDENCE",
label: asset.assetKey,
publicPath: asset.publicPath ?? undefined,
dependencyRevision: asset.updatedAt,
}));
}
/** `SupportsEvidenceKey` built from the same rule the catalog-entry gate uses. */
export function supportsEvidenceKeyIn(
catalog: ReadonlyArray<CatalogEntry>,
): SupportsEvidenceKey {
return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key));
}
@@ -59,6 +59,27 @@ function fail(detail: string): never {
throw new ContentFormatError([{ line: 1, column: 1, detail }]);
}
/**
* The one place that decides whether a `CatalogEntry` stands for a given
* evidence key -- an `EVIDENCE`-typed entry matches by `id`, `label`, or the
* legacy static `publicPath` convention. Exported so callers that need to
* synthesize additional `EVIDENCE` entries (from an Asset list, e.g.
* `evidenceCatalogEntriesFromAssets`) test membership against the exact same
* rule this gate itself uses, instead of re-declaring a subset of it.
*/
export function evidenceCatalogEntryFor(
catalog: ReadonlyArray<CatalogEntry>,
key: string,
): CatalogEntry | undefined {
return catalog.find(
(entry) =>
entry.type === "EVIDENCE" &&
(entry.id === key ||
entry.label === key ||
entry.publicPath === `/media/${key}.svg`),
);
}
function catalogEntry(
catalog: ReadonlyArray<CatalogEntry>,
id: string | null,
@@ -188,14 +209,9 @@ export function projectWorkingCopy(
if (!supportsEvidenceKey(block.key)) {
fail(`supported local evidence key not found: ${block.key}`);
}
const evidence = catalog.find(
(entry) =>
entry.type === "EVIDENCE" &&
(entry.id === block.key ||
entry.label === block.key ||
entry.publicPath === `/media/${block.key}.svg`),
);
if (!evidence) fail(`EVIDENCE catalog entry not found: ${block.key}`);
if (!evidenceCatalogEntryFor(catalog, block.key)) {
fail(`EVIDENCE catalog entry not found: ${block.key}`);
}
}
return {
@@ -3,6 +3,7 @@ import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
export type UploadState =
| { kind: "IDLE" }
@@ -52,7 +53,6 @@ const MESSAGES: Record<UploadState["kind"], string> = {
export function AssetUploadDialog(props: Readonly<{
gateway: StudioAssetGateway;
kind: AssetKind;
idempotencyKey: string;
onUploaded: (asset: Asset) => void;
onClose: () => void;
}>) {
@@ -80,9 +80,14 @@ export function AssetUploadDialog(props: Readonly<{
async function submit(file: File) {
setState({ kind: "UPLOADING" });
try {
// Fix round 1 (I1). A fresh key per call, not one generated once when
// the dialog opened: every terminal state re-enables the file input,
// so a user can retry with a *different* file after a failure, and two
// different payloads must never share one idempotency key -- the exact
// retry scenario idempotency keys exist for.
const asset = await props.gateway.uploadAsset(
{ file, kind: props.kind },
{ idempotencyKey: props.idempotencyKey },
{ idempotencyKey: createLocalId("studio-asset-upload") },
);
const next = stateForUploaded(asset);
setState(next);
@@ -146,7 +151,7 @@ export function AssetUploadDialog(props: Readonly<{
}}
/>
</label>
<p className="studio-dialog-status" role="status" aria-live="polite">{MESSAGES[state.kind]}</p>
<p className="studio-dialog-status" role="status" aria-live="polite" aria-label="업로드 상태">{MESSAGES[state.kind]}</p>
<div className="studio-dialog-actions">
<button type="button" disabled={uploading} onClick={props.onClose}>
@@ -2,7 +2,6 @@ import { useRef, useState } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { useStudioAssetGateway } from "../use-studio.ts";
import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
@@ -53,7 +52,7 @@ export function CaseFields({
const bodyRef = useRef<HTMLTextAreaElement>(null);
const uploadTriggerRef = useRef<HTMLButtonElement>(null);
const [uploadKind, setUploadKind] = useState<AssetKind>("IMAGE");
const [uploadKey, setUploadKey] = useState<string | null>(null);
const [uploadOpen, setUploadOpen] = useState(false);
const update = (patch: Partial<CaseInput>) => onChange({ ...draft, ...patch });
const insertDirective = (directive: string) => {
@@ -70,7 +69,7 @@ export function CaseFields({
};
const closeUpload = () => {
setUploadKey(null);
setUploadOpen(false);
queueMicrotask(() => uploadTriggerRef.current?.focus());
};
@@ -99,18 +98,17 @@ export function CaseFields({
<button
ref={uploadTriggerRef}
type="button"
onClick={() => setUploadKey(createLocalId("studio-asset-upload"))}
onClick={() => setUploadOpen(true)}
>
Asset
</button>
</div>
<AssetPicker gateway={assetGateway} onLoaded={onAssetsLoaded} onInsert={insertDirective} />
</div>
{uploadKey ? (
{uploadOpen ? (
<AssetUploadDialog
gateway={assetGateway}
kind={uploadKind}
idempotencyKey={uploadKey}
onUploaded={(asset) => {
onAssetUploaded?.(asset);
insertDirective(buildEvidenceDirective({
@@ -1,5 +1,9 @@
import type { components } from "../../../contracts/studio/generated.ts";
import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts";
import {
evidenceCatalogEntriesFromAssets,
supportsEvidenceKeyIn,
} from "../../../domain/content-format/asset-evidence-catalog.ts";
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
import {
projectWorkingCopy,
@@ -13,43 +17,6 @@ type CatalogEntry = components["schemas"]["CatalogEntry"];
type ResolvedAsset = components["schemas"]["ResolvedAsset"];
type PublicRenderModel = components["schemas"]["PublicRenderModel"];
/**
* `projectWorkingCopy` applies two gates to every `EVIDENCE_FIGURE` block
* before any asset resolver runs: `supportsEvidenceKey`, and an `EVIDENCE`
* `CatalogEntry` lookup by `id`/`label`/`publicPath`. Both exist to stop a
* document referencing evidence that does not exist -- so opening them for
* the Asset Picker (Task 10) means teaching them about the *loaded Asset
* list*, not bypassing them.
*
* The document catalog (`getCatalog({ type: "EVIDENCE" })`) and the Asset
* list (`listAssets`) are different sources today: the catalog only knows
* about evidence a previous publish already registered, so a key the Picker
* just inserted -- backed by a real, freshly loaded `READY` asset -- has no
* catalog row yet. This synthesizes one `EVIDENCE` `CatalogEntry` per `READY`
* asset, `label`ed with its `assetKey` -- the same field the one
* pre-existing fixture entry already uses
* (`{ type: "EVIDENCE", label: "fetch-strategy-boundary" }`) -- and both
* gates are driven off the resulting merged catalog. A key backed by no
* loaded `READY` asset and no catalog row still fails both gates; nothing
* here can forge a pass for a dangling reference.
*/
function evidenceCatalogEntries(assets: readonly Asset[]): CatalogEntry[] {
return assets
.filter((asset) => asset.managementStatus === "READY")
.map((asset) => ({
id: asset.id,
type: "EVIDENCE",
label: asset.assetKey,
publicPath: asset.publicPath ?? undefined,
dependencyRevision: asset.updatedAt,
}));
}
function supportsEvidenceKeyIn(catalog: ReadonlyArray<CatalogEntry>) {
return (key: string): boolean =>
catalog.some((entry) => entry.type === "EVIDENCE" && entry.label === key);
}
/**
* The one legacy key predates the Asset gateway entirely: the document
* catalog carries a fixture `EVIDENCE` row for it (so the gates above pass
@@ -121,7 +88,7 @@ export function InstantPreview({
assets?: readonly Asset[];
}) {
const studio = useStudio();
const effectiveCatalog = [...catalog, ...evidenceCatalogEntries(assets)];
const effectiveCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)];
let model: PublicRenderModel | null = null;
let issues: string[] | null = null;
try {
+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"));
});
+6 -6
View File
@@ -6,12 +6,12 @@ import type { TechLogInstallContext } from "../../src/features/tech-log/adapters
* always selects the mock adapter, so `contractOperations` is a throwing stub
* — `createTechLogFeatureInstalledInput` never reads it on the MOCK branch.
*
* `createStudioAssetGateway` is unconditional (Task 7), so `apiBaseUrl`,
* `requestTimeoutMs` and `csrf` must still be well-formed even here: building
* the gateway constructs the upload transport eagerly. No existing test
* exercises the asset gateway's operations, so `contractOperations` and
* `csrf` stay throwing stubs — the same "never actually used" contract as
* before.
* `createStudioAssetGateway` also selects a mock adapter on `studioSource:
* "MOCK"` (Task 10 fix round 1, I2) that shares its Asset store with
* `createStudioGateway`'s mock, so `contractOperations`/`csrf` still never
* see real use here. `apiBaseUrl`/`requestTimeoutMs` stay well-formed anyway
* — they are read on the HTTP branch only, but this context is a plain
* value, not a conditional one.
*/
export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze({
studioSource: "MOCK",