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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user