fix: normalize uncharacterized read failures in the mock Studio gateway

`idempotent()` was fixed to wrap anything `work()` throws, with a documented
rationale: this port's contract is `StudioGatewayError` only, so nothing else
may cross it. Its `read()` sibling was left unwrapped, so an uncharacterized
internal failure on any of the seven read operations escaped as a raw `Error`
and reached UI code written to catch `StudioGatewayError`.

`read()` now applies the same `failureOf` classification. It has no
idempotency ledger, so only the problem half is used. `boundary()` stays
outside the wrap so an aborted request still surfaces as `AbortError`,
exactly as `idempotent()` arranges it -- asserted by the new test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 09:09:36 +09:00
co-authored by Claude Opus 5
parent e4f9f81a3f
commit e2a0e695dd
2 changed files with 63 additions and 1 deletions
@@ -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",
});
});