diff --git a/src/features/tech-log/adapters/create-tech-log-feature-input.ts b/src/features/tech-log/adapters/create-tech-log-feature-input.ts index f580d77..fae4760 100644 --- a/src/features/tech-log/adapters/create-tech-log-feature-input.ts +++ b/src/features/tech-log/adapters/create-tech-log-feature-input.ts @@ -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(); + 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({ diff --git a/src/features/tech-log/adapters/mock/mock-studio-asset-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-asset-gateway.ts new file mode 100644 index 0000000..0b090b2 --- /dev/null +++ b/src/features/tech-log/adapters/mock/mock-studio-asset-gateway.ts @@ -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> = { + "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 { + 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, + supplied: Partial = {}, +): 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 { + 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 { + 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 { + 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 { + 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 { + await boundary(options); + const asset = findById(assetId); + if (asset) assets.delete(asset.assetKey); + }, + }); +} diff --git a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts index 9f9d236..acd9f77 100644 --- a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -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; }; 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 = {}) { @@ -61,7 +69,7 @@ function exactSet(left: string[], right: string[]) { export function createMockStudioGateway(supplied: Partial = {}): 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 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, () => { diff --git a/src/features/tech-log/adapters/mock/project-public-render-model.ts b/src/features/tech-log/adapters/mock/project-public-render-model.ts index cfa93e7..15625de 100644 --- a/src/features/tech-log/adapters/mock/project-public-render-model.ts +++ b/src/features/tech-log/adapters/mock/project-public-render-model.ts @@ -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; // 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}`); } diff --git a/src/features/tech-log/adapters/mock/validate-working-copy.ts b/src/features/tech-log/adapters/mock/validate-working-copy.ts index b0ad13b..6809bf4 100644 --- a/src/features/tech-log/adapters/mock/validate-working-copy.ts +++ b/src/features/tech-log/adapters/mock/validate-working-copy.ts @@ -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; documents: ReadonlyArray; + /** + * 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; }; 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}`); } } diff --git a/src/features/tech-log/domain/content-format/asset-evidence-catalog.ts b/src/features/tech-log/domain/content-format/asset-evidence-catalog.ts new file mode 100644 index 0000000..44a4d46 --- /dev/null +++ b/src/features/tech-log/domain/content-format/asset-evidence-catalog.ts @@ -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, +): SupportsEvidenceKey { + return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key)); +} diff --git a/src/features/tech-log/domain/content-format/project-public-render-model.ts b/src/features/tech-log/domain/content-format/project-public-render-model.ts index 12981fa..fd0b105 100644 --- a/src/features/tech-log/domain/content-format/project-public-render-model.ts +++ b/src/features/tech-log/domain/content-format/project-public-render-model.ts @@ -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, + 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, 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 { diff --git a/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx b/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx index ffca535..8004421 100644 --- a/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx +++ b/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx @@ -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 = { 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<{ }} /> -

{MESSAGES[state.kind]}

+

{MESSAGES[state.kind]}

- {uploadKey ? ( + {uploadOpen ? ( { onAssetUploaded?.(asset); insertDirective(buildEvidenceDirective({ diff --git a/src/features/tech-log/presentation/studio/components/instant-preview.tsx b/src/features/tech-log/presentation/studio/components/instant-preview.tsx index d917a6b..1cfbe40 100644 --- a/src/features/tech-log/presentation/studio/components/instant-preview.tsx +++ b/src/features/tech-log/presentation/studio/components/instant-preview.tsx @@ -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) { - 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 { diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx index f528117..520540f 100644 --- a/tests/features/tech-log/asset-picker.test.tsx +++ b/tests/features/tech-log/asset-picker.test.tsx @@ -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( {}} onClose={() => {}} />); + render( {}} 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((resolve) => { resolveUpload = resolve; }), ); - render( {}} onClose={() => {}} />); + render( {}} 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 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( - uploaded.push(asset)} onClose={() => {}} />, + 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( - uploaded.push(asset)} onClose={() => {}} />, + 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( {}} onClose={() => {}} />); + render( {}} 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( {}} onClose={() => {}} />); + render( {}} 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( {}} onClose={() => {}} />); + render( {}} 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( {}} onClose={() => (closed += 1)} />); + render( {}} 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( {}} 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( + + gateway} createAssetGateway={() => assetGateway}> + + + , + ); + + 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([""], "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; + 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")); +}); diff --git a/tests/helpers/studio-install-context.ts b/tests/helpers/studio-install-context.ts index 5e756da..7e139cc 100644 --- a/tests/helpers/studio-install-context.ts +++ b/tests/helpers/studio-install-context.ts @@ -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",