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) { 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"], }); }); it("should parse filter[check_id] from grouped finding deep links", () => { // Given - URL produced by the grouped finding resources panel deep link. setSearchParams({ "filter[check_id]": "teams_external_users_can_join", expandedCheckId: "teams_external_users_can_join", }); // When const { result } = renderHook(() => useFilterBatch()); // Then - expandedCheckId is not a filter chip, but check_id is URL-backed. expect(result.current.pendingFilters).toEqual({ "filter[check_id]": ["teams_external_users_can_join"], }); expect(result.current.getFilterValue("filter[check_id]")).toEqual([ "teams_external_users_can_join", ]); }); }); // ── 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([]); }); it("should replace exclusive legacy filters when the new grouped filter is edited", () => { // Given - a legacy deep link with the exact check_id filter applied. setSearchParams({ "filter[check_id]": "teams_external_users_can_join", }); const { result } = renderHook(() => useFilterBatch({ exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]], }), ); // When - the Finding Group multi-select writes the __in filter. act(() => { result.current.setPending("filter[check_id__in]", [ "teams_external_users_cannot_join", ]); }); // Then - the stale exact filter is removed from pending state. expect(result.current.pendingFilters).toEqual({ "filter[check_id__in]": ["teams_external_users_cannot_join"], }); }); }); // ── 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"]); }); it("should expose legacy exclusive filter values through the replacement key", () => { // Given - a legacy grouped finding deep link uses filter[check_id]. setSearchParams({ "filter[check_id]": "teams_external_users_can_join", }); const { result } = renderHook(() => useFilterBatch({ exclusiveFilterGroups: [["filter[check_id]", "filter[check_id__in]"]], }), ); // When - the new Finding Group control asks for filter[check_id__in]. const values = result.current.getFilterValue("filter[check_id__in]"); // Then - the existing exact value appears selected in the control. expect(values).toEqual(["teams_external_users_can_join"]); }); }); // ── 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"); }); }); });