From 8f84776969860d821a5d390d46971a9f6dac6531 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 10:04:11 +0500 Subject: [PATCH] =?UTF-8?q?fix(api):=20handle=20204=20No=20Content=20?= =?UTF-8?q?=D0=B2=20apiFetch=20(#1219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apiFetch безусловно звал response.json() после !response.ok-check, что падало SyntaxError'ом на пустом теле 204. DELETE /custom-pois/{id} и DELETE /admin/site-finder/weight-profiles/{id} объявлены status_code=204 → mutation promise реджектился → onSuccess/invalidateQueries не срабатывали → POI/профиль виден в UI до случайного рефетча, хотя реально удалён на сервере. Silent fail для DELETE кастомных POI на SiteMap (#1219, P2). Fix: на 204 или пустом теле resolve'имся с undefined (аналог семантики apiFetchWithStatus). Сигнатура Promise сохранена — 80 не-204 callers backward-compatible; два DELETE-хука уже типизированы / и корректно игнорируют возврат. 6 новых vitest кейсов (api.test.ts): 204, empty-body-200, 200+JSON, 404, 500, regression guard. 73/73 frontend тестов зелёные, lint+tsc clean. Closes #1219 --- frontend/src/lib/__tests__/api.test.ts | 117 +++++++++++++++++++++++++ frontend/src/lib/api.ts | 14 ++- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/__tests__/api.test.ts 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; } /** -- 2.45.3