mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
5b9824c379
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
579 lines
19 KiB
TypeScript
579 lines
19 KiB
TypeScript
import { act, renderHook } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// --- Mock next/navigation ---
|
|
const mockPush = vi.fn();
|
|
let mockSearchParamsValue = new URLSearchParams();
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ push: mockPush }),
|
|
usePathname: () => "/findings",
|
|
useSearchParams: () => mockSearchParamsValue,
|
|
}));
|
|
|
|
import { useFilterBatch } from "./use-filter-batch";
|
|
|
|
/**
|
|
* Helper to re-assign the mocked searchParams and re-import the hook.
|
|
* Because useSearchParams() is called inside the hook on every render,
|
|
* we just update the module-level variable and force a re-render.
|
|
*/
|
|
function setSearchParams(params: Record<string, string>) {
|
|
mockSearchParamsValue = new URLSearchParams(params);
|
|
}
|
|
|
|
describe("useFilterBatch", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockSearchParamsValue = new URLSearchParams();
|
|
});
|
|
|
|
// ── Initial state ──────────────────────────────────────────────────────────
|
|
|
|
describe("initial state", () => {
|
|
it("should have empty pending filters when there are no URL params", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
|
|
// When
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Then
|
|
expect(result.current.pendingFilters).toEqual({});
|
|
expect(result.current.hasChanges).toBe(false);
|
|
expect(result.current.changeCount).toBe(0);
|
|
});
|
|
|
|
it("should initialize pending filters from URL search params on mount", () => {
|
|
// Given
|
|
setSearchParams({
|
|
"filter[severity__in]": "critical,high",
|
|
"filter[status__in]": "FAIL",
|
|
});
|
|
|
|
// When
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Then
|
|
expect(result.current.pendingFilters).toEqual({
|
|
"filter[severity__in]": ["critical", "high"],
|
|
"filter[status__in]": ["FAIL"],
|
|
});
|
|
expect(result.current.hasChanges).toBe(false);
|
|
});
|
|
|
|
it("should expose filter[delta]=new under the FILTER_FIELD.DELTA key so the dropdown shows it selected", async () => {
|
|
// Given — URL from LinkToFindings uses `filter[delta]` (singular), matching the API.
|
|
setSearchParams({
|
|
"filter[status__in]": "FAIL",
|
|
"filter[delta]": "new",
|
|
});
|
|
|
|
const { FILTER_FIELD } = await import("@/types/filters");
|
|
|
|
// When
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Then — the Delta dropdown reads via getFilterValue(`filter[${FILTER_FIELD.DELTA}]`).
|
|
// For the checkbox of "new" to appear checked, that lookup must return ["new"].
|
|
expect(
|
|
result.current.getFilterValue(`filter[${FILTER_FIELD.DELTA}]`),
|
|
).toEqual(["new"]);
|
|
});
|
|
|
|
it("should include both filter[status__in] and filter[delta] from the overview deep link", () => {
|
|
// Given — URL produced by LinkToFindings: /findings?...&filter[status__in]=FAIL&filter[delta]=new
|
|
setSearchParams({
|
|
"filter[status__in]": "FAIL",
|
|
"filter[delta]": "new",
|
|
});
|
|
|
|
// When
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Then — the singular `filter[delta]` key must be captured in pendingFilters
|
|
// so FindingsFilters can render a chip for it (same as filter[status__in]).
|
|
expect(result.current.pendingFilters).toEqual({
|
|
"filter[status__in]": ["FAIL"],
|
|
"filter[delta]": ["new"],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Excluded keys ──────────────────────────────────────────────────────────
|
|
|
|
describe("excluded keys", () => {
|
|
it("should exclude filter[search] from batch operations", () => {
|
|
// Given — search is excluded from batch; muted now participates in batch
|
|
setSearchParams({
|
|
"filter[search]": "some-search-term",
|
|
"filter[muted]": "false",
|
|
"filter[severity__in]": "critical",
|
|
});
|
|
|
|
// When
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Then — severity and muted are in pendingFilters; search is excluded
|
|
expect(result.current.pendingFilters).toEqual({
|
|
"filter[muted]": ["false"],
|
|
"filter[severity__in]": ["critical"],
|
|
});
|
|
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
|
|
// muted is now part of batch (not excluded)
|
|
expect(result.current.pendingFilters["filter[muted]"]).toEqual(["false"]);
|
|
});
|
|
});
|
|
|
|
// ── setPending ─────────────────────────────────────────────────────────────
|
|
|
|
describe("setPending", () => {
|
|
it("should update pending state for a given key", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
|
});
|
|
|
|
// Then
|
|
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
|
"critical",
|
|
"high",
|
|
]);
|
|
});
|
|
|
|
it("should auto-prefix key with filter[] when not already prefixed", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.setPending("severity__in", ["critical"]);
|
|
});
|
|
|
|
// Then — key is stored with filter[] prefix
|
|
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
|
"critical",
|
|
]);
|
|
});
|
|
|
|
it("should keep the key but with empty array when values is empty", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Pre-condition: set a value first
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical"]);
|
|
});
|
|
|
|
// When — clear the filter by passing empty array
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", []);
|
|
});
|
|
|
|
// Then
|
|
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── getFilterValue ─────────────────────────────────────────────────────────
|
|
|
|
describe("getFilterValue", () => {
|
|
it("should return pending values for a key that has been set", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
|
});
|
|
|
|
// When
|
|
const values = result.current.getFilterValue("filter[severity__in]");
|
|
|
|
// Then
|
|
expect(values).toEqual(["critical", "high"]);
|
|
});
|
|
|
|
it("should return an empty array for a key that has not been set", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// When
|
|
const values = result.current.getFilterValue("filter[unknown_key]");
|
|
|
|
// Then
|
|
expect(values).toEqual([]);
|
|
});
|
|
|
|
it("should auto-prefix key when calling getFilterValue without filter[]", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical"]);
|
|
});
|
|
|
|
// When — key without prefix
|
|
const values = result.current.getFilterValue("severity__in");
|
|
|
|
// Then
|
|
expect(values).toEqual(["critical"]);
|
|
});
|
|
});
|
|
|
|
// ── hasChanges & changeCount ───────────────────────────────────────────────
|
|
|
|
describe("hasChanges", () => {
|
|
it("should be false when pending matches the URL state", () => {
|
|
// Given
|
|
setSearchParams({ "filter[severity__in]": "critical" });
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Then — initial state = URL state, so no changes
|
|
expect(result.current.hasChanges).toBe(false);
|
|
});
|
|
|
|
it("should be true when pending differs from the URL state", () => {
|
|
// Given
|
|
setSearchParams({ "filter[severity__in]": "critical" });
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// When — change pending
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
|
});
|
|
|
|
// Then
|
|
expect(result.current.hasChanges).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("changeCount", () => {
|
|
it("should be 0 when pending matches URL", () => {
|
|
// Given
|
|
setSearchParams({ "filter[severity__in]": "critical" });
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Then
|
|
expect(result.current.changeCount).toBe(0);
|
|
});
|
|
|
|
it("should count the number of changed filter keys", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// When — add two different pending filters
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical"]);
|
|
result.current.setPending("filter[status__in]", ["FAIL"]);
|
|
});
|
|
|
|
// Then — 2 keys differ from URL (which has neither)
|
|
expect(result.current.changeCount).toBe(2);
|
|
});
|
|
|
|
it("should decrease changeCount when a pending filter is reset to match URL", () => {
|
|
// Given — URL has severity=critical, pending adds status=FAIL
|
|
setSearchParams({ "filter[severity__in]": "critical" });
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
act(() => {
|
|
result.current.setPending("filter[status__in]", ["FAIL"]);
|
|
});
|
|
|
|
expect(result.current.changeCount).toBe(1);
|
|
|
|
// When — reset status back to empty (matching URL which has no status)
|
|
act(() => {
|
|
result.current.setPending("filter[status__in]", []);
|
|
});
|
|
|
|
// Then
|
|
expect(result.current.changeCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── applyAll ───────────────────────────────────────────────────────────────
|
|
|
|
describe("applyAll", () => {
|
|
it("should call router.push with all pending filters serialized as URL params", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
|
});
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.applyAll();
|
|
});
|
|
|
|
// Then
|
|
expect(mockPush).toHaveBeenCalledTimes(1);
|
|
const calledUrl: string = mockPush.mock.calls[0][0];
|
|
expect(calledUrl).toContain("filter%5Bseverity__in%5D=critical%2Chigh");
|
|
});
|
|
|
|
it("should reset page number when a page param exists in the URL", () => {
|
|
// Given — simulate a URL that already has page=3
|
|
mockSearchParamsValue = new URLSearchParams({
|
|
"filter[severity__in]": "critical",
|
|
page: "3",
|
|
});
|
|
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
act(() => {
|
|
result.current.setPending("filter[status__in]", ["FAIL"]);
|
|
});
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.applyAll();
|
|
});
|
|
|
|
// Then — page should be reset to 1
|
|
const calledUrl: string = mockPush.mock.calls[0][0];
|
|
expect(calledUrl).toContain("page=1");
|
|
});
|
|
|
|
it("should preserve excluded params (filter[search], filter[muted]) in the URL", () => {
|
|
// Given
|
|
mockSearchParamsValue = new URLSearchParams({
|
|
"filter[search]": "my-search",
|
|
"filter[muted]": "false",
|
|
});
|
|
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical"]);
|
|
});
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.applyAll();
|
|
});
|
|
|
|
// Then — search and muted should still be present
|
|
const calledUrl: string = mockPush.mock.calls[0][0];
|
|
expect(calledUrl).toContain("filter%5Bsearch%5D=my-search");
|
|
expect(calledUrl).toContain("filter%5Bmuted%5D=false");
|
|
});
|
|
});
|
|
|
|
// ── discardAll ─────────────────────────────────────────────────────────────
|
|
|
|
describe("discardAll", () => {
|
|
it("should reset pending to match the current URL state", () => {
|
|
// Given — URL has severity=critical
|
|
setSearchParams({ "filter[severity__in]": "critical" });
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Add a pending change
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical", "high"]);
|
|
result.current.setPending("filter[status__in]", ["FAIL"]);
|
|
});
|
|
|
|
expect(result.current.hasChanges).toBe(true);
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.discardAll();
|
|
});
|
|
|
|
// Then — pending should match URL again
|
|
expect(result.current.pendingFilters).toEqual({
|
|
"filter[severity__in]": ["critical"],
|
|
});
|
|
expect(result.current.hasChanges).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── URL sync (back/forward) ────────────────────────────────────────────────
|
|
|
|
describe("URL sync", () => {
|
|
it("should re-sync pending state when searchParams change (e.g., browser back/forward)", () => {
|
|
// Given — initial empty URL
|
|
setSearchParams({});
|
|
const { result, rerender } = renderHook(() => useFilterBatch());
|
|
|
|
// Add a pending change
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical"]);
|
|
});
|
|
|
|
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
|
"critical",
|
|
]);
|
|
|
|
// When — simulate browser back by changing searchParams externally
|
|
act(() => {
|
|
mockSearchParamsValue = new URLSearchParams({
|
|
"filter[severity__in]": "high",
|
|
});
|
|
});
|
|
rerender();
|
|
|
|
// Then — pending should re-sync from new URL
|
|
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
|
"high",
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ── removePending ──────────────────────────────────────────────────────────
|
|
|
|
describe("removePending", () => {
|
|
it("should remove a single filter key from pending state", () => {
|
|
// Given
|
|
setSearchParams({});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
act(() => {
|
|
result.current.setPending("filter[severity__in]", ["critical"]);
|
|
result.current.setPending("filter[status__in]", ["FAIL"]);
|
|
});
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.removePending("filter[severity__in]");
|
|
});
|
|
|
|
// Then
|
|
expect(
|
|
result.current.pendingFilters["filter[severity__in]"],
|
|
).toBeUndefined();
|
|
expect(result.current.pendingFilters["filter[status__in]"]).toEqual([
|
|
"FAIL",
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ── clearAndApply ──────────────────────────────────────────────────────────
|
|
|
|
describe("clearAndApply", () => {
|
|
it("should clear all batch-managed filters and push URL immediately", () => {
|
|
// Given
|
|
setSearchParams({
|
|
"filter[severity__in]": "critical",
|
|
"filter[status__in]": "FAIL",
|
|
});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// Pre-condition — pending is loaded from URL
|
|
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
|
|
"critical",
|
|
]);
|
|
expect(result.current.pendingFilters["filter[status__in]"]).toEqual([
|
|
"FAIL",
|
|
]);
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.clearAndApply();
|
|
});
|
|
|
|
// Then — pending is empty
|
|
expect(result.current.pendingFilters).toEqual({});
|
|
|
|
// And router.push was called
|
|
expect(mockPush).toHaveBeenCalledTimes(1);
|
|
const calledUrl: string = mockPush.mock.calls[0][0];
|
|
|
|
// The pushed URL must NOT contain severity or status
|
|
expect(calledUrl).not.toContain("severity");
|
|
expect(calledUrl).not.toContain("status");
|
|
});
|
|
|
|
it("should not expose cleared URL filters as pending changes while navigation updates", () => {
|
|
// Given
|
|
setSearchParams({
|
|
"filter[severity__in]": "critical",
|
|
"filter[status__in]": "FAIL",
|
|
});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.clearAndApply();
|
|
});
|
|
|
|
// Then — the UI should not render the pending-filter row after clearing.
|
|
expect(result.current.appliedFilters).toEqual({});
|
|
expect(result.current.changedFilters).toEqual({});
|
|
expect(result.current.hasChanges).toBe(false);
|
|
expect(result.current.changeCount).toBe(0);
|
|
});
|
|
|
|
it("should apply defaultParams when clearing", () => {
|
|
// Given
|
|
setSearchParams({ "filter[severity__in]": "critical" });
|
|
const { result } = renderHook(() =>
|
|
useFilterBatch({ defaultParams: { "filter[muted]": "false" } }),
|
|
);
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.clearAndApply();
|
|
});
|
|
|
|
// Then — pushed URL contains the defaultParam
|
|
expect(mockPush).toHaveBeenCalledTimes(1);
|
|
const calledUrl: string = mockPush.mock.calls[0][0];
|
|
expect(calledUrl).toContain("filter%5Bmuted%5D=false");
|
|
});
|
|
|
|
it("should preserve filter[search] (excluded from batch)", () => {
|
|
// Given — URL has both a search param (excluded) and a batch filter
|
|
mockSearchParamsValue = new URLSearchParams({
|
|
"filter[search]": "test",
|
|
"filter[severity__in]": "critical",
|
|
});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.clearAndApply();
|
|
});
|
|
|
|
// Then — search param is preserved; severity is gone
|
|
expect(mockPush).toHaveBeenCalledTimes(1);
|
|
const calledUrl: string = mockPush.mock.calls[0][0];
|
|
expect(calledUrl).toContain("filter%5Bsearch%5D=test");
|
|
expect(calledUrl).not.toContain("severity");
|
|
});
|
|
|
|
it("should reset pagination to page 1", () => {
|
|
// Given — URL already has a page param
|
|
mockSearchParamsValue = new URLSearchParams({
|
|
"filter[severity__in]": "critical",
|
|
page: "3",
|
|
});
|
|
const { result } = renderHook(() => useFilterBatch());
|
|
|
|
// When
|
|
act(() => {
|
|
result.current.clearAndApply();
|
|
});
|
|
|
|
// Then — page is reset to 1
|
|
expect(mockPush).toHaveBeenCalledTimes(1);
|
|
const calledUrl: string = mockPush.mock.calls[0][0];
|
|
expect(calledUrl).toContain("page=1");
|
|
});
|
|
});
|
|
});
|