diff --git a/frontend/src/lib/__tests__/api.test.ts b/frontend/src/lib/__tests__/api.test.ts new file mode 100644 index 00000000..968e02ca --- /dev/null +++ b/frontend/src/lib/__tests__/api.test.ts @@ -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("/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("/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("/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("/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("/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("/api/v1/foo")).rejects.toThrow( + /API error 500/, + ); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index beb34d43..a9b86866 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -38,7 +38,19 @@ export async function apiFetch( if (!response.ok) { throw new Error(`API error ${response.status}: ${await response.text()}`); } - return response.json() as Promise; + // 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; } /**