From a0fb994ef0d3468bed4534413e1588fdc6595abc Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 6 Mar 2026 17:22:39 -0800 Subject: [PATCH] [Test] Add unit tests for 5 untested policy components Adds Vitest + RTL test files for policy_table, policy_templates, guardrail_selection_modal, impact_popover, and add_attachment_form. 53 tests total covering rendering, user interactions, API calls, and conditional UI behavior. Co-Authored-By: Claude Sonnet 4.6 --- .../policies/add_attachment_form.test.tsx | 110 +++++++++++ .../guardrail_selection_modal.test.tsx | 127 ++++++++++++ .../policies/impact_popover.test.tsx | 184 ++++++++++++++++++ .../components/policies/policy_table.test.tsx | 150 ++++++++++++++ .../policies/policy_templates.test.tsx | 144 ++++++++++++++ 5 files changed, 715 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/impact_popover.test.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/policy_table.test.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/policy_templates.test.tsx diff --git a/ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx b/ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx new file mode 100644 index 00000000000..8982f72da35 --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/add_attachment_form.test.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "../networking"; +import AddAttachmentForm from "./add_attachment_form"; +import { Policy } from "./types"; + +vi.mock("../networking"); + +vi.mock("./impact_preview_alert", () => ({ + default: ({ impactResult }: { impactResult: any }) => + React.createElement("div", { "data-testid": "impact-preview" }, `${impactResult.affected_keys_count} keys`), +})); + +const makePolicy = (overrides: Partial = {}): Policy => ({ + policy_id: "policy-id-1", + policy_name: "test-policy", + inherit: null, + description: null, + guardrails_add: [], + guardrails_remove: [], + condition: null, + ...overrides, +}); + +const defaultProps = { + visible: true, + onClose: vi.fn(), + onSuccess: vi.fn(), + accessToken: "test-token", + policies: [makePolicy({ policy_name: "policy-alpha" }), makePolicy({ policy_name: "policy-beta", policy_id: "id-2" })], + createAttachment: vi.fn(), +}; + +describe("AddAttachmentForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(networking.teamListCall).mockResolvedValue([]); + vi.mocked(networking.keyListCall).mockResolvedValue({ keys: [] }); + vi.mocked(networking.modelAvailableCall).mockResolvedValue({ data: [] }); + }); + + it("should render the modal title when visible", async () => { + renderWithProviders(); + expect(await screen.findByText("Create Policy Attachment")).toBeInTheDocument(); + }); + + it("should not render modal content when visible is false", () => { + renderWithProviders(); + expect(screen.queryByText("Create Policy Attachment")).not.toBeInTheDocument(); + }); + + it("should fetch teams, keys, and models on mount when visible and accessToken are provided", async () => { + renderWithProviders(); + await waitFor(() => { + expect(networking.teamListCall).toHaveBeenCalled(); + expect(networking.keyListCall).toHaveBeenCalled(); + expect(networking.modelAvailableCall).toHaveBeenCalled(); + }); + }); + + it("should not fetch teams, keys, or models when accessToken is null", () => { + renderWithProviders(); + expect(networking.teamListCall).not.toHaveBeenCalled(); + expect(networking.keyListCall).not.toHaveBeenCalled(); + expect(networking.modelAvailableCall).not.toHaveBeenCalled(); + }); + + it("should call onClose when the Cancel button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: /cancel/i })); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it("should not show scope-specific fields when scope is global (default)", async () => { + renderWithProviders(); + await screen.findByText("Create Policy Attachment"); + expect(screen.queryByText("Teams")).not.toBeInTheDocument(); + expect(screen.queryByText("Keys")).not.toBeInTheDocument(); + expect(screen.queryByText("Models")).not.toBeInTheDocument(); + }); + + it("should show Teams, Keys, Models, and Tags fields when scope is switched to specific", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Create Policy Attachment"); + await user.click(screen.getByRole("radio", { name: /specific/i })); + expect(screen.getByText("Teams")).toBeInTheDocument(); + expect(screen.getByText("Keys")).toBeInTheDocument(); + expect(screen.getByText("Models")).toBeInTheDocument(); + expect(screen.getByText("Tags")).toBeInTheDocument(); + }); + + it("should show the 'Estimate Impact' button only when scope is specific", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Create Policy Attachment"); + expect(screen.queryByRole("button", { name: /estimate impact/i })).not.toBeInTheDocument(); + await user.click(screen.getByRole("radio", { name: /specific/i })); + expect(screen.getByRole("button", { name: /estimate impact/i })).toBeInTheDocument(); + }); + + it("should render a 'Create Attachment' submit button", async () => { + renderWithProviders(); + expect(await screen.findByRole("button", { name: /create attachment/i })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.test.tsx b/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.test.tsx new file mode 100644 index 00000000000..e730de71e6b --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.test.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import GuardrailSelectionModal from "./guardrail_selection_modal"; + +const makeGuardrailDef = (name: string, description = "A guardrail description") => ({ + guardrail_name: name, + guardrail_info: { description }, + litellm_params: { guardrail: "presidio", mode: "pre_call" }, +}); + +const makeTemplate = (guardrailDefs: any[] = [], overrides: any = {}) => ({ + title: "Test Template", + guardrailDefinitions: guardrailDefs, + ...overrides, +}); + +const defaultProps = { + visible: true, + template: makeTemplate([makeGuardrailDef("guardrail-new-1"), makeGuardrailDef("guardrail-new-2")]), + existingGuardrails: new Set(), + onConfirm: vi.fn(), + onCancel: vi.fn(), +}; + +describe("GuardrailSelectionModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render guardrail names from the template", async () => { + renderWithProviders(); + expect(await screen.findByText("guardrail-new-1")).toBeInTheDocument(); + expect(screen.getByText("guardrail-new-2")).toBeInTheDocument(); + }); + + it("should pre-select only new guardrails when the modal opens", async () => { + renderWithProviders(); + await screen.findByText("guardrail-new-1"); + const checkboxes = screen.getAllByRole("checkbox"); + checkboxes.forEach((cb) => expect(cb).toBeChecked()); + }); + + it("should not show a checkbox for guardrails that already exist", async () => { + const props = { + ...defaultProps, + template: makeTemplate([makeGuardrailDef("existing-g"), makeGuardrailDef("new-g")]), + existingGuardrails: new Set(["existing-g"]), + }; + renderWithProviders(); + await screen.findByText("existing-g"); + expect(screen.getAllByRole("checkbox")).toHaveLength(1); + }); + + it("should show an 'Already exists' tag for guardrails that exist in the system", async () => { + const props = { + ...defaultProps, + template: makeTemplate([makeGuardrailDef("existing-g")]), + existingGuardrails: new Set(["existing-g"]), + }; + renderWithProviders(); + expect(await screen.findByText("Already exists")).toBeInTheDocument(); + }); + + it("should show 'Create N Guardrails & Use Template' on the confirm button when N guardrails are selected", async () => { + renderWithProviders(); + expect(await screen.findByRole("button", { name: /create 2 guardrails & use template/i })).toBeInTheDocument(); + }); + + it("should show 'Use Template' on the confirm button when no new guardrails are selected", async () => { + const props = { + ...defaultProps, + template: makeTemplate([makeGuardrailDef("existing-g")]), + existingGuardrails: new Set(["existing-g"]), + }; + renderWithProviders(); + expect(await screen.findByRole("button", { name: /^use template$/i })).toBeInTheDocument(); + }); + + it("should call onConfirm with the definitions of selected guardrails when confirmed", async () => { + const user = userEvent.setup(); + const def = makeGuardrailDef("my-guardrail"); + const props = { ...defaultProps, template: makeTemplate([def]) }; + renderWithProviders(); + await user.click(await screen.findByRole("button", { name: /create 1 guardrail/i })); + expect(defaultProps.onConfirm).toHaveBeenCalledWith([def]); + }); + + it("should deselect all guardrails when 'Deselect All' is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("guardrail-new-1"); + await user.click(screen.getByRole("button", { name: /deselect all/i })); + screen.getAllByRole("checkbox").forEach((cb) => expect(cb).not.toBeChecked()); + }); + + it("should re-select all new guardrails when 'Select All New' is clicked after deselecting", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("guardrail-new-1"); + await user.click(screen.getByRole("button", { name: /deselect all/i })); + await user.click(screen.getByRole("button", { name: /select all new/i })); + screen.getAllByRole("checkbox").forEach((cb) => expect(cb).toBeChecked()); + }); + + it("should show 'No guardrails defined' when the template has no guardrail definitions", async () => { + const props = { ...defaultProps, template: makeTemplate([]) }; + renderWithProviders(); + expect(await screen.findByText(/no guardrails defined for this template/i)).toBeInTheDocument(); + }); + + it("should show a progress badge when progressInfo is provided", async () => { + const props = { ...defaultProps, progressInfo: { current: 2, total: 5 } }; + renderWithProviders(); + expect(await screen.findByText(/template 2 of 5/i)).toBeInTheDocument(); + }); + + it("should call onCancel when the Cancel button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("guardrail-new-1"); + await user.click(screen.getByRole("button", { name: /^cancel$/i })); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/policies/impact_popover.test.tsx b/ui/litellm-dashboard/src/components/policies/impact_popover.test.tsx new file mode 100644 index 00000000000..b8758927794 --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/impact_popover.test.tsx @@ -0,0 +1,184 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "../networking"; +import ImpactPopover from "./impact_popover"; +import { PolicyAttachment } from "./types"; + +vi.mock("../networking"); + +vi.mock("@heroicons/react/outline", () => ({ + EyeIcon: function EyeIcon() { return null; }, +})); + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Icon: ({ icon: IconComp, onClick, className }: any) => + React.createElement("button", { type: "button", onClick, className }, IconComp?.displayName ?? IconComp?.name ?? "icon"), + }; +}); + +// Expose the Popover's onOpenChange so tests can trigger it programmatically. +vi.mock("antd", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Popover: ({ children, onOpenChange, content }: any) => + React.createElement( + "div", + null, + React.createElement("div", { "data-testid": "popover-content" }, content), + React.createElement( + "div", + { + role: "button", + "aria-label": "open-popover", + onClick: () => onOpenChange?.(true), + }, + children + ) + ), + Tooltip: ({ children }: any) => React.createElement(React.Fragment, null, children), + Spin: () => React.createElement("span", null, "Loading..."), + Tag: ({ children }: any) => React.createElement("span", null, children), + }; +}); + +const makeAttachment = (overrides: Partial = {}): PolicyAttachment => ({ + attachment_id: "att-001", + policy_name: "my-policy", + scope: null, + teams: [], + keys: [], + models: [], + tags: [], + ...overrides, +}); + +describe("ImpactPopover", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /open-popover/i })).toBeInTheDocument(); + }); + + it("should show 'Click to load' as the initial popover content", () => { + renderWithProviders(); + expect(screen.getByText(/click to load/i)).toBeInTheDocument(); + }); + + it("should call estimateAttachmentImpactCall when the popover is opened", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 0, + affected_teams_count: 0, + sample_keys: [], + sample_teams: [], + }); + const attachment = makeAttachment({ policy_name: "rate-limit", teams: ["team-a"] }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + await waitFor(() => { + expect(networking.estimateAttachmentImpactCall).toHaveBeenCalledWith("my-token", { + policy_name: "rate-limit", + scope: null, + teams: ["team-a"], + keys: [], + models: [], + tags: [], + }); + }); + }); + + it("should not call the API when accessToken is null", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(networking.estimateAttachmentImpactCall).not.toHaveBeenCalled(); + }); + + it("should show a loading indicator while the impact is being fetched", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockReturnValue(new Promise(() => {})); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + // Multiple "Loading..." nodes exist (Spin + adjacent text) — assert at least one is present + expect(screen.queryAllByText(/loading/i).length).toBeGreaterThan(0); + }); + + it("should show a global scope warning when affected_keys_count is -1", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: -1, + affected_teams_count: -1, + sample_keys: [], + sample_teams: [], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(await screen.findByText(/global scope.*affects all keys and teams/i)).toBeInTheDocument(); + }); + + it("should show key and team counts when impact data is loaded for a specific scope", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 5, + affected_teams_count: 2, + sample_keys: ["sk-abc"], + sample_teams: ["team-x"], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(await screen.findByText(/5/)).toBeInTheDocument(); + expect(screen.getByText(/2/)).toBeInTheDocument(); + }); + + it("should render sample key tags when returned from the API", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 2, + affected_teams_count: 0, + sample_keys: ["sk-key-one", "sk-key-two"], + sample_teams: [], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(await screen.findByText("sk-key-one")).toBeInTheDocument(); + expect(screen.getByText("sk-key-two")).toBeInTheDocument(); + }); + + it("should show 'No keys or teams currently affected' when both counts are 0", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 0, + affected_teams_count: 0, + sample_keys: [], + sample_teams: [], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(await screen.findByText(/no keys or teams currently affected/i)).toBeInTheDocument(); + }); + + it("should not call the API a second time when the popover is already loaded", async () => { + const user = userEvent.setup(); + vi.mocked(networking.estimateAttachmentImpactCall).mockResolvedValue({ + affected_keys_count: 1, + affected_teams_count: 0, + sample_keys: ["sk-abc"], + sample_teams: [], + }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + await screen.findByText("sk-abc"); + await user.click(screen.getByRole("button", { name: /open-popover/i })); + expect(networking.estimateAttachmentImpactCall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/policies/policy_table.test.tsx b/ui/litellm-dashboard/src/components/policies/policy_table.test.tsx new file mode 100644 index 00000000000..f671fae6b9b --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/policy_table.test.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import PolicyTable from "./policy_table"; +import { Policy } from "./types"; + +vi.mock("@heroicons/react/outline", () => ({ + TrashIcon: function TrashIcon() { return null; }, + PencilIcon: function PencilIcon() { return null; }, + SwitchVerticalIcon: function SwitchVerticalIcon() { return null; }, + ChevronUpIcon: function ChevronUpIcon() { return null; }, + ChevronDownIcon: function ChevronDownIcon() { return null; }, +})); + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Button: React.forwardRef(({ children, ...props }, ref) => + React.createElement("button", { ...props, ref }, children) + ), + Icon: ({ icon: IconComp, onClick, className }: any) => + React.createElement("button", { type: "button", onClick, className }, IconComp?.displayName ?? IconComp?.name ?? "icon"), + Tooltip: ({ children }: { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + Badge: ({ children }: { children?: React.ReactNode }) => + React.createElement("span", null, children), + }; +}); + +const makePolicy = (overrides: Partial = {}): Policy => ({ + policy_id: "policy-id-1", + policy_name: "test-policy", + inherit: null, + description: null, + guardrails_add: [], + guardrails_remove: [], + condition: null, + ...overrides, +}); + +const defaultProps = { + policies: [], + isLoading: false, + onDeleteClick: vi.fn(), + onEditClick: vi.fn(), + onViewClick: vi.fn(), + isAdmin: true, +}; + +describe("PolicyTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render column headers", () => { + renderWithProviders(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + it("should show a loading message when isLoading is true", () => { + renderWithProviders(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("should show 'No policies found' when there are no policies", () => { + renderWithProviders(); + expect(screen.getByText(/no policies found/i)).toBeInTheDocument(); + }); + + it("should render a button with the policy name for each grouped policy", () => { + const policies = [ + makePolicy({ policy_name: "alpha-policy", policy_id: "id-1" }), + makePolicy({ policy_name: "beta-policy", policy_id: "id-2" }), + ]; + renderWithProviders(); + expect(screen.getByRole("button", { name: "alpha-policy" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "beta-policy" })).toBeInTheDocument(); + }); + + it("should call onViewClick with the policy_id when the policy name button is clicked", async () => { + const user = userEvent.setup(); + const policy = makePolicy({ policy_name: "my-policy", policy_id: "view-id-1" }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "my-policy" })); + expect(defaultProps.onViewClick).toHaveBeenCalledWith("view-id-1"); + }); + + it("should call onDeleteClick with policy_id and policy_name when the delete icon is clicked", async () => { + const user = userEvent.setup(); + const policy = makePolicy({ policy_name: "del-policy", policy_id: "del-id-1" }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /TrashIcon/i })); + expect(defaultProps.onDeleteClick).toHaveBeenCalledWith("del-id-1", "del-policy"); + }); + + it("should call onEditClick with the policy when the edit icon is clicked", async () => { + const user = userEvent.setup(); + const policy = makePolicy({ policy_name: "edit-policy", policy_id: "edit-id-1" }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /PencilIcon/i })); + expect(defaultProps.onEditClick).toHaveBeenCalledWith(policy); + }); + + it("should not show admin action icons for non-admins", () => { + const policy = makePolicy(); + renderWithProviders(); + expect(screen.queryByRole("button", { name: /TrashIcon/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /PencilIcon/i })).not.toBeInTheDocument(); + }); + + it("should show a version badge when multiple versions of the same policy name exist", () => { + const policies = [ + makePolicy({ policy_name: "versioned", policy_id: "v1", version_status: "published", version_number: 1 }), + makePolicy({ policy_name: "versioned", policy_id: "v2", version_status: "production", version_number: 2 }), + ]; + renderWithProviders(); + expect(screen.getByText(/2 version/i)).toBeInTheDocument(); + }); + + it("should group policies with the same name into a single row", () => { + const policies = [ + makePolicy({ policy_name: "shared", policy_id: "s1", version_status: "published" }), + makePolicy({ policy_name: "shared", policy_id: "s2", version_status: "production" }), + ]; + renderWithProviders(); + expect(screen.getAllByRole("button", { name: "shared" })).toHaveLength(1); + }); + + it("should show an overflow tag when more than 2 guardrails_add exist", () => { + const policy = makePolicy({ guardrails_add: ["g1", "g2", "g3", "g4"] }); + renderWithProviders(); + expect(screen.getByText("+2")).toBeInTheDocument(); + }); + + it("should prefer the production version as the primary policy when grouping", async () => { + const user = userEvent.setup(); + const policies = [ + makePolicy({ policy_name: "grouped", policy_id: "published-id", version_status: "published" }), + makePolicy({ policy_name: "grouped", policy_id: "prod-id", version_status: "production" }), + ]; + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "grouped" })); + expect(defaultProps.onViewClick).toHaveBeenCalledWith("prod-id"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/policies/policy_templates.test.tsx b/ui/litellm-dashboard/src/components/policies/policy_templates.test.tsx new file mode 100644 index 00000000000..b53cab42208 --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/policy_templates.test.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "../networking"; +import PolicyTemplates from "./policy_templates"; + +vi.mock("../networking"); + +vi.mock("@heroicons/react/outline", () => ({ + ShieldCheckIcon: function ShieldCheckIcon() { return null; }, + ShieldExclamationIcon: function ShieldExclamationIcon() { return null; }, + BeakerIcon: function BeakerIcon() { return null; }, + CurrencyDollarIcon: function CurrencyDollarIcon() { return null; }, + CheckCircleIcon: function CheckCircleIcon() { return null; }, +})); + +const makeTemplate = (overrides: any = {}) => ({ + id: "tpl-1", + title: "Test Template", + description: "A test template", + icon: "ShieldCheckIcon", + iconColor: "text-green-500", + iconBg: "bg-green-50", + guardrails: ["guardrail-a"], + tags: [], + complexity: "Low" as const, + ...overrides, +}); + +const defaultProps = { + onUseTemplate: vi.fn(), + onOpenAiSuggestion: vi.fn(), + accessToken: "test-token", +}; + +describe("PolicyTemplates", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the section header after loading", async () => { + vi.mocked(networking.getPolicyTemplates).mockResolvedValue([]); + renderWithProviders(); + expect(await screen.findByText("Policy Templates")).toBeInTheDocument(); + }); + + it("should not show the template grid while fetching", () => { + vi.mocked(networking.getPolicyTemplates).mockReturnValue(new Promise(() => {})); + renderWithProviders(); + expect(screen.queryByText("Policy Templates")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /use ai to find templates/i })).not.toBeInTheDocument(); + }); + + it("should render a card for each fetched template", async () => { + const templates = [ + makeTemplate({ title: "Template Alpha" }), + makeTemplate({ id: "tpl-2", title: "Template Beta" }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + renderWithProviders(); + expect(await screen.findByText("Template Alpha")).toBeInTheDocument(); + expect(screen.getByText("Template Beta")).toBeInTheDocument(); + }); + + it("should call onTemplatesLoaded with the fetched templates after loading", async () => { + const templates = [makeTemplate()]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + const onTemplatesLoaded = vi.fn(); + renderWithProviders(); + await waitFor(() => { + expect(onTemplatesLoaded).toHaveBeenCalledWith(templates); + }); + }); + + it("should call onOpenAiSuggestion when the AI suggestion button is clicked", async () => { + vi.mocked(networking.getPolicyTemplates).mockResolvedValue([]); + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Policy Templates"); + await user.click(screen.getByRole("button", { name: /use ai to find templates/i })); + expect(defaultProps.onOpenAiSuggestion).toHaveBeenCalled(); + }); + + it("should render tag filter checkboxes for unique tags across all templates", async () => { + const templates = [ + makeTemplate({ tags: ["compliance"] }), + makeTemplate({ id: "tpl-2", tags: ["compliance", "security"] }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + renderWithProviders(); + expect(await screen.findByRole("checkbox", { name: /compliance/i })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: /security/i })).toBeInTheDocument(); + }); + + it("should filter to only matching templates when a tag is selected", async () => { + const templates = [ + makeTemplate({ id: "tpl-1", title: "Compliance Template", tags: ["compliance"] }), + makeTemplate({ id: "tpl-2", title: "Security Template", tags: ["security"] }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Compliance Template"); + await user.click(screen.getByRole("checkbox", { name: /compliance/i })); + expect(screen.getByText("Compliance Template")).toBeInTheDocument(); + expect(screen.queryByText("Security Template")).not.toBeInTheDocument(); + }); + + it("should show 'No templates match' when selected tags exclude all templates", async () => { + const templates = [ + makeTemplate({ id: "tpl-1", title: "Alpha Template", tags: ["alpha"] }), + makeTemplate({ id: "tpl-2", title: "Beta Template", tags: ["beta"] }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Alpha Template"); + await user.click(screen.getByRole("checkbox", { name: /alpha/i })); + await user.click(screen.getByRole("checkbox", { name: /beta/i })); + expect(screen.getByText(/no templates match the selected filters/i)).toBeInTheDocument(); + }); + + it("should restore all templates when 'Clear all' is clicked", async () => { + const templates = [ + makeTemplate({ id: "tpl-1", title: "Alpha Template", tags: ["alpha"] }), + makeTemplate({ id: "tpl-2", title: "Beta Template", tags: ["beta"] }), + ]; + vi.mocked(networking.getPolicyTemplates).mockResolvedValue(templates); + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Alpha Template"); + await user.click(screen.getByRole("checkbox", { name: /alpha/i })); + expect(screen.queryByText("Beta Template")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /clear all/i })); + expect(screen.getByText("Beta Template")).toBeInTheDocument(); + }); + + it("should not fetch templates when accessToken is null", () => { + renderWithProviders(); + expect(networking.getPolicyTemplates).not.toHaveBeenCalled(); + }); +});