fix(api): handle 204 No Content в apiFetch (#1219)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 48s
CI / frontend-tests (pull_request) Successful in 49s
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 48s
CI / frontend-tests (pull_request) Successful in 49s
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
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<T> сохранена — 80 не-204 callers
backward-compatible; два DELETE-хука уже типизированы <unknown>/<void>
и корректно игнорируют возврат.
6 новых vitest кейсов (api.test.ts): 204, empty-body-200, 200+JSON, 404,
500, regression guard. 73/73 frontend тестов зелёные, lint+tsc clean.
Closes #1219
This commit is contained in:
parent
c8000d1089
commit
8f84776969
2 changed files with 130 additions and 1 deletions
117
frontend/src/lib/__tests__/api.test.ts
Normal file
117
frontend/src/lib/__tests__/api.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue