diff --git a/changelog.d/fixes/11603-search-analytics-response-hardening.md b/changelog.d/fixes/11603-search-analytics-response-hardening.md new file mode 100644 index 0000000000..a0aa7a4e97 --- /dev/null +++ b/changelog.d/fixes/11603-search-analytics-response-hardening.md @@ -0,0 +1 @@ +- **fix(dashboard):** Show a stable error state when Search Analytics returns an HTTP error or malformed data ([#11603](https://github.com/diegosouzapw/OmniRoute/pull/11603)) — thanks @pacocartones diff --git a/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx index 5e6ed6bc27..dce71e2276 100644 --- a/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab.tsx @@ -22,6 +22,52 @@ interface SearchStats { avgDurationMs: number; } +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isSearchStats(value: unknown): value is SearchStats { + if (!value || typeof value !== "object") return false; + + const candidate = value as Partial; + return ( + isFiniteNumber(candidate.total) && + isFiniteNumber(candidate.today) && + isFiniteNumber(candidate.cached) && + isFiniteNumber(candidate.errors) && + isFiniteNumber(candidate.totalCostUsd) && + isFiniteNumber(candidate.cacheHitRate) && + isFiniteNumber(candidate.avgDurationMs) && + !!candidate.byProvider && + typeof candidate.byProvider === "object" && + !Array.isArray(candidate.byProvider) && + Object.values(candidate.byProvider).every( + (provider) => + !!provider && + typeof provider === "object" && + isFiniteNumber(provider.count) && + isFiniteNumber(provider.costUsd) + ) && + Array.isArray(candidate.last24h) && + candidate.last24h.every( + (point) => typeof point.hour === "string" && isFiniteNumber(point.count) + ) + ); +} + +async function readSearchStats(response: Response): Promise { + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = + body && typeof body === "object" && "error" in body && typeof body.error === "string" + ? body.error + : null; + throw new Error(message ?? "searchAnalyticsNoData"); + } + if (!isSearchStats(body)) throw new Error("searchAnalyticsNoData"); + return body; +} + function StatCard({ icon, label, @@ -85,16 +131,25 @@ export default function SearchAnalyticsTab() { const [error, setError] = useState(null); useEffect(() => { - fetch("/api/v1/search/analytics") - .then((r) => r.json()) - .then((d) => { - setStats(d); - setLoading(false); - }) - .catch((e) => { - setError(e.message); - setLoading(false); - }); + let cancelled = false; + const controller = new AbortController(); + async function loadStats() { + try { + const response = await fetch("/api/v1/search/analytics", { signal: controller.signal }); + const nextStats = await readSearchStats(response); + if (!cancelled) setStats(nextStats); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "searchAnalyticsNoData"; + if (!cancelled) setError(message); + } finally { + if (!cancelled) setLoading(false); + } + } + void loadStats(); + return () => { + cancelled = true; + controller.abort(); + }; }, []); if (loading) { diff --git a/tests/unit/search-analytics-response-hardening.test.tsx b/tests/unit/search-analytics-response-hardening.test.tsx new file mode 100644 index 0000000000..e077644d53 --- /dev/null +++ b/tests/unit/search-analytics-response-hardening.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); + +vi.mock("next-intl", () => ({ + useTranslations: () => { + const translate = (key: string) => key; + translate.rich = (key: string) => key; + return translate; + }, +})); + +let mounted: boolean; +describe("SearchAnalyticsTab response handling", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + mounted = true; + root = createRoot(container); + }); + + afterEach(async () => { + if (mounted) await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + async function renderTab() { + const { default: SearchAnalyticsTab } = + await import("@/app/(dashboard)/dashboard/analytics/SearchAnalyticsTab"); + await act(async () => { + root.render(); + }); + } + + it("shows the server error state for a non-OK JSON response", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + ); + + await renderTab(); + + expect(container.textContent).toContain("Internal server error"); + expect(container.textContent).not.toContain("searchAnalyticsTotalSearches"); + }); + + it("shows the fallback error state when the error response is not JSON", async () => { + fetchMock.mockResolvedValue(new Response("upstream unavailable", { status: 502 })); + + await renderTab(); + + expect(container.textContent).toContain("searchAnalyticsNoData"); + expect(container.textContent).not.toContain("searchAnalyticsTotalSearches"); + }); + + it("rejects a successful response with an invalid statistics shape", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ total: 3 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await renderTab(); + + expect(container.textContent).toContain("searchAnalyticsNoData"); + expect(container.textContent).not.toContain("searchAnalyticsTotalSearches"); + }); + + it("rejects malformed provider statistics before rendering", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + total: 1, + today: 1, + cached: 0, + errors: 0, + totalCostUsd: 0, + byProvider: { brave: { count: 1 } }, + last24h: [], + cacheHitRate: 0, + avgDurationMs: 12, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + await renderTab(); + + expect(container.textContent).toContain("searchAnalyticsNoData"); + expect(container.textContent).not.toContain("searchAnalyticsTotalSearches"); + }); + + it("rejects an array in place of the provider statistics map", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + total: 1, + today: 1, + cached: 0, + errors: 0, + totalCostUsd: 0, + byProvider: [], + last24h: [], + cacheHitRate: 0, + avgDurationMs: 12, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + await renderTab(); + + expect(container.textContent).toContain("searchAnalyticsNoData"); + expect(container.textContent).not.toContain("searchAnalyticsTotalSearches"); + }); + + it("aborts the analytics request when unmounted", async () => { + let requestSignal: AbortSignal | undefined; + fetchMock.mockImplementation( + (_input, init) => + new Promise(() => { + requestSignal = init?.signal ?? undefined; + }) + ); + + await renderTab(); + expect(requestSignal?.aborted).toBe(false); + + await act(async () => { + root.unmount(); + }); + mounted = false; + + expect(requestSignal?.aborted).toBe(true); + }); + + it("renders a valid statistics response", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + total: 3, + today: 2, + cached: 1, + errors: 0, + totalCostUsd: 0.25, + byProvider: { brave: { count: 3, costUsd: 0.25 } }, + last24h: [{ hour: "2026-08-26T04:00:00Z", count: 3 }], + + cacheHitRate: 33, + avgDurationMs: 12, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + await renderTab(); + + expect(container.textContent).toContain("searchAnalyticsTotalSearches"); + expect(container.textContent).toContain("brave"); + expect(container.textContent).toContain("$0.2500"); + expect(container.textContent).not.toContain("searchAnalyticsNoDataDescription"); + }); +});