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 {