fix(api): handle 204 No Content в apiFetch (#1219) #1249

Merged
bot-backend merged 1 commit from fix/api-fetch-204-no-content into main 2026-06-13 05:13:44 +00:00
2 changed files with 130 additions and 1 deletions

View file

@ -0,0 +1,117 @@
/**
* Tests for `apiFetch` regression coverage для issue #1219.
*
* Bug: `apiFetch` безусловно звал `response.json()` после `response.ok`-check,
* что падало с `SyntaxError: Unexpected end of JSON input` на 204 No Content
* (DELETE /api/v1/custom-pois/{id}, DELETE /api/v1/admin/site-finder/weight-
* profiles/{id}). Промис мутации реджектился onSuccess/invalidateQueries не
* срабатывали UI рассинхронизировался с сервером (объект удалён, но виден).
*
* Fix: на 204 / пустом теле resolve'имся с undefined (см. api.ts:apiFetch).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "../api";
// Минимальный mock Response — fetch global под jsdom есть, но контролируем
// status/body точечно через vi.stubGlobal.
function mockResponse(init: {
status: number;
body?: string;
ok?: boolean;
}): Response {
const { status, body = "", ok = status >= 200 && status < 300 } = init;
return {
ok,
status,
text: () => Promise.resolve(body),
json: () =>
body
? Promise.resolve(JSON.parse(body))
: Promise.reject(new SyntaxError("Unexpected end of JSON input")),
} as unknown as Response;
}
describe("apiFetch — 204 No Content handling (#1219)", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("resolves with undefined on 204 No Content (empty body)", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
mockResponse({ status: 204, body: "" }),
);
const result = await apiFetch<unknown>("/api/v1/custom-pois/42", {
method: "DELETE",
});
expect(result).toBeUndefined();
});
it("does not throw SyntaxError on 204 (regression guard)", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
mockResponse({ status: 204, body: "" }),
);
await expect(
apiFetch<unknown>("/api/v1/custom-pois/42", { method: "DELETE" }),
).resolves.toBeUndefined();
});
it("resolves with parsed JSON on 200 OK with body", async () => {
const payload = { id: 1, name: "Test POI", lat: 56.83, lon: 60.6 };
vi.mocked(fetch).mockResolvedValueOnce(
mockResponse({ status: 200, body: JSON.stringify(payload) }),
);
const result = await apiFetch<typeof payload>("/api/v1/custom-pois/1");
expect(result).toEqual(payload);
});
it("resolves with undefined on 200 OK with empty body (defensive)", async () => {
// Не-стандартно (200 + empty), но возможно для некоторых прокси/edge cases.
// До фикса падало тем же SyntaxError что и 204.
vi.mocked(fetch).mockResolvedValueOnce(
mockResponse({ status: 200, body: "" }),
);
const result = await apiFetch<unknown>("/api/v1/something");
expect(result).toBeUndefined();
});
it("throws on 4xx with detail body included", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
mockResponse({
status: 404,
body: JSON.stringify({ detail: "POI not found" }),
ok: false,
}),
);
await expect(
apiFetch<unknown>("/api/v1/custom-pois/999", { method: "DELETE" }),
).rejects.toThrow(/API error 404/);
});
it("throws on 5xx", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
mockResponse({
status: 500,
body: "Internal Server Error",
ok: false,
}),
);
await expect(apiFetch<unknown>("/api/v1/foo")).rejects.toThrow(
/API error 500/,
);
});
});

View file

@ -38,7 +38,19 @@ export async function apiFetch<T>(
if (!response.ok) {
throw new Error(`API error ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<T>;
// 204 No Content / пустое тело: `response.json()` бросит SyntaxError на пустой
// строке. Читаем как text один раз (stream consumable только раз) и парсим
// JSON только если непусто. На 204 / empty resolve'имся с undefined — caller
// для DELETE/no-content endpoints его игнорирует. Аналог поведения
// apiFetchWithStatus (см. ниже). Issue #1219.
if (response.status === 204) {
return undefined as T;
}
const rawText = await response.text();
if (!rawText) {
return undefined as T;
}
return JSON.parse(rawText) as T;
}
/**