test(client): unit coverage for the off-origin URL rewriter + middleware contract #233

Open
opened 2026-06-23 11:52:56 +00:00 by james · 0 comments
Owner

Context

PR #232 fixed four separate bugs in apps/client/lib/apiClient.ts's off-origin URL rewriter that surfaced when running the universal client on a real Android device via Expo Go:

  1. new URL(originalUrl) threw on relative paths — RN's whatwg-fetch keeps relative inputs literal, so the rewriter silently no-op'd and the request went to whatever bundle origin RN happened to resolve. Manifested as 404s on login.
  2. new Request(next, request) didn't transfer the body stream via RN's polyfill — server saw empty body, parsed it as invalid JSON. Manifested as "invalid_body" on every POST.
  3. Returning the unchanged input from onRequest / onResponse middleware tripped openapi-fetch's instanceof Request / instanceof Response check on RN's polyfill class identity. Manifested as "onResponse: must return new Response() when modifying the response".
  4. headers: request.headers passed a Headers instance to RN's Request constructor — RN's polyfill doesn't iterate that correctly, the Authorization header set by authMiddleware disappeared, every authenticated GET returned 401. Manifested as the post-login bounce-back.

All four are unit-testable in pure JS — the rewriter is a pure function and the middleware contract is observable from the outside. They'd have been caught by tests against RN's whatwg-fetch polyfill before reaching a device.

Source

Follow-up from the PR #232 debugging cycle.

Scope

  • Export rewriteRequestUrl from apps/client/lib/apiClient.ts for testing (or extract it to a sibling module — your call; the existing file already exports apiClient so a sibling keeps the boundary clean).
  • Add apps/client/tests/apiClient.test.ts covering:
    • rewriteRequestUrl with an absolute URL → splice the configured base over the path.
    • rewriteRequestUrl with a relative URL starting with / → splice the configured base directly (the RN path).
    • rewriteRequestUrl with a foreign absolute URL → pass through.
    • rewriteRequestUrl with no configured base → pass through.
    • The off-origin gate: PWA web (Platform.web, not Tauri) → no-op.
  • Add a middleware-contract test in apps/client/tests/ or packages/api-client/tests/. Construct a createApiClient instance with a fixture fetch stub, install the rewriter + auth middlewares, fire a request, and assert:
    • The outgoing request URL is the spliced one.
    • The outgoing request body matches the input body byte-for-byte.
    • The outgoing request Authorization header is the one getAuthHeader returned.
    • When the response is 2xx, the middleware chain returns the response without throwing.
    • When the response is 4xx, the middleware chain throws CarolApiError.

The test environment needs to spec the same Request / Response / Headers shape RN ships. Vitest's happy-dom or jsdom both satisfy that surface; pick whichever matches what apps/client's test suite already uses.

Acceptance criteria

  • Tests fail if any one of the four bug-fixes in PR #232 is reverted.
  • Unit tests are pure-function (no fetch I/O), middleware tests stub fetch with a recorded call list.
  • pnpm -F @carol/client test stays green.
  • Mocha-style "describe + it" with explicit assertions on URL, body, headers, and thrown error type.

Out of scope

  • Hooks-layer tests (TanStack Query integration). Those live in packages/api-client's test surface.
  • Full network round-trip tests against a running API. That's the native E2E ticket, filed alongside this one.

Composes with

  • #232 — the original fixes.
  • The native E2E smoke ticket (filed alongside).

Part of

#176

## Context PR [#232](https://forge.wynning.tech/james/carol/pulls/232) fixed four separate bugs in `apps/client/lib/apiClient.ts`'s off-origin URL rewriter that surfaced when running the universal client on a real Android device via Expo Go: 1. `new URL(originalUrl)` threw on relative paths — RN's whatwg-fetch keeps relative inputs literal, so the rewriter silently no-op'd and the request went to whatever bundle origin RN happened to resolve. Manifested as 404s on login. 2. `new Request(next, request)` didn't transfer the body stream via RN's polyfill — server saw empty body, parsed it as invalid JSON. Manifested as `"invalid_body"` on every POST. 3. Returning the unchanged input from `onRequest` / `onResponse` middleware tripped openapi-fetch's `instanceof Request` / `instanceof Response` check on RN's polyfill class identity. Manifested as `"onResponse: must return new Response() when modifying the response"`. 4. `headers: request.headers` passed a `Headers` instance to RN's Request constructor — RN's polyfill doesn't iterate that correctly, the Authorization header set by authMiddleware disappeared, every authenticated GET returned 401. Manifested as the post-login bounce-back. All four are unit-testable in pure JS — the rewriter is a pure function and the middleware contract is observable from the outside. They'd have been caught by tests against RN's whatwg-fetch polyfill before reaching a device. ## Source Follow-up from the PR [#232](https://forge.wynning.tech/james/carol/pulls/232) debugging cycle. ## Scope - Export `rewriteRequestUrl` from `apps/client/lib/apiClient.ts` for testing (or extract it to a sibling module — your call; the existing file already exports `apiClient` so a sibling keeps the boundary clean). - Add `apps/client/tests/apiClient.test.ts` covering: - `rewriteRequestUrl` with an absolute URL → splice the configured base over the path. - `rewriteRequestUrl` with a relative URL starting with `/` → splice the configured base directly (the RN path). - `rewriteRequestUrl` with a foreign absolute URL → pass through. - `rewriteRequestUrl` with no configured base → pass through. - The off-origin gate: PWA web (Platform.web, not Tauri) → no-op. - Add a middleware-contract test in `apps/client/tests/` or `packages/api-client/tests/`. Construct a `createApiClient` instance with a fixture `fetch` stub, install the rewriter + auth middlewares, fire a request, and assert: - The outgoing request URL is the spliced one. - The outgoing request body matches the input body byte-for-byte. - The outgoing request `Authorization` header is the one `getAuthHeader` returned. - When the response is 2xx, the middleware chain returns the response without throwing. - When the response is 4xx, the middleware chain throws `CarolApiError`. The test environment needs to spec the same Request / Response / Headers shape RN ships. Vitest's `happy-dom` or `jsdom` both satisfy that surface; pick whichever matches what `apps/client`'s test suite already uses. ## Acceptance criteria - [ ] Tests fail if any one of the four bug-fixes in PR #232 is reverted. - [ ] Unit tests are pure-function (no fetch I/O), middleware tests stub fetch with a recorded call list. - [ ] `pnpm -F @carol/client test` stays green. - [ ] Mocha-style "describe + it" with explicit assertions on URL, body, headers, and thrown error type. ## Out of scope - Hooks-layer tests (TanStack Query integration). Those live in `packages/api-client`'s test surface. - Full network round-trip tests against a running API. That's the native E2E ticket, filed alongside this one. ## Composes with - [#232](https://forge.wynning.tech/james/carol/pulls/232) — the original fixes. - The native E2E smoke ticket (filed alongside). ## Part of [#176](https://forge.wynning.tech/james/carol/issues/176)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#233
No description provided.