From 98be357da8e8c63d0fa1999ee19cab0c1c41b327 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 19:04:13 +0700 Subject: [PATCH] fix(web): handle empty-body 2xx responses; fix(store): sync DeleteDomainsNotInZones query with generated columns req() in the frontend api client called res.json() on every non-204 2xx response, which throws SyntaxError on the empty body returned by POST .../customs (201) and similar endpoints. Read the response body once as text and parse it only when non-empty, so any bodyless 2xx resolves instead of rejecting. Added web/src/api/client.test.ts coverage that exercises the real req() against a stubbed fetch for both the 201 (addCustom) and 204 (removeCustom) empty-body cases. Also brought internal/store/queries/domains.sql's DeleteDomainsNotInZones query back in sync with the hand-maintained internal/store/db/domains.sql.go (RETURNING * -> explicit column list) since sqlc isn't installed in this environment and the two files are kept aligned by hand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU --- internal/store/queries/domains.sql | 2 +- web/src/api/client.test.ts | 26 ++++++++++++++++++++++++++ web/src/api/client.ts | 12 +++++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/store/queries/domains.sql b/internal/store/queries/domains.sql index 8dc7f5c..f406bff 100644 --- a/internal/store/queries/domains.sql +++ b/internal/store/queries/domains.sql @@ -41,4 +41,4 @@ SELECT count(*) FROM domains WHERE last_check_status = 'drift'; -- name: DeleteDomainsNotInZones :many DELETE FROM domains WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[]) -RETURNING *; +RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status; diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 2eedff8..0f19401 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -201,4 +201,30 @@ describe("api client", () => { expect.objectContaining({ method: "GET", credentials: "include" }), ) }) + + // Empty-body 2xx responses: the real backend answers customs endpoints with + // no body at all (201 on add, 204 on remove). `res.json()` throws + // SyntaxError on an empty body, so `req()` must resolve instead of parsing + // when there is nothing to parse. Uses a fetch stub with no `json` method, + // mirroring a real empty-body Response, so an accidental `.json()` call + // fails the test instead of silently succeeding. + describe("empty-body 2xx responses", () => { + function mockEmptyBodyFetch(status: number) { + return vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status, + text: async () => "", + } as unknown as Response) + } + + it("addCustom resolves on 201 with an empty body", async () => { + mockEmptyBodyFetch(201) + await expect(api.addCustom(PROJECT_ID, "d1", "A a.")).resolves.toBeUndefined() + }) + + it("removeCustom resolves on 204 with an empty body", async () => { + mockEmptyBodyFetch(204) + await expect(api.removeCustom(PROJECT_ID, "d1", "A a.")).resolves.toBeUndefined() + }) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3e3b703..6e442c4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -20,14 +20,20 @@ async function req(path: string, init?: RequestInit): Promise { credentials: "include", ...init, }) + // Body can only be consumed once — read it as text up front, then parse + // (or not) from that string. Some responses (e.g. 201/204 on the customs + // endpoints) legitimately carry no body at all. + const raw = await res.text() if (res.status === 401) throw new UnauthorizedError() if (!res.ok) { let msg = `HTTP ${res.status}` - try { const b = await res.json(); if (b?.error) msg = String(b.error) } catch { /* ignore */ } + if (raw) { + try { const b = JSON.parse(raw); if (b?.error) msg = String(b.error) } catch { /* ignore */ } + } throw new Error(msg) } - if (res.status === 204) return undefined as T - return (await res.json()) as T + if (!raw) return undefined as T + return JSON.parse(raw) as T } function projectPath(projectId: string, path: string): string {