API client — typed fetch, query params, and errors that throw
Exercised over real HTTP against this app's own static assets — a 200 from a fixture and a genuine 404 from the file server. fetch() resolves on an error status; this client does not, which is the whole point. The arbitrary-status route handler this page used to call did not survive the move to a static export; the cards below say exactly what was lost.
buildQueryParams
Handles the cases hand-built strings get wrong: arrays become repeated keys, undefined is dropped rather than serialised as the string “undefined”, false is kept (it is a value, not an absence), and everything is encoded.
{
"status": "Approved",
"tags": [
"hipaa",
"phi"
],
"page": 2,
"includeArchived": false,
"owner": "«undefined»",
"note": "a value with spaces & an ampersand"
}becomes
status=Approved&tags=hipaa&tags=phi&page=2&includeArchived=false¬e=a+value+with+spaces+%26+an+ampersand
Note owner is absent entirely and includeArchived=false is present. Dropping a false is a common hand-rolled bug — it flips the server's default.
apiFetch — a real round trip
GETs /api/echo.json, a static fixture served over real HTTP. Headers, parsing and response typing are the client's job and are genuinely exercised; the generic parameter types the response. It was a POST to a route handler that echoed the request body until this demo moved to a static export — a static file cannot vary by request, so the reflection is gone and the round trip is not.
Errors — the part worth demonstrating
fetch() does not reject on 4xx or 5xx — it resolves with ok: false. Code written as const data = await res.json() then happily uses an error body as data. apiFetch throws a typed ApiError instead, so a failure cannot be mistaken for a result.
This offered 400, 401, 403, 404, 500 and 503 until the demo moved to a static export. A static file server cannot be asked to return a chosen status, so the other five were removed rather than simulated — a faked status would demonstrate the mock, not the client. The 404 body is HTML or empty rather than JSON for the same reason, so ApiError.body carrying a server's JSON error is described below instead of shown here.