Two review Minor findings against the brief itself, both closed: - tests/mocks/handlers/tech-log-studio.ts silently dropped documented query filters instead of mirroring the mock gateway it wraps: listStudioDocuments forwarded only q/limit (dropping kind, publicationStatus, nextAction, projectId, sort, cursor), listStudioCatalog forwarded only type (dropping q/cursor/limit), and listStudioPublications ignored all four of its parameters outright. Added a shared queryParams() helper and forward every field each operation's projectRequest actually emits, plus a regression test that narrows the fixture set by kind through the real HTTP gateway (confirmed it fails without the fix). - canonicalInputIdentity (on the idempotency-safety path, reused by Task 6) had no test. Added three tests against mutationIntent(): a large Korean payload stays within the byte bound, the same input retried twice yields an identical identity, and a mid-codepoint truncation cut leaves no replacement character (confirmed the third fails without the strip). The known collision limitation of any bounded-length identity scheme is documented in the test file rather than solved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forwards every query parameter a `projectRequest` puts on the wire, not
|
|
* just the ones a first pass happened to reach for. A handler that silently
|
|
* drops a documented filter lets a later filter test pass for the wrong
|
|
* reason — it would observe the *unfiltered* mock result and never learn the
|
|
* parameter never left the URL.
|
|
*/
|
|
function queryParams(
|
|
url: URL,
|
|
stringNames: readonly string[],
|
|
numberNames: readonly string[] = [],
|
|
): Record<string, string | number> {
|
|
const result: Record<string, string | number> = {};
|
|
for (const name of stringNames) {
|
|
const value = url.searchParams.get(name);
|
|
if (value !== null) result[name] = value;
|
|
}
|
|
for (const name of numberNames) {
|
|
const value = url.searchParams.get(name);
|
|
if (value !== null) result[name] = Number(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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);
|
|
return json(() =>
|
|
gateway.listDocuments(
|
|
queryParams(
|
|
url,
|
|
["q", "kind", "publicationStatus", "nextAction", "projectId", "sort", "cursor"],
|
|
["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`, ({ request }) => {
|
|
const url = new URL(request.url);
|
|
return json(() =>
|
|
gateway.listPublications(
|
|
queryParams(url, ["q", "type", "cursor"], ["limit"]) as never,
|
|
),
|
|
);
|
|
}),
|
|
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(
|
|
queryParams(url, ["type", "q", "cursor"], ["limit"]) as never,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
};
|
|
}
|