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, StudioGatewayError, } 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("gets a single asset detail through the canonical operation", async () => { const detail = { asset: READY_ASSET, usages: [ { documentId: "22222222-2222-4222-8222-222222222222", documentTitle: "Fetch Join과 Batch Fetch 비교", }, ], hasPublicationHistory: true, }; const { calls, dependencies } = deps({ getStudioAsset: { kind: "SUCCESS", value: detail, effect: "APPLIED_CONFIRMED", }, }); const gateway = createHttpStudioAssetGateway(dependencies as never); const result = await gateway.getAsset(READY_ASSET.id); assert.equal(calls[0]!.operationId, "getStudioAsset"); assert.deepEqual(calls[0]!.input, { assetId: READY_ASSET.id }); assert.deepEqual(result, detail); }); 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"); }); // I2 (fix round 1). `techLogCsrf.invalidate()` at the composition root only // runs from `contractOperations.execute`'s `UNAUTHENTICATED` branch, which // the multipart upload bypasses entirely. Without an explicit call from the // gateway itself, a 401/403 on upload left a stale token cached for every // other Studio operation. for (const status of [401, 403]) { test(`invalidates the cached CSRF token when upload rejects with ${status}`, async () => { let executions = 0; const csrf = createCsrfTokenProvider({ async execute() { executions += 1; return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" }; }, }); const { dependencies } = deps({}); const gateway = createHttpStudioAssetGateway({ ...dependencies, csrf, upload: { async upload() { throw new StudioGatewayError({ type: "https://techlog.local/problems/authentication-required", title: "AUTHENTICATION_REQUIRED", status, detail: "세션이 만료되었습니다.", code: "AUTHENTICATION_REQUIRED", }); }, }, } as never); await assert.rejects( gateway.uploadAsset( { file: new File([""], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" }, { idempotencyKey: "upload-1" }, ), ); // The upload itself already consumed one fetch. assert.equal(executions, 1); // A fresh token() call after the failure must re-fetch, not replay the // (now-rejected) cached value. await csrf.token(); assert.equal(executions, 2); }); } test("does not invalidate the cached CSRF token for an unrelated upload failure", async () => { let executions = 0; const csrf = createCsrfTokenProvider({ async execute() { executions += 1; return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" }; }, }); const { dependencies } = deps({}); const gateway = createHttpStudioAssetGateway({ ...dependencies, csrf, upload: { async upload() { throw new StudioGatewayError({ type: "https://techlog.local/problems/payload-too-large", title: "PAYLOAD_TOO_LARGE", status: 413, detail: "파일이 너무 큽니다.", code: "PAYLOAD_TOO_LARGE", }); }, }, } as never); await assert.rejects( gateway.uploadAsset( { file: new File([""], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" }, { idempotencyKey: "upload-1" }, ), ); assert.equal(executions, 1); await csrf.token(); // Still cached — a 413 says nothing about the token's validity. assert.equal(executions, 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); });