From d9c2d8bc5e6f747421309776eb8199b8b48526bc Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 02:16:51 +0900 Subject: [PATCH] feat: add the TechLog Studio asset gateway port and JSON adapter --- .../http/http-studio-asset-gateway.ts | 81 ++++++++++ .../application/ports/studio-asset-gateway.ts | 41 +++++ .../tech-log/contracts/studio/contract.ts | 8 + .../tech-log/studio-asset-gateway.test.ts | 140 ++++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 src/features/tech-log/adapters/http/http-studio-asset-gateway.ts create mode 100644 src/features/tech-log/application/ports/studio-asset-gateway.ts create mode 100644 tests/features/tech-log/studio-asset-gateway.test.ts diff --git a/src/features/tech-log/adapters/http/http-studio-asset-gateway.ts b/src/features/tech-log/adapters/http/http-studio-asset-gateway.ts new file mode 100644 index 0000000..fff1aaa --- /dev/null +++ b/src/features/tech-log/adapters/http/http-studio-asset-gateway.ts @@ -0,0 +1,81 @@ +import type { + Asset, + AssetDetail, + AssetPage, + UpdateAssetCommand, +} from "../../contracts/studio/contract.ts"; +import type { + ListAssetsQuery, + StudioAssetGateway, + UploadAssetForm, +} from "../../application/ports/studio-asset-gateway.ts"; +import type { IdempotentOptions, RequestOptions } from "../../application/ports/studio-gateway.ts"; +import { toStudioGatewayError } from "./studio-error-mapping.ts"; +import { mutationIntent, type StudioOperationExecutor } from "./http-studio-gateway.ts"; +import type { CsrfTokenProvider } from "./studio-session-csrf.ts"; + +export type StudioAssetUploadTransport = Readonly<{ + upload( + form: UploadAssetForm, + headers: Readonly>, + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise; +}>; + +export type HttpStudioAssetGatewayDependencies = Readonly<{ + operations: StudioOperationExecutor; + csrf: CsrfTokenProvider; + upload: StudioAssetUploadTransport; +}>; + +const ROUTE_ID = "TECH_LOG_STUDIO_ASSETS"; + +export function createHttpStudioAssetGateway( + deps: HttpStudioAssetGatewayDependencies, +): StudioAssetGateway { + async function read(operationId: string, input: unknown, options?: RequestOptions) { + const outcome = await deps.operations.execute(operationId, input, { + routeId: ROUTE_ID, + ...(options?.signal ? { signal: options.signal } : {}), + }); + if (outcome.kind !== "SUCCESS") throw toStudioGatewayError(outcome, operationId); + return outcome.value as T; + } + + async function command(operationId: string, input: unknown, options: IdempotentOptions) { + // Task 4와 동일한 규칙: 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는 + // intent에서, `x-csrf-token`은 credential collaborator에서 온다. + const outcome = await deps.operations.execute(operationId, input, { + routeId: ROUTE_ID, + intent: mutationIntent(operationId, options.idempotencyKey, input), + ...(options.signal ? { signal: options.signal } : {}), + }); + if (outcome.kind === "SUCCESS") return outcome.value as T; + throw toStudioGatewayError(outcome, operationId); + } + + const gateway: StudioAssetGateway = { + listAssets: (query, options) => read("listStudioAssets", query, options), + getAsset: (assetId, options) => read("getStudioAsset", { assetId }, options), + async uploadAsset(form, options) { + const [token, headerName] = await Promise.all([ + deps.csrf.token(options.signal ? { signal: options.signal } : undefined), + deps.csrf.headerName(options.signal ? { signal: options.signal } : undefined), + ]); + return deps.upload.upload( + form, + Object.freeze({ + [headerName]: token, + "Idempotency-Key": options.idempotencyKey, + }), + options.signal ? { signal: options.signal } : undefined, + ); + }, + updateAssetMetadata: (assetId, cmd: UpdateAssetCommand, options) => + command("updateStudioAsset", { assetId, ...cmd }, options), + async deleteAsset(assetId, options) { + await command("deleteStudioAsset", { assetId }, options); + }, + }; + return Object.freeze(gateway); +} diff --git a/src/features/tech-log/application/ports/studio-asset-gateway.ts b/src/features/tech-log/application/ports/studio-asset-gateway.ts new file mode 100644 index 0000000..1c9e48c --- /dev/null +++ b/src/features/tech-log/application/ports/studio-asset-gateway.ts @@ -0,0 +1,41 @@ +import type { + Asset, + AssetDetail, + AssetKind, + AssetManagementStatus, + AssetPage, + UpdateAssetCommand, +} from "../../contracts/studio/contract.ts"; +import type { IdempotentOptions, RequestOptions } from "./studio-gateway.ts"; + +export type ListAssetsQuery = Readonly<{ + q?: string; + kind?: AssetKind; + managementStatus?: AssetManagementStatus; + cursor?: string; + limit?: number; +}>; + +export type UploadAssetForm = Readonly<{ + file: File; + kind: AssetKind; + altText?: string; + decorative?: boolean; +}>; + +/** + * Asset은 `StudioGateway`와 별도 포트다. 파일 전송과 JSON orchestration은 + * 실패 모델이 다르고, 업로드 구현을 presigned/resumable로 바꿀 때 교체 범위가 + * 이 포트 뒤에서 끝나야 한다. + */ +export interface StudioAssetGateway { + listAssets(query: ListAssetsQuery, options?: RequestOptions): Promise; + uploadAsset(form: UploadAssetForm, options: IdempotentOptions): Promise; + getAsset(assetId: string, options?: RequestOptions): Promise; + updateAssetMetadata( + assetId: string, + command: UpdateAssetCommand, + options: IdempotentOptions, + ): Promise; + deleteAsset(assetId: string, options: IdempotentOptions): Promise; +} diff --git a/src/features/tech-log/contracts/studio/contract.ts b/src/features/tech-log/contracts/studio/contract.ts index 2c6b64c..7a77f17 100644 --- a/src/features/tech-log/contracts/studio/contract.ts +++ b/src/features/tech-log/contracts/studio/contract.ts @@ -25,3 +25,11 @@ export type PublicationSnapshot = Schemas["PublicationSnapshot"]; export type CatalogPage = Schemas["CatalogPage"]; export type ProblemDetails = Schemas["ProblemDetails"]; export type PublicRenderModel = Schemas["PublicRenderModel"]; +export type Asset = Schemas["Asset"]; +export type AssetDetail = Schemas["AssetDetail"]; +export type AssetPage = Schemas["AssetPage"]; +export type AssetUsage = Schemas["AssetUsage"]; +export type AssetKind = Schemas["AssetKind"]; +export type AssetManagementStatus = Schemas["AssetManagementStatus"]; +export type UpdateAssetCommand = Schemas["UpdateAssetCommand"]; +export type StudioSession = Schemas["StudioSession"]; diff --git a/tests/features/tech-log/studio-asset-gateway.test.ts b/tests/features/tech-log/studio-asset-gateway.test.ts new file mode 100644 index 0000000..a4d5bd6 --- /dev/null +++ b/tests/features/tech-log/studio-asset-gateway.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { createHttpStudioAssetGateway } from "../../../src/features/tech-log/adapters/http/http-studio-asset-gateway.ts"; +import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; + +const READY_ASSET = { + id: "11111111-1111-4111-8111-111111111111", + assetKey: "fetch-strategy-boundary", + kind: "DIAGRAM", + mediaType: "image/svg+xml", + originalFilename: "boundary.svg", + byteSize: 4096, + width: 1080, + height: 420, + altText: "Fetch Join과 Batch Fetch 비교", + decorative: false, + managementStatus: "READY", + publicPath: "/media/fetch-strategy-boundary.svg", + usageCount: 1, + version: 1, + createdAt: "2026-08-14T01:00:00.000Z", + updatedAt: "2026-08-14T01:00:00.000Z", +}; + +function deps(outcomes: Record) { + const calls: { operationId: string; input: unknown }[] = []; + return { + calls, + dependencies: { + operations: { + async execute(operationId: string, input: unknown) { + calls.push({ operationId, input }); + const outcome = outcomes[operationId]; + if (!outcome) throw new Error(`no outcome for ${operationId}`); + return outcome as never; + }, + }, + csrf: createCsrfTokenProvider({ + async execute() { + return { csrfToken: "csrf", csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }), + upload: { + async upload() { + return READY_ASSET as never; + }, + }, + }, + }; +} + +test("lists assets through the canonical operation", async () => { + const { calls, dependencies } = deps({ + listStudioAssets: { + kind: "SUCCESS", + value: { items: [READY_ASSET], nextCursor: null }, + effect: "APPLIED_CONFIRMED", + }, + }); + const gateway = createHttpStudioAssetGateway(dependencies as never); + + const page = await gateway.listAssets({ kind: "DIAGRAM", limit: 20 }); + + assert.equal(page.items.length, 1); + assert.equal(calls[0]!.operationId, "listStudioAssets"); +}); + +test("delegates upload to the transport with CSRF and idempotency headers", async () => { + let received: Record = {}; + const { dependencies } = deps({}); + const gateway = createHttpStudioAssetGateway({ + ...dependencies, + upload: { + async upload(_form: unknown, headers: Record) { + received = headers; + return READY_ASSET as never; + }, + }, + } as never); + + const asset = await gateway.uploadAsset( + { file: new File([""], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" }, + { idempotencyKey: "upload-1" }, + ); + + assert.equal(asset.managementStatus, "READY"); + assert.equal(received["X-CSRF-TOKEN"], "csrf"); + assert.equal(received["Idempotency-Key"], "upload-1"); +}); + +test("surfaces ASSET_IN_USE from a rejected delete", async () => { + const { dependencies } = deps({ + deleteStudioAsset: { + kind: "PROBLEM", + problem: { + type: "https://techlog.local/problems/asset-in-use", + title: "ASSET_IN_USE", + status: 409, + detail: "사용 중인 Asset은 삭제할 수 없습니다.", + code: "ASSET_IN_USE", + }, + metadata: { status: 409 }, + effect: "NOT_APPLIED", + }, + }); + const gateway = createHttpStudioAssetGateway(dependencies as never); + + await assert.rejects( + gateway.deleteAsset(READY_ASSET.id, { idempotencyKey: "delete-1" }), + (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "ASSET_IN_USE"); + return true; + }, + ); +}); + +test("sends expectedVersion when updating metadata", async () => { + const { calls, dependencies } = deps({ + updateStudioAsset: { + kind: "SUCCESS", + value: { ...READY_ASSET, version: 2, decorative: true, altText: null }, + effect: "APPLIED_CONFIRMED", + }, + }); + const gateway = createHttpStudioAssetGateway(dependencies as never); + + const updated = await gateway.updateAssetMetadata( + READY_ASSET.id, + { expectedVersion: 1, decorative: true, altText: null }, + { idempotencyKey: "update-1" }, + ); + + assert.equal(updated.version, 2); + const input = calls[0]!.input as Record; + assert.equal(input["expectedVersion"], 1); + assert.equal(input["assetId"], READY_ASSET.id); +});