feat: implement the TechLog Studio HTTP gateway
Adds createHttpStudioGateway, the adapter the Studio UI calls in production. It drives every mutation through mutationIntent() (defineMutationIntent/defineIdempotencyKey, not createBrowserMutationIntentFactory, so the caller-supplied idempotency key is preserved rather than regenerated) and leaves header construction to the executor/credential collaborator entirely - the gateway never sees or sets Idempotency-Key or x-csrf-token itself. Also moves stable-stringify.ts out of adapters/mock/ so the mock and http adapters share one pure function without production code depending on the mock directory, and fixes the resulting import in cursor.ts, mock-studio-gateway.ts, and mock-studio-gateway.test.ts. Adds MSW handlers (tests/mocks/handlers/tech-log-studio.ts) that wrap the reference mock-studio-gateway implementation, and a contract test suite that drives the gateway through a thin fetch-based executor built from the contract's own projectRequest, proving canonical path/body/header construction without assembling the full platform transport. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9313018ef5
commit
1d01a522de
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
defineIdempotencyKey,
|
||||||
|
defineMutationIntent,
|
||||||
|
MUTATION_INTENT_BOUNDS,
|
||||||
|
type MutationIntent,
|
||||||
|
} from "../../../../contracts/mutation-intent.ts";
|
||||||
|
import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts";
|
||||||
|
import { stableStringify } from "../stable-stringify.ts";
|
||||||
|
import type {
|
||||||
|
IdempotentOptions,
|
||||||
|
RequestOptions,
|
||||||
|
StudioGateway,
|
||||||
|
} from "../../application/ports/studio-gateway.ts";
|
||||||
|
import { toStudioGatewayError } from "./studio-error-mapping.ts";
|
||||||
|
|
||||||
|
export type StudioOperationExecutor = Readonly<{
|
||||||
|
execute(
|
||||||
|
operationId: string,
|
||||||
|
input: unknown,
|
||||||
|
context: Readonly<{
|
||||||
|
routeId: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
intent?: MutationIntent;
|
||||||
|
}>,
|
||||||
|
): Promise<HttpExecutionOutcome<unknown, unknown>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type HttpStudioGatewayDependencies = Readonly<{
|
||||||
|
operations: StudioOperationExecutor;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const ROUTE_ID = "TECH_LOG_STUDIO";
|
||||||
|
|
||||||
|
const IDENTITY_ENCODER = new TextEncoder();
|
||||||
|
const IDENTITY_DECODER = new TextDecoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `createBrowserMutationIntentFactory`는 쓰지 않는다. 그 factory는
|
||||||
|
* `requiresIdempotencyKey`일 때 키를 **스스로 생성**하는데, 이 포트의 키는
|
||||||
|
* 호출자가 만들어 안전한 재시도에 재사용하는 값이다. `defineMutationIntent`가
|
||||||
|
* 호출자 공급 키를 검증하며 받아주는 정식 경로다(`mutation-intent.ts`의
|
||||||
|
* "A caller-supplied value is never trimmed, regenerated or silently dropped").
|
||||||
|
*/
|
||||||
|
export function mutationIntent(
|
||||||
|
operationId: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
input: unknown,
|
||||||
|
): MutationIntent {
|
||||||
|
return defineMutationIntent({
|
||||||
|
intentId: globalThis.crypto.randomUUID(),
|
||||||
|
operationId,
|
||||||
|
canonicalInputIdentity: canonicalIdentity(input),
|
||||||
|
idempotencyKey: defineIdempotencyKey(idempotencyKey),
|
||||||
|
createdAtMonotonicMs: globalThis.performance.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 같은 명령의 재시도가 같은 신원을 갖도록 키 순서를 고정해 직렬화한다.
|
||||||
|
* `canonicalInputIdentityMaxBytes`는 바이트 한도다 — 문자 수로 자르면 한글
|
||||||
|
* 위주의 본문에서 문자 수 기준 한도보다 훨씬 먼저 바이트 한도를 넘는다.
|
||||||
|
* 원본이 유효한 UTF-8이므로 바이트 경계에서 잘라낸 뒤 끝에 남는 불완전한
|
||||||
|
* 시퀀스만 제거하면(디코더가 U+FFFD로 바꾼 꼬리만 잘라내면) 항상 한도
|
||||||
|
* 이하가 되고, 같은 입력은 항상 같은 결과를 낸다.
|
||||||
|
*/
|
||||||
|
function canonicalIdentity(input: unknown): string {
|
||||||
|
const identity = stableStringify(input);
|
||||||
|
const bytes = IDENTITY_ENCODER.encode(identity);
|
||||||
|
if (bytes.byteLength <= MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes) {
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
const truncated = bytes.subarray(
|
||||||
|
0,
|
||||||
|
MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes,
|
||||||
|
);
|
||||||
|
return IDENTITY_DECODER.decode(truncated).replace(/�+$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 헤더는 gateway가 만들지 않는다. `Idempotency-Key`는 실행 context의 intent에서,
|
||||||
|
* `x-csrf-token`은 credential collaborator에서 온다. gateway가 입력 본문에
|
||||||
|
* 넣으면 계약 본문이 오염되고 계약 소유 헤더는 거절된다.
|
||||||
|
*/
|
||||||
|
export function createHttpStudioGateway(
|
||||||
|
deps: HttpStudioGatewayDependencies,
|
||||||
|
): StudioGateway {
|
||||||
|
async function read<T>(
|
||||||
|
operationId: string,
|
||||||
|
input: unknown,
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>(
|
||||||
|
operationId: string,
|
||||||
|
input: unknown,
|
||||||
|
options: IdempotentOptions,
|
||||||
|
): Promise<T> {
|
||||||
|
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: StudioGateway = {
|
||||||
|
getDashboard: (options) => read("getStudioDashboard", {}, options),
|
||||||
|
listDocuments: (query, options) => read("listStudioDocuments", query, options),
|
||||||
|
createDocument: (input, options) =>
|
||||||
|
command("createStudioDocument", { document: input }, options),
|
||||||
|
getDocument: (documentId, options) =>
|
||||||
|
read("getStudioDocument", { documentId }, options),
|
||||||
|
saveDocument: (documentId, cmd, options) =>
|
||||||
|
command("saveStudioDocument", { documentId, ...cmd }, options),
|
||||||
|
validateDocument: (documentId, cmd, options) =>
|
||||||
|
command("validateStudioDocument", { documentId, ...cmd }, options),
|
||||||
|
createPreview: (documentId, cmd, options) =>
|
||||||
|
command("createStudioPreview", { documentId, ...cmd }, options),
|
||||||
|
getCurrentPreview: (documentId, options) =>
|
||||||
|
read("getCurrentStudioPreview", { documentId }, options),
|
||||||
|
publishDocument: (documentId, cmd, options) =>
|
||||||
|
command("publishStudioDocument", { documentId, ...cmd }, options),
|
||||||
|
unpublishPublication: (publicationId, cmd, options) =>
|
||||||
|
command("unpublishStudioPublication", { publicationId, ...cmd }, options),
|
||||||
|
listPublications: (query, options) => read("listStudioPublications", query, options),
|
||||||
|
getPublicationSnapshot: (publicationEventId, options) =>
|
||||||
|
read("getStudioPublicationSnapshot", { publicationEventId }, options),
|
||||||
|
getCatalog: (query, options) => read("listStudioCatalog", query, options),
|
||||||
|
};
|
||||||
|
return Object.freeze(gateway);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||||
import type { ProblemDetails } from "../../contracts/studio/contract.ts";
|
import type { ProblemDetails } from "../../contracts/studio/contract.ts";
|
||||||
import { stableStringify } from "./stable-stringify.ts";
|
import { stableStringify } from "../stable-stringify.ts";
|
||||||
|
|
||||||
export type CursorPayload = { binding: string; lastValue: string; lastId: string };
|
export type CursorPayload = { binding: string; lastValue: string; lastId: string };
|
||||||
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;
|
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;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { deriveDocumentState, derivePreviewState } from "../../domain/studio/doc
|
|||||||
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
||||||
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
||||||
import { projectWorkingCopy } from "./project-public-render-model.ts";
|
import { projectWorkingCopy } from "./project-public-render-model.ts";
|
||||||
import { stableStringify } from "./stable-stringify.ts";
|
import { stableStringify } from "../stable-stringify.ts";
|
||||||
import { validateWorkingCopy, validateWorkingCopyInputStructure } from "./validate-working-copy.ts";
|
import { validateWorkingCopy, validateWorkingCopyInputStructure } from "./validate-working-copy.ts";
|
||||||
|
|
||||||
export { createMockStudioState } from "./mock-state.ts";
|
export { createMockStudioState } from "./mock-state.ts";
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterAll, afterEach, beforeAll, test } from "vitest";
|
||||||
|
import { setupServer } from "msw/node";
|
||||||
|
|
||||||
|
import { createHttpStudioGateway } from "../../../src/features/tech-log/adapters/http/http-studio-gateway.ts";
|
||||||
|
import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||||
|
import { createTechLogStudioHandlers } from "../../mocks/handlers/tech-log-studio.ts";
|
||||||
|
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||||
|
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||||
|
import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
|
||||||
|
|
||||||
|
const { handlers } = createTechLogStudioHandlers();
|
||||||
|
const server = setupServer(...handlers);
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
const BASE = "http://api.test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전송 계층을 얇게 세운다. 이 테스트가 증명하는 것은 gateway가 canonical
|
||||||
|
* 경로/본문/헤더를 정확히 만들고 응답을 포트 계약으로 되돌린다는 것이다.
|
||||||
|
* 플랫폼 계약 런타임을 실제로 조립하지 않고, 계약 기여의 `projectRequest`로
|
||||||
|
* 요청을 만들어 `fetch`로 보낸다 — 이 테스트의 대상은 gateway이지 플랫폼
|
||||||
|
* 전송이 아니다.
|
||||||
|
*/
|
||||||
|
async function realDependencies() {
|
||||||
|
const byId = new Map(
|
||||||
|
TECH_LOG_STUDIO_CONTRIBUTION.http.map((entry) => [entry.contract.operationId, entry]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const csrf = createCsrfTokenProvider({
|
||||||
|
async execute() {
|
||||||
|
const response = await fetch(`${BASE}/api/v1/studio/session`);
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
csrfToken: string;
|
||||||
|
csrfHeaderName: string;
|
||||||
|
};
|
||||||
|
return { csrfToken: body.csrfToken, csrfHeaderName: body.csrfHeaderName };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const operations = {
|
||||||
|
async execute(
|
||||||
|
operationId: string,
|
||||||
|
input: unknown,
|
||||||
|
context: { signal?: AbortSignal; intent?: unknown },
|
||||||
|
) {
|
||||||
|
const entry = byId.get(operationId);
|
||||||
|
if (!entry) throw new Error(`unregistered operation: ${operationId}`);
|
||||||
|
const { contract } = entry;
|
||||||
|
const projected = contract.projectRequest(input as never);
|
||||||
|
|
||||||
|
let path = contract.pathTemplate;
|
||||||
|
for (const [name, value] of Object.entries(projected.pathValues)) {
|
||||||
|
path = path.replace(`{${name}}`, encodeURIComponent(value));
|
||||||
|
}
|
||||||
|
const url = new URL(`${BASE}${path}`);
|
||||||
|
for (const [name, value] of projected.queryEntries) {
|
||||||
|
url.searchParams.append(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실행기가 헤더를 만드는 두 경로를 그대로 재현한다: intent → Idempotency-Key,
|
||||||
|
// credential collaborator → x-csrf-token.
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (contract.retrySemantics === "KEYED") {
|
||||||
|
const intent = (context as { intent?: { idempotencyKey?: string } }).intent;
|
||||||
|
if (intent?.idempotencyKey) headers["Idempotency-Key"] = intent.idempotencyKey;
|
||||||
|
headers["X-CSRF-TOKEN"] = await csrf.token();
|
||||||
|
}
|
||||||
|
if (contract.requestBody === "JSON") headers["content-type"] = "application/json";
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: contract.method,
|
||||||
|
headers,
|
||||||
|
...(contract.requestBody === "JSON"
|
||||||
|
? { body: JSON.stringify(projected.body) }
|
||||||
|
: {}),
|
||||||
|
...(context.signal ? { signal: context.signal } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contract.acceptedStatuses.includes(response.status)) {
|
||||||
|
const value =
|
||||||
|
contract.responseBody === "NONE" ? null : await response.json();
|
||||||
|
return { kind: "SUCCESS" as const, value, effect: "APPLIED_CONFIRMED" as const };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "PROBLEM" as const,
|
||||||
|
problem: await response.json(),
|
||||||
|
metadata: { status: response.status },
|
||||||
|
effect: "NOT_APPLIED" as const,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { operations } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `CreateDocumentInput` IS the `WorkingCopyInput` (no `{ document }` wrapper)
|
||||||
|
* per the port signature, but `CREATE_STUDIO_DOCUMENT`'s `projectRequest`
|
||||||
|
* reads `value.document` off its operation input to build the request body.
|
||||||
|
* The gateway must bridge that mismatch by wrapping the port's raw input as
|
||||||
|
* `{ document: input }` before calling the operation — otherwise the wire
|
||||||
|
* body would be `undefined` instead of the document.
|
||||||
|
*/
|
||||||
|
test("wraps the raw port input into the operation's { document } shape", async () => {
|
||||||
|
const gateway = createHttpStudioGateway(await realDependencies());
|
||||||
|
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||||
|
const { id, version, updatedAt, ...input } = before.document;
|
||||||
|
void id;
|
||||||
|
void version;
|
||||||
|
void updatedAt;
|
||||||
|
|
||||||
|
const created = await gateway.createDocument(
|
||||||
|
{ ...input, title: "HTTP 경로로 생성" },
|
||||||
|
{ idempotencyKey: "create-1" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(created.title, "HTTP 경로로 생성");
|
||||||
|
assert.equal(created.version, 1);
|
||||||
|
assert.equal(typeof created.id, "string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads the dashboard through the canonical path", async () => {
|
||||||
|
const gateway = createHttpStudioGateway(await realDependencies());
|
||||||
|
const dashboard = await gateway.getDashboard();
|
||||||
|
assert.equal(typeof dashboard.totals.documents, "number");
|
||||||
|
assert.ok(dashboard.totals.documents > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saves with expectedVersion and returns the new version", async () => {
|
||||||
|
const gateway = createHttpStudioGateway(await realDependencies());
|
||||||
|
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||||
|
const { id, version, updatedAt, ...input } = before.document;
|
||||||
|
void id;
|
||||||
|
void updatedAt;
|
||||||
|
|
||||||
|
const saved = await gateway.saveDocument(
|
||||||
|
before.document.id,
|
||||||
|
{ expectedVersion: version, document: { ...input, title: "HTTP 경로로 저장" } },
|
||||||
|
{ idempotencyKey: "save-1" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(saved.document.title, "HTTP 경로로 저장");
|
||||||
|
assert.equal(saved.document.version, version + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces VERSION_CONFLICT as the port error, not a transport error", async () => {
|
||||||
|
const gateway = createHttpStudioGateway(await realDependencies());
|
||||||
|
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||||
|
const { id, version, updatedAt, ...input } = before.document;
|
||||||
|
void id;
|
||||||
|
void updatedAt;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
gateway.saveDocument(
|
||||||
|
before.document.id,
|
||||||
|
{ expectedVersion: version + 99, document: input },
|
||||||
|
{ idempotencyKey: "conflict-1" },
|
||||||
|
),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(isStudioGatewayError(error));
|
||||||
|
assert.equal(error.code, "VERSION_CONFLICT");
|
||||||
|
assert.equal(error.status, 409);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends the CSRF header and an Idempotency-Key on every mutation", async () => {
|
||||||
|
const captured: { csrf: string | null; key: string | null }[] = [];
|
||||||
|
server.use(
|
||||||
|
...createTechLogStudioHandlers().handlers,
|
||||||
|
);
|
||||||
|
server.events.on("request:start", ({ request }) => {
|
||||||
|
if (request.method === "GET") return;
|
||||||
|
captured.push({
|
||||||
|
csrf: request.headers.get("X-CSRF-TOKEN"),
|
||||||
|
key: request.headers.get("Idempotency-Key"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const gateway = createHttpStudioGateway(await realDependencies());
|
||||||
|
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||||
|
const { id, version, updatedAt, ...input } = before.document;
|
||||||
|
void id;
|
||||||
|
void updatedAt;
|
||||||
|
await gateway.saveDocument(
|
||||||
|
before.document.id,
|
||||||
|
{ expectedVersion: version, document: input },
|
||||||
|
{ idempotencyKey: "csrf-1" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(captured.length, 1);
|
||||||
|
assert.equal(captured[0]!.csrf, "csrf-test-token");
|
||||||
|
assert.equal(captured[0]!.key, "csrf-1");
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
createMockStudioGateway,
|
createMockStudioGateway,
|
||||||
createMockStudioState,
|
createMockStudioState,
|
||||||
} from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
} from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
||||||
import { stableStringify } from "../../../src/features/tech-log/adapters/mock/stable-stringify.ts";
|
import { stableStringify } from "../../../src/features/tech-log/adapters/stable-stringify.ts";
|
||||||
|
|
||||||
const NOW = "2026-08-14T01:00:00.000Z";
|
const NOW = "2026-08-14T01:00:00.000Z";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
|
||||||
|
import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
||||||
|
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||||
|
|
||||||
|
const BASE = "http://api.test";
|
||||||
|
|
||||||
|
function problemResponse(error: unknown) {
|
||||||
|
if (isStudioGatewayError(error)) {
|
||||||
|
return HttpResponse.json(error.problem, {
|
||||||
|
status: error.status,
|
||||||
|
headers: { "content-type": "application/problem+json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json(work: () => Promise<unknown>, status = 200) {
|
||||||
|
try {
|
||||||
|
return HttpResponse.json((await work()) as never, { status });
|
||||||
|
} catch (error) {
|
||||||
|
return problemResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTechLogStudioHandlers(
|
||||||
|
gateway = createMockStudioGateway(),
|
||||||
|
baseUrl = BASE,
|
||||||
|
) {
|
||||||
|
const key = (request: Request) =>
|
||||||
|
request.headers.get("Idempotency-Key") ?? "missing-key";
|
||||||
|
|
||||||
|
return {
|
||||||
|
gateway,
|
||||||
|
handlers: [
|
||||||
|
http.get(`${baseUrl}/api/v1/studio/session`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
authenticated: true,
|
||||||
|
displayName: "테스트 편집자",
|
||||||
|
roles: ["STUDIO_EDITOR"],
|
||||||
|
csrfToken: "csrf-test-token",
|
||||||
|
csrfHeaderName: "X-CSRF-TOKEN",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get(`${baseUrl}/api/v1/studio/dashboard`, () =>
|
||||||
|
json(() => gateway.getDashboard()),
|
||||||
|
),
|
||||||
|
http.get(`${baseUrl}/api/v1/studio/documents`, ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const limit = url.searchParams.get("limit");
|
||||||
|
return json(() =>
|
||||||
|
gateway.listDocuments({
|
||||||
|
...(url.searchParams.get("q") ? { q: url.searchParams.get("q")! } : {}),
|
||||||
|
...(limit ? { limit: Number(limit) } : {}),
|
||||||
|
} as never),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
http.post(`${baseUrl}/api/v1/studio/documents`, async ({ request }) =>
|
||||||
|
json(
|
||||||
|
async () =>
|
||||||
|
gateway.createDocument((await request.json()) as never, {
|
||||||
|
idempotencyKey: key(request),
|
||||||
|
}),
|
||||||
|
201,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get(`${baseUrl}/api/v1/studio/documents/:documentId`, ({ params }) =>
|
||||||
|
json(() => gateway.getDocument(String(params.documentId))),
|
||||||
|
),
|
||||||
|
http.put(
|
||||||
|
`${baseUrl}/api/v1/studio/documents/:documentId`,
|
||||||
|
async ({ request, params }) =>
|
||||||
|
json(async () =>
|
||||||
|
gateway.saveDocument(
|
||||||
|
String(params.documentId),
|
||||||
|
(await request.json()) as never,
|
||||||
|
{ idempotencyKey: key(request) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.post(
|
||||||
|
`${baseUrl}/api/v1/studio/documents/:documentId/validate`,
|
||||||
|
async ({ request, params }) =>
|
||||||
|
json(async () =>
|
||||||
|
gateway.validateDocument(
|
||||||
|
String(params.documentId),
|
||||||
|
(await request.json()) as never,
|
||||||
|
{ idempotencyKey: key(request) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get(
|
||||||
|
`${baseUrl}/api/v1/studio/documents/:documentId/preview`,
|
||||||
|
({ params }) => json(() => gateway.getCurrentPreview(String(params.documentId))),
|
||||||
|
),
|
||||||
|
http.post(
|
||||||
|
`${baseUrl}/api/v1/studio/documents/:documentId/preview`,
|
||||||
|
async ({ request, params }) =>
|
||||||
|
json(
|
||||||
|
async () =>
|
||||||
|
gateway.createPreview(
|
||||||
|
String(params.documentId),
|
||||||
|
(await request.json()) as never,
|
||||||
|
{ idempotencyKey: key(request) },
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.post(
|
||||||
|
`${baseUrl}/api/v1/studio/documents/:documentId/publish`,
|
||||||
|
async ({ request, params }) =>
|
||||||
|
json(async () =>
|
||||||
|
gateway.publishDocument(
|
||||||
|
String(params.documentId),
|
||||||
|
(await request.json()) as never,
|
||||||
|
{ idempotencyKey: key(request) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get(`${baseUrl}/api/v1/studio/publications`, () =>
|
||||||
|
json(() => gateway.listPublications({})),
|
||||||
|
),
|
||||||
|
http.post(
|
||||||
|
`${baseUrl}/api/v1/studio/publications/:publicationId/unpublish`,
|
||||||
|
async ({ request, params }) =>
|
||||||
|
json(async () =>
|
||||||
|
gateway.unpublishPublication(
|
||||||
|
String(params.publicationId),
|
||||||
|
(await request.json()) as never,
|
||||||
|
{ idempotencyKey: key(request) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get(
|
||||||
|
`${baseUrl}/api/v1/studio/publications/:publicationEventId/preview`,
|
||||||
|
({ params }) =>
|
||||||
|
json(() => gateway.getPublicationSnapshot(String(params.publicationEventId))),
|
||||||
|
),
|
||||||
|
http.get(`${baseUrl}/api/v1/studio/catalog`, ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return json(() =>
|
||||||
|
gateway.getCatalog({ type: url.searchParams.get("type") as never }),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user