mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
merge: F5 ComplianceTab actor filter + CompressionLogTab namespace fix
This commit is contained in:
@@ -72,6 +72,7 @@ export default function ComplianceTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [eventType, setEventType] = useState("");
|
||||
const [actor, setActor] = useState("");
|
||||
const [severity, setSeverity] = useState<"all" | Severity>("all");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
@@ -85,6 +86,7 @@ export default function ComplianceTab() {
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (actor) params.set("actor", actor);
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
params.set("offset", String(offset));
|
||||
if (eventType) params.set("action", eventType);
|
||||
@@ -105,7 +107,7 @@ export default function ComplianceTab() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventType, from, offset, t, to]);
|
||||
}, [actor, eventType, from, offset, t, to]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchEntries();
|
||||
@@ -120,10 +122,15 @@ export default function ComplianceTab() {
|
||||
return Array.from(new Set(entries.map((entry) => entry.action).filter(Boolean))).sort();
|
||||
}, [entries]);
|
||||
|
||||
const actors = useMemo(() => {
|
||||
return Array.from(new Set(entries.map((entry) => entry.actor).filter(Boolean))).sort();
|
||||
}, [entries]);
|
||||
|
||||
const canGoNext = offset + PAGE_SIZE < totalCount;
|
||||
|
||||
const resetFilters = () => {
|
||||
setEventType("");
|
||||
setActor("");
|
||||
setSeverity("all");
|
||||
setFrom("");
|
||||
setTo("");
|
||||
@@ -186,7 +193,7 @@ export default function ComplianceTab() {
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{t("eventType")}
|
||||
@@ -207,6 +214,26 @@ export default function ComplianceTab() {
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{t("actor")}
|
||||
</span>
|
||||
<input
|
||||
list="compliance-actors"
|
||||
value={actor}
|
||||
onChange={(event) => {
|
||||
setOffset(0);
|
||||
setActor(event.target.value);
|
||||
}}
|
||||
placeholder={t("actorPlaceholder")}
|
||||
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<datalist id="compliance-actors">
|
||||
{actors.map((a) => (
|
||||
<option key={a} value={a} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{t("severity")}
|
||||
|
||||
@@ -24,7 +24,7 @@ interface LogEntry {
|
||||
}
|
||||
|
||||
export default function CompressionLogTab() {
|
||||
const t = useTranslations("settings");
|
||||
const t = useTranslations("logs");
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
@@ -1137,7 +1137,9 @@
|
||||
"a2aEvents": "Events",
|
||||
"a2aArtifacts": "Artifacts",
|
||||
"a2aNoTasks": "No A2A tasks recorded.",
|
||||
"a2aLoadingTasks": "Loading A2A tasks..."
|
||||
"a2aLoadingTasks": "Loading A2A tasks...",
|
||||
"actor": "Actor",
|
||||
"actorPlaceholder": "Filter by actor"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -3357,7 +3359,10 @@
|
||||
"fetchFailed": "Failed to fetch logs",
|
||||
"copyFailed": "Failed to copy log entry",
|
||||
"copyLogEntry": "Copy log entry"
|
||||
}
|
||||
},
|
||||
"compressionLogTitle": "Compression Log",
|
||||
"compressionLogEmpty": "No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome",
|
||||
|
||||
@@ -1137,7 +1137,9 @@
|
||||
"a2aEvents": "Eventos",
|
||||
"a2aArtifacts": "Artefatos",
|
||||
"a2aNoTasks": "Nenhuma tarefa A2A registrada.",
|
||||
"a2aLoadingTasks": "Carregando tarefas A2A..."
|
||||
"a2aLoadingTasks": "Carregando tarefas A2A...",
|
||||
"actor": "Autor",
|
||||
"actorPlaceholder": "Filtrar por autor"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -3354,7 +3356,10 @@
|
||||
"fetchFailed": "Failed to fetch logs",
|
||||
"copyFailed": "Failed to copy log entry",
|
||||
"copyLogEntry": "Copy log entry"
|
||||
}
|
||||
},
|
||||
"compressionLogTitle": "Compression Log",
|
||||
"compressionLogEmpty": "No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.",
|
||||
"tokens": "tokens"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Bem-vindo",
|
||||
|
||||
156
tests/unit/ui/compliance-tab-actor-filter.test.tsx
Normal file
156
tests/unit/ui/compliance-tab-actor-filter.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Minimal i18n stub — returns key as-is so assertions are stable.
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values && typeof values.count !== "undefined" && typeof values.total !== "undefined") {
|
||||
return `${values.count}/${values.total} ${key}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub fetch before importing the component.
|
||||
const fetchCalls: string[] = [];
|
||||
const mockFetch = vi.fn((url: string) => {
|
||||
fetchCalls.push(url);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
headers: {
|
||||
get: (name: string) => (name === "x-total-count" ? "0" : null),
|
||||
},
|
||||
} as unknown as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
// Import component after mocks.
|
||||
const { default: ComplianceTab } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/audit/ComplianceTab"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForCondition(fn: () => boolean, timeoutMs = 5000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitForCondition timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function renderTab() {
|
||||
// Must set IS_REACT_ACT_ENVIRONMENT before each render.
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(<ComplianceTab />);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls.length = 0;
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ComplianceTab — actor filter", { timeout: 30000 }, () => {
|
||||
it("renders the actor label, input and datalist", async () => {
|
||||
const el = renderTab();
|
||||
|
||||
// Wait until fetch resolves and the component re-renders with the filter grid.
|
||||
await waitForCondition(() => el.querySelector('input[list="compliance-actors"]') !== null);
|
||||
|
||||
// The actor label text should appear (i18n stub returns key as-is).
|
||||
expect(el.textContent).toContain("actor");
|
||||
|
||||
// An input with list="compliance-actors" must exist.
|
||||
const actorInput = el.querySelector('input[list="compliance-actors"]');
|
||||
expect(actorInput).toBeTruthy();
|
||||
|
||||
// A datalist with id="compliance-actors" must exist.
|
||||
const datalist = el.querySelector("datalist#compliance-actors");
|
||||
expect(datalist).toBeTruthy();
|
||||
});
|
||||
|
||||
it("initial fetch does not include actor param", async () => {
|
||||
renderTab();
|
||||
|
||||
// Wait until at least one fetch call is made.
|
||||
await waitForCondition(() => fetchCalls.length > 0);
|
||||
|
||||
// The first fetch should not include an actor param (state is "").
|
||||
expect(fetchCalls[0]).not.toContain("actor=");
|
||||
});
|
||||
|
||||
it("re-fetches with actor param after typing in the actor input", async () => {
|
||||
const el = renderTab();
|
||||
await waitForCondition(() => el.querySelector('input[list="compliance-actors"]') !== null);
|
||||
|
||||
const beforeCount = fetchCalls.length;
|
||||
|
||||
const actorInput = el.querySelector(
|
||||
'input[list="compliance-actors"]'
|
||||
) as HTMLInputElement | null;
|
||||
expect(actorInput).toBeTruthy();
|
||||
|
||||
// Simulate React controlled input change via nativeInputValueSetter + dispatchEvent.
|
||||
act(() => {
|
||||
if (actorInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(actorInput, "admin");
|
||||
actorInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
actorInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for a new fetch triggered by the actor state change.
|
||||
await waitForCondition(
|
||||
() => fetchCalls.slice(beforeCount).some((url) => url.includes("actor=admin")),
|
||||
5000
|
||||
);
|
||||
|
||||
expect(fetchCalls.some((url) => url.includes("actor=admin"))).toBe(true);
|
||||
});
|
||||
|
||||
it("clearFilters button exists and clicking it does not throw", async () => {
|
||||
const el = renderTab();
|
||||
await waitForCondition(() => el.querySelector('input[list="compliance-actors"]') !== null);
|
||||
|
||||
// Find the clearFilters button (i18n stub returns key "clearFilters").
|
||||
const clearBtn = Array.from(el.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes("clearFilters")
|
||||
);
|
||||
expect(clearBtn).toBeTruthy();
|
||||
|
||||
// Clicking the button should not throw.
|
||||
expect(() => {
|
||||
act(() => {
|
||||
clearBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
122
tests/unit/ui/compression-log-namespace.test.tsx
Normal file
122
tests/unit/ui/compression-log-namespace.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import CompressionLogTab from "@/app/(dashboard)/dashboard/logs/CompressionLogTab";
|
||||
|
||||
// Keys that CompressionLogTab uses from the "logs" namespace.
|
||||
const LOGS_KEYS = ["loading", "compressionLogTitle", "compressionLogEmpty", "tokens"] as const;
|
||||
|
||||
// Track calls to useTranslations so we can assert the namespace.
|
||||
const namespacesSeen: string[] = [];
|
||||
|
||||
// i18n stub that simulates next-intl's useTranslations.
|
||||
// Only the "logs" namespace has the compression-related keys.
|
||||
const logsMessages: Record<string, string> = {
|
||||
loading: "Loading...",
|
||||
compressionLogTitle: "Compression Log",
|
||||
compressionLogEmpty:
|
||||
"No compressed requests yet. Compression stats will appear here when requests are processed with compression enabled.",
|
||||
tokens: "tokens",
|
||||
};
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (namespace: string) => {
|
||||
namespacesSeen.push(namespace);
|
||||
return (key: string) => {
|
||||
if (namespace === "logs") {
|
||||
return logsMessages[key] ?? `_MISSING_${key}`;
|
||||
}
|
||||
// Any other namespace returns a sentinel so tests can catch wrong namespace.
|
||||
return `_WRONG_NS_${namespace}_${key}`;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch so the component doesn't fail on network calls.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as unknown as Response)
|
||||
)
|
||||
);
|
||||
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
cleanupCallbacks.push(() => container.remove());
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("CompressionLogTab — logs namespace", { timeout: 30000 }, () => {
|
||||
beforeEach(() => {
|
||||
namespacesSeen.length = 0;
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
cleanupCallbacks.pop()?.();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders without missing-key sentinels — all required logs.* keys are present", async () => {
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<CompressionLogTab />);
|
||||
});
|
||||
|
||||
// The rendered text must NOT contain any _MISSING_ or _WRONG_NS_ sentinel.
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).not.toContain("_MISSING_");
|
||||
expect(text).not.toContain("_WRONG_NS_");
|
||||
});
|
||||
|
||||
it("uses the 'logs' namespace (not 'settings')", async () => {
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<CompressionLogTab />);
|
||||
});
|
||||
|
||||
// Verify that useTranslations was called with "logs".
|
||||
expect(namespacesSeen).toContain("logs");
|
||||
// Must NOT have been called with "settings".
|
||||
expect(namespacesSeen).not.toContain("settings");
|
||||
});
|
||||
|
||||
it("renders loading state text from logs namespace", async () => {
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<CompressionLogTab />);
|
||||
});
|
||||
|
||||
// Either the loading text or the empty state text should appear — both come from logs keys.
|
||||
const text = container.textContent ?? "";
|
||||
const hasExpectedText =
|
||||
text.includes(logsMessages.loading) ||
|
||||
text.includes(logsMessages.compressionLogEmpty) ||
|
||||
text.includes(logsMessages.compressionLogTitle);
|
||||
|
||||
expect(hasExpectedText).toBe(true);
|
||||
});
|
||||
|
||||
it("all required logs keys have defined values in the mock", () => {
|
||||
// Sanity-check: the test mock itself covers all the keys the component uses.
|
||||
for (const key of LOGS_KEYS) {
|
||||
expect(logsMessages[key]).toBeDefined();
|
||||
expect(typeof logsMessages[key]).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user