diff --git a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts index 967d197..b685c6f 100644 --- a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -104,7 +104,19 @@ export function createMockStudioGateway(supplied: Partial(options: RequestOptions | undefined, work: () => T) { await boundary(options); return clone(work()); } + async function read(options: RequestOptions | undefined, work: () => T) { + // `boundary` stays outside the wrap so an abort still surfaces as + // `AbortError`, exactly as `idempotent()` arranges it. + await boundary(options); + try { return clone(work()); } + catch (error) { + // Same reason as `idempotent()`'s catch below: this port's contract is + // `StudioGatewayError` only. A read has no idempotency ledger, so the + // `replayable` half of the classification has nothing to record here -- + // only the problem is used. + throw new StudioGatewayError(clone(failureOf(error).problem)); + } + } async function idempotent(operation: string, target: string, request: () => unknown, options: IdempotentOptions, work: () => T): Promise { await boundary(options); if (typeof options.idempotencyKey !== "string" || cp(options.idempotencyKey) < 1 || cp(options.idempotencyKey) > 200) throw requestError([{ path: "/idempotencyKey", message: "Idempotency key must be 1-200 characters." }]); diff --git a/tests/features/tech-log/mock-studio-gateway.test.ts b/tests/features/tech-log/mock-studio-gateway.test.ts index 1cca943..e3300dd 100644 --- a/tests/features/tech-log/mock-studio-gateway.test.ts +++ b/tests/features/tech-log/mock-studio-gateway.test.ts @@ -639,3 +639,53 @@ test("getDocument's nextAction reflects the assembled WorkingCopyDetail, not a c assert.equal(edgeToken.currentValidation?.validatedVersion, 2); assert.equal(edgeToken.nextAction, "FIX_VALIDATION"); }); + +// Final fix wave, item 6. `idempotent()` normalizes anything `work()` throws +// into a `StudioGatewayError` because that is the port's whole contract -- +// but its `read()` sibling did not, so an uncharacterized internal failure on +// any of the seven read operations escaped the port as a raw `Error` and +// reached UI code written to catch `StudioGatewayError`. An aborted request +// must still surface as `AbortError`: `boundary()` runs outside the wrap, the +// same way `idempotent()` already arranges it. +test("read() normalizes an uncharacterized internal failure, and still lets an abort through", async () => { + let failing = false; + const gateway = createMockStudioGateway({ + clock: { now: () => new Date(NOW) }, + idGenerator: ids(), + dependencyRevision: { + current: () => { + if (failing) throw new Error("의존성 리비전을 읽지 못했습니다."); + return "catalog-2026-08-14"; + }, + }, + }); + + const created = await gateway.createDocument( + emptyCase({ title: "읽기 경계", slug: "read-boundary", summary: "요약" }), + { idempotencyKey: "read-boundary-create" }, + ); + + failing = true; + for (const [label, call] of [ + ["getDocument", () => gateway.getDocument(created.id)], + ["getDashboard", () => gateway.getDashboard()], + ["listDocuments", () => gateway.listDocuments({})], + ] as const) { + await assert.rejects(call(), (error: unknown) => { + assert.ok( + isStudioGatewayError(error), + `${label}: expected a StudioGatewayError, got ${String(error)}`, + ); + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + assert.equal(error.status, 500); + assert.equal(error.retryable, true); + return true; + }); + } + + const controller = new AbortController(); + controller.abort(); + await assert.rejects(gateway.getDocument(created.id, { signal: controller.signal }), { + name: "AbortError", + }); +});