fix(dashboard): harden search analytics responses (#11603)

Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
Paco Cartones
2026-08-26 14:22:15 +02:00
committed by GitHub
parent 5f5d1101ba
commit a0422b9af8
3 changed files with 253 additions and 10 deletions

View File

@@ -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

View File

@@ -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<SearchStats>;
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<SearchStats> {
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<string | null>(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) {

View File

@@ -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<typeof fetch>();
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(<SearchAnalyticsTab />);
});
}
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<Response>(() => {
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");
});
});