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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
This commit is contained in:
2026-08-19 19:04:13 +07:00
co-authored by Claude Opus 5
parent 58a57ff4d0
commit 98be357da8
3 changed files with 36 additions and 4 deletions
+26
View File
@@ -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()
})
})
})
+9 -3
View File
@@ -20,14 +20,20 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
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 {