feat: add the TechLog asset multipart upload transport
Wires the whole Asset capability into the running application: the multipart upload transport (the contract runtime can only express JSON bodies), a single composition-root-owned CSRF provider shared between the platform's credential collaborator (18 JSON operations) and the upload transport (1 multipart operation), and Studio/StudioShell exposure of the Asset gateway alongside the existing document gateway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9c2d8bc5e
commit
c9c832c365
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterAll, afterEach, beforeAll, test } from "vitest";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
|
||||
import { createAssetUploadTransport } from "../../../src/features/tech-log/adapters/http/asset-upload-transport.ts";
|
||||
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||
|
||||
const BASE = "http://api.test";
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
const transport = () =>
|
||||
createAssetUploadTransport({ baseUrl: `${BASE}/`, timeoutMs: 10_000 });
|
||||
|
||||
const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" });
|
||||
|
||||
test("posts multipart form data with the supplied headers", async () => {
|
||||
let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null;
|
||||
|
||||
server.use(
|
||||
http.post(`${BASE}/api/v1/studio/assets`, async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
seen = {
|
||||
kind: form.get("kind"),
|
||||
alt: form.get("altText"),
|
||||
csrf: request.headers.get("X-CSRF-TOKEN"),
|
||||
key: request.headers.get("Idempotency-Key"),
|
||||
};
|
||||
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
|
||||
const asset = await transport().upload(
|
||||
{ file: svg(), kind: "DIAGRAM", altText: "경계 다이어그램", decorative: false },
|
||||
{ "X-CSRF-TOKEN": "csrf", "Idempotency-Key": "up-1" },
|
||||
);
|
||||
|
||||
assert.equal((asset as { id: string }).id, "a");
|
||||
assert.equal(seen!.kind, "DIAGRAM");
|
||||
assert.equal(seen!.alt, "경계 다이어그램");
|
||||
assert.equal(seen!.csrf, "csrf");
|
||||
assert.equal(seen!.key, "up-1");
|
||||
});
|
||||
|
||||
test("does not set content-type itself so the boundary survives", async () => {
|
||||
let contentType: string | null = "unset";
|
||||
server.use(
|
||||
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
|
||||
contentType = request.headers.get("content-type");
|
||||
return HttpResponse.json({ id: "a" }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
|
||||
await transport().upload({ file: svg(), kind: "IMAGE" }, {});
|
||||
|
||||
assert.ok(contentType?.startsWith("multipart/form-data; boundary="));
|
||||
});
|
||||
|
||||
test("maps 413 onto PAYLOAD_TOO_LARGE", async () => {
|
||||
server.use(
|
||||
http.post(`${BASE}/api/v1/studio/assets`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
type: "https://techlog.local/problems/payload-too-large",
|
||||
title: "PAYLOAD_TOO_LARGE",
|
||||
status: 413,
|
||||
detail: "파일이 너무 큽니다.",
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
},
|
||||
{ status: 413, headers: { "content-type": "application/problem+json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
transport().upload({ file: svg(), kind: "IMAGE" }, {}),
|
||||
(error: unknown) => {
|
||||
assert.ok(isStudioGatewayError(error));
|
||||
assert.equal(error.code, "PAYLOAD_TOO_LARGE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("maps 415 onto UNSUPPORTED_MEDIA_TYPE", async () => {
|
||||
server.use(
|
||||
http.post(`${BASE}/api/v1/studio/assets`, () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
type: "https://techlog.local/problems/unsupported-media-type",
|
||||
title: "UNSUPPORTED_MEDIA_TYPE",
|
||||
status: 415,
|
||||
detail: "지원하지 않는 형식입니다.",
|
||||
code: "UNSUPPORTED_MEDIA_TYPE",
|
||||
},
|
||||
{ status: 415, headers: { "content-type": "application/problem+json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
|
||||
assert.ok(isStudioGatewayError(error));
|
||||
assert.equal(error.code, "UNSUPPORTED_MEDIA_TYPE");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("maps a network failure onto STUDIO_UNAVAILABLE", async () => {
|
||||
server.use(http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.error()));
|
||||
|
||||
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
|
||||
assert.ok(isStudioGatewayError(error));
|
||||
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,10 @@ type Equal<Left, Right> =
|
||||
type Assert<Condition extends true> = Condition;
|
||||
|
||||
type TechLogFeatureInputExposesNoMissingOrAdditionalKeys = Assert<
|
||||
Equal<keyof ApplicationFeatureInputs["tech-log"], "publicContent" | "createStudioGateway">
|
||||
Equal<
|
||||
keyof ApplicationFeatureInputs["tech-log"],
|
||||
"publicContent" | "createStudioGateway" | "createStudioAssetGateway"
|
||||
>
|
||||
>;
|
||||
type TechLogFeatureInputRegistryValueMatchesFeatureContract = Assert<
|
||||
Equal<ApplicationFeatureInputs["tech-log"], TechLogFeatureInput>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { test } from "vitest";
|
||||
import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts";
|
||||
import { createInstalledFeatureInputs } from "../../../src/features/installed-feature-adapters.ts";
|
||||
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||
import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||
import type { TechLogFeatureInput } from "../../../src/features/tech-log/application/tech-log-feature-input.ts";
|
||||
import type { WorkingCopy } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
|
||||
@@ -42,6 +43,13 @@ function installedInputs(
|
||||
throw new Error("reference executor is not used by composition tests");
|
||||
},
|
||||
},
|
||||
apiBaseUrl: "http://composition.test/",
|
||||
requestTimeoutMs: 10_000,
|
||||
csrf: createCsrfTokenProvider({
|
||||
async execute() {
|
||||
throw new Error("CSRF provider is not used by composition tests");
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +58,7 @@ test("installs TechLog beside the retained reference feature through application
|
||||
|
||||
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]);
|
||||
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
|
||||
"createStudioAssetGateway",
|
||||
"createStudioGateway",
|
||||
"publicContent",
|
||||
]);
|
||||
@@ -107,3 +116,11 @@ test("each createStudioGateway call owns an isolated mutable Studio session", as
|
||||
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
);
|
||||
});
|
||||
|
||||
test("each Studio session gets its own asset gateway instance", () => {
|
||||
const installed = installedInputs("HTTP");
|
||||
assert.notEqual(
|
||||
installed["tech-log"].createStudioAssetGateway(),
|
||||
installed["tech-log"].createStudioAssetGateway(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,33 @@ test("lists assets through the canonical operation", async () => {
|
||||
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<string, string> = {};
|
||||
const { dependencies } = deps({});
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { createCsrfTokenProvider } from "../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||
import type { TechLogInstallContext } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
|
||||
/**
|
||||
* The install context the existing Studio test suite composes against. It
|
||||
* 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.
|
||||
*/
|
||||
export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze({
|
||||
studioSource: "MOCK",
|
||||
@@ -12,4 +20,11 @@ export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze(
|
||||
throw new Error("contract executor is not used by the mock Studio gateway");
|
||||
},
|
||||
}),
|
||||
apiBaseUrl: "http://mock-studio.test/",
|
||||
requestTimeoutMs: 10_000,
|
||||
csrf: createCsrfTokenProvider({
|
||||
async execute() {
|
||||
throw new Error("CSRF provider is not used by the mock Studio gateway");
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user