mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
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:
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
187
tests/unit/search-analytics-response-hardening.test.tsx
Normal file
187
tests/unit/search-analytics-response-hardening.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user