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:
@@ -41,4 +41,4 @@ SELECT count(*) FROM domains WHERE last_check_status = 'drift';
|
|||||||
-- name: DeleteDomainsNotInZones :many
|
-- name: DeleteDomainsNotInZones :many
|
||||||
DELETE FROM domains
|
DELETE FROM domains
|
||||||
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
|
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;
|
||||||
|
|||||||
@@ -201,4 +201,30 @@ describe("api client", () => {
|
|||||||
expect.objectContaining({ method: "GET", credentials: "include" }),
|
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",
|
credentials: "include",
|
||||||
...init,
|
...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.status === 401) throw new UnauthorizedError()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `HTTP ${res.status}`
|
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)
|
throw new Error(msg)
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T
|
if (!raw) return undefined as T
|
||||||
return (await res.json()) as T
|
return JSON.parse(raw) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectPath(projectId: string, path: string): string {
|
function projectPath(projectId: string, path: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user