import * as React from "react"; import { render, screen, waitFor, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as fetchMock from "fetch-mock-jest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import PatronBlockingRulesEditor, { PatronBlockingRulesEditorHandle, } from "../../../src/components/PatronBlockingRulesEditor"; import { PatronBlockingRule } from "../../../src/interfaces"; import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; /** Renders with a fresh QueryClient so useQuery hooks work in tests. */ function renderEditor(element: React.ReactElement) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return render( {element} ); } const VALIDATE_URL = "/admin/patron_auth_service_validate_patron_blocking_rule"; /** Sample available_fields dict returned by the server on successful validation. */ const SAMPLE_FIELDS = { fines: "2.50", patron_identifier: "12345", patron_name: "John Doe", }; const SUCCESS_RESPONSE = { status: 200, body: { available_fields: SAMPLE_FIELDS }, }; const existingRules: PatronBlockingRule[] = [ { name: "Rule A", rule: "expr_a", message: "msg a" }, { name: "Rule B", rule: "expr_b" }, ]; /** * Both describe blocks share a beforeEach/afterEach that provides a default * successful validation response. This ensures that any incidental blur events * fired by user interactions in non-blur-focused tests don't throw * "only absolute URLs are supported" errors from the fetch polyfill. * * Blur-specific tests that need a non-200 response call fetchMock.mockReset() * at the start and then set up their own route. * * Note: in userEvent.type, curly braces are special key sequences. Use {{ * and }} to type literal { and } characters. */ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)", () => { beforeEach(() => { fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); }); afterEach(() => { fetchMock.mockReset(); }); it("calls onValidationStateChange(true) when a rule is added because it is incomplete", async () => { const user = userEvent.setup(); const onChange = jest.fn(); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await waitFor(() => expect(onChange).toHaveBeenCalledWith(true)); }); it("calls onValidationStateChange(false) after successful validation on blur", async () => { const user = userEvent.setup(); const onChange = jest.fn(); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0"); await user.tab(); await waitFor(() => expect(onChange).toHaveBeenCalledWith(false)); }); it("calls onValidationStateChange(false) after failed validation on blur (validation does not block save)", async () => { const user = userEvent.setup(); const onChange = jest.fn(); fetchMock.mockReset(); fetchMock.post(VALIDATE_URL, { status: 400, body: { detail: "Bad expression" }, }); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type(screen.getByLabelText(/Rule Expression/i), "bad_syntax"); await user.tab(); // Wait for the warning to appear; save is not blocked by failed validation. await screen.findByText(/Bad expression/i); expect(onChange).toHaveBeenLastCalledWith(false); }); it("calls onValidationStateChange(true) when two rules have the same name", async () => { const user = userEvent.setup(); const onChange = jest.fn(); const rules: PatronBlockingRule[] = [ { name: "Rule A", rule: "expr_a" }, { name: "Rule B", rule: "expr_b" }, ]; renderEditor( ); // Initially not blocking (no duplicates, no pending) await waitFor(() => expect(onChange).toHaveBeenCalledWith(false)); // Rename rule B to match rule A const nameInputs = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; await user.clear(nameInputs[1]); await user.type(nameInputs[1], "Rule A"); await waitFor(() => expect(onChange).toHaveBeenCalledWith(true)); }); it("calls onValidationStateChange(false) after a duplicate name is resolved", async () => { const user = userEvent.setup(); const onChange = jest.fn(); const rules: PatronBlockingRule[] = [ { name: "Rule A", rule: "expr_a" }, { name: "Rule A", rule: "expr_b" }, // duplicate ]; renderEditor( ); // Initially blocking due to duplicate name await waitFor(() => expect(onChange).toHaveBeenCalledWith(true)); // Fix the duplicate const nameInputs = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; await user.clear(nameInputs[1]); await user.type(nameInputs[1], "Rule B"); await waitFor(() => expect(onChange).toHaveBeenCalledWith(false)); }); it("calls onValidationStateChange(true) when a rule is added without a serviceId because it is incomplete", async () => { const user = userEvent.setup(); const onChange = jest.fn(); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); // Incomplete rule (empty name + expression) still blocks save even without serviceId await waitFor(() => expect(onChange).toHaveBeenCalledWith(true)); }); it("removes blocking state when the pending rule is deleted", async () => { const user = userEvent.setup(); const onChange = jest.fn(); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await waitFor(() => expect(onChange).toHaveBeenCalledWith(true)); // Delete the only rule — blocking should clear await user.click(screen.getByRole("button", { name: /Delete/i })); await waitFor(() => expect(onChange).toHaveBeenCalledWith(false)); }); it("does not block save when a rule with no serviceId has all required fields filled", async () => { const user = userEvent.setup(); const onChange = jest.fn(); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0"); // Both fields filled, no serviceId → not blocking await waitFor(() => expect(onChange).toHaveBeenCalledWith(false)); }); it("keeps save blocked until every rule has required fields (validation does not block)", async () => { const user = userEvent.setup(); const onChange = jest.fn(); renderEditor( ); // Add and fill the first rule await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "Rule One"); await user.type(screen.getByLabelText(/Rule Expression/i), "expr_one"); await waitFor(() => expect(onChange).toHaveBeenCalledWith(false)); // Add a second rule — save should block (incomplete) await user.click(screen.getByRole("button", { name: /Add Rule/i })); await waitFor(() => expect(onChange).toHaveBeenCalledWith(true)); // Fill in the second rule — no validation needed to unblock const nameInputs = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; const ruleInputs = screen.getAllByLabelText( /Rule Expression/i ) as HTMLTextAreaElement[]; await user.type(nameInputs[1], "Rule Two"); await user.type(ruleInputs[1], "expr_two"); await waitFor(() => expect(onChange).toHaveBeenCalledWith(false)); }); it("does not block save when an existing rule's expression is edited (validation is advisory)", async () => { const user = userEvent.setup(); const onChange = jest.fn(); const rules: PatronBlockingRule[] = [{ name: "Rule A", rule: "expr_a" }]; renderEditor( ); await waitFor(() => expect(onChange).toHaveBeenCalledWith(false)); // Edit the expression — save stays unblocked (no incomplete, no duplicate) const ruleTextarea = screen.getByLabelText( /Rule Expression/i ) as HTMLTextAreaElement; await user.clear(ruleTextarea); await user.type(ruleTextarea, "new_expr"); expect(onChange).toHaveBeenLastCalledWith(false); }); }); describe("PatronBlockingRulesEditor — on-blur server validation", () => { beforeEach(() => { fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); }); afterEach(() => { fetchMock.mockReset(); }); it("calls the validation API when the user leaves the Rule Expression field", async () => { const user = userEvent.setup(); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type( screen.getByLabelText(/Rule Expression/i), "{{fines}} > 10.0" ); await user.tab(); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( VALIDATE_URL, expect.objectContaining({ method: "POST" }) ) ); }); it("shows a server error message inline after a failed validation", async () => { const user = userEvent.setup(); fetchMock.mockReset(); fetchMock.post(VALIDATE_URL, { status: 400, body: { detail: "Unknown placeholder: {x}" }, }); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "Bad Rule"); await user.type(screen.getByLabelText(/Rule Expression/i), "{{x}} > 0"); await user.tab(); expect(await screen.findByText(/Unknown placeholder: \{x\}/i)).toBeTruthy(); }); it("clears the server error immediately when the user edits the rule field", async () => { const user = userEvent.setup(); fetchMock.mockReset(); fetchMock.post(VALIDATE_URL, { status: 400, body: { detail: "Bad expression" }, }); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type(screen.getByLabelText(/Rule Expression/i), "bad"); await user.tab(); await screen.findByText(/Bad expression/i); // Typing in the rule field clears the server error immediately (no re-fetch needed) await user.click(screen.getByLabelText(/Rule Expression/i)); await user.type(screen.getByLabelText(/Rule Expression/i), "x"); expect(screen.queryByText(/Bad expression/i)).toBeNull(); }); it("does not call the validation API when the rule field is empty on blur", async () => { const user = userEvent.setup(); fetchMock.mockReset(); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); // Click the Rule Expression textarea then tab away without typing await user.click(screen.getByLabelText(/Rule Expression/i)); await user.tab(); expect(fetchMock).not.toHaveBeenCalled(); }); it("still calls the validation API when serviceId is undefined and shows the server error", async () => { const user = userEvent.setup(); fetchMock.mockReset(); fetchMock.post(VALIDATE_URL, { status: 400, body: { detail: "Patron auth service not found. Save the service before validating rules.", }, }); // No serviceId — simulates a new service that has not yet been saved renderEditor(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0"); await user.tab(); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( VALIDATE_URL, expect.objectContaining({ method: "POST" }) ) ); expect( await screen.findByText(/Save the service before validating rules/i) ).toBeTruthy(); }); it("preserves the server error on the second rule after the first rule is deleted", async () => { const user = userEvent.setup(); fetchMock.mockReset(); fetchMock.post(VALIDATE_URL, { status: 400, body: { detail: "Bad expression syntax" }, }); renderEditor( ); // Add a second rule and give it an invalid expression await user.click(screen.getByRole("button", { name: /Add Rule/i })); const nameInputs = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; const ruleTextareas = screen.getAllByLabelText( /Rule Expression/i ) as HTMLTextAreaElement[]; await user.type(nameInputs[1], "Rule B"); await user.type(ruleTextareas[1], "bad_syntax"); await user.tab(); // Wait for the server error to appear on the second rule await screen.findByText(/Bad expression syntax/i); // Delete the first (valid) rule const deleteButtons = screen.getAllByRole("button", { name: /Delete/i }); await user.click(deleteButtons[0]); // Only the formerly-second rule should remain const remainingNameInputs = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; expect(remainingNameInputs).toHaveLength(1); expect(remainingNameInputs[0].value).toBe("Rule B"); // The server error for that rule must still be visible expect(screen.getByText(/Bad expression syntax/i)).toBeTruthy(); }); it("shows no error after a successful re-validation that follows a failure", async () => { const user = userEvent.setup(); fetchMock.mockReset(); fetchMock.post(VALIDATE_URL, { status: 400, body: { detail: "Syntax error in rule" }, }); renderEditor( ); await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type(screen.getByLabelText(/Rule Expression/i), "bad_syntax"); await user.tab(); await screen.findByText(/Syntax error in rule/i); // Switch mock to success, correct the rule, and blur again fetchMock.mockReset(); fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); await user.clear(screen.getByLabelText(/Rule Expression/i)); await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0"); await user.tab(); await waitFor(() => expect(screen.queryByText(/Syntax error in rule/i)).toBeNull() ); }); }); describe("PatronBlockingRulesEditor", () => { // Provide a default successful validation response so that tests which // incidentally trigger blur on the Rule Expression field (e.g. by typing in // the Message textarea) don't produce "only absolute URLs" fetch errors. beforeEach(() => { fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); }); afterEach(() => { fetchMock.mockReset(); }); it("renders with no rules when no value provided", () => { renderEditor(); expect(screen.getByText(/No patron blocking rules defined/i)).toBeTruthy(); expect(screen.getByRole("button", { name: /Add Rule/i })).toBeTruthy(); }); it("renders existing rules from value prop", () => { renderEditor(); expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(2); expect(screen.getAllByLabelText(/Rule Expression/i)).toHaveLength(2); const nameInputs = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; expect(nameInputs[0].value).toBe("Rule A"); expect(nameInputs[1].value).toBe("Rule B"); const ruleTextareas = screen.getAllByLabelText( /Rule Expression/i ) as HTMLTextAreaElement[]; expect(ruleTextareas[0].value).toBe("expr_a"); expect(ruleTextareas[1].value).toBe("expr_b"); }); it("adds a new blank rule row when Add Rule is clicked", async () => { const user = userEvent.setup(); renderEditor(); expect(screen.queryByLabelText(/Rule Name/i)).toBeNull(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(1); expect(screen.getAllByLabelText(/Rule Expression/i)).toHaveLength(1); expect(screen.getAllByLabelText(/Message/i)).toHaveLength(1); }); it("removes a rule row when Delete is clicked", async () => { const user = userEvent.setup(); renderEditor(); expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(2); const deleteButtons = screen.getAllByRole("button", { name: /Delete/i }); await user.click(deleteButtons[0]); expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(1); const remaining = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; expect(remaining[0].value).toBe("Rule B"); }); it("getValue returns current rules including edits", async () => { const user = userEvent.setup(); const ref = React.createRef(); renderEditor(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); const nameInput = screen.getByLabelText(/Rule Name/i); const ruleTextarea = screen.getByLabelText(/Rule Expression/i); const messageInput = screen.getByLabelText(/Message/i); await user.clear(nameInput); await user.type(nameInput, "My Rule"); await user.clear(ruleTextarea); await user.type(ruleTextarea, "blocked = true"); await user.clear(messageInput); await user.type(messageInput, "You are blocked"); const value = ref.current.getValue(); expect(value).toHaveLength(1); expect(value[0].name).toBe("My Rule"); expect(value[0].rule).toBe("blocked = true"); expect(value[0].message).toBe("You are blocked"); }); it("getValue returns an empty array when no rules exist", () => { const ref = React.createRef(); renderEditor(); expect(ref.current.getValue()).toEqual([]); }); it("disables all editing inputs and buttons when disabled prop is true", () => { renderEditor(); // The Help button stays enabled even in disabled mode (read-only affordance). const editingButtons = screen .getAllByRole("button") .filter( (btn) => !btn.classList.contains("patron-blocking-rules-help-btn") ); editingButtons.forEach((btn) => expect(btn).toBeDisabled()); const inputs = screen.getAllByRole("textbox"); inputs.forEach((input) => expect(input).toBeDisabled()); }); it("does not show 'no rules' message when rules exist", () => { renderEditor(); expect(screen.queryByText(/No patron blocking rules defined/i)).toBeNull(); }); it("disables Add Rule button when an existing rule is missing required fields", async () => { const user = userEvent.setup(); renderEditor(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); expect(screen.getByRole("button", { name: /Add Rule/i })).toBeDisabled(); }); it("re-enables Add Rule button once all required fields are filled", async () => { const user = userEvent.setup(); renderEditor(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); expect(screen.getByRole("button", { name: /Add Rule/i })).toBeDisabled(); await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type( screen.getByLabelText(/Rule Expression/i), "blocked = true" ); expect( screen.getByRole("button", { name: /Add Rule/i }) ).not.toBeDisabled(); }); it("shows a duplicate-name error inline when two rules share the same name", async () => { const user = userEvent.setup(); const rules: PatronBlockingRule[] = [ { name: "Rule A", rule: "expr_a" }, { name: "Rule B", rule: "expr_b" }, ]; renderEditor(); // Rename rule B to match rule A const nameInputs = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; await user.clear(nameInputs[1]); await user.type(nameInputs[1], "Rule A"); await waitFor(() => expect(screen.getAllByText(/Rule Name must be unique/i)).toHaveLength(2) ); }); it("clears the duplicate-name error once the name is made unique", async () => { const user = userEvent.setup(); const rules: PatronBlockingRule[] = [ { name: "Rule A", rule: "expr_a" }, { name: "Rule A", rule: "expr_b" }, // duplicate ]; renderEditor(); // Both rows should start with the duplicate error expect(screen.getAllByText(/Rule Name must be unique/i)).toHaveLength(2); // Fix the second rule's name const nameInputs = screen.getAllByLabelText( /Rule Name/i ) as HTMLInputElement[]; await user.clear(nameInputs[1]); await user.type(nameInputs[1], "Rule B"); await waitFor(() => expect(screen.queryByText(/Rule Name must be unique/i)).toBeNull() ); }); it("shows server error message even when there are no rules", () => { const error: FetchErrorData = { status: 500, response: JSON.stringify({ detail: "Internal server error" }), url: "", }; renderEditor(); expect(screen.getByText(/Internal server error/i)).toBeTruthy(); }); it("getValue does not include internal _id field in returned rules", () => { const ref = React.createRef(); renderEditor(); const value = ref.current.getValue(); value.forEach((rule) => { expect(rule).not.toHaveProperty("_id"); }); }); it("hides the 'no rules' message once a rule is added", async () => { const user = userEvent.setup(); renderEditor(); expect(screen.getByText(/No patron blocking rules defined/i)).toBeTruthy(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); expect(screen.queryByText(/No patron blocking rules defined/i)).toBeNull(); }); }); describe("PatronBlockingRulesEditor — validateAndGetValue", () => { beforeEach(() => { fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); }); afterEach(() => { fetchMock.mockReset(); }); it("returns all rules (stripped of _id) when every rule has name and expression", () => { const ref = React.createRef(); renderEditor(); let result: PatronBlockingRule[] | null; act(() => { result = ref.current.validateAndGetValue(); }); expect(result).toHaveLength(2); expect(result[0]).toEqual({ name: "Rule A", rule: "expr_a", message: "msg a", }); expect(result[1]).toEqual({ name: "Rule B", rule: "expr_b" }); result.forEach((r) => expect(r).not.toHaveProperty("_id")); }); it("returns null and shows a name error when a rule is missing its name", async () => { const user = userEvent.setup(); const ref = React.createRef(); renderEditor(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); // Leave name empty, fill only the expression await user.type(screen.getByLabelText(/Rule Expression/i), "expr"); let result: PatronBlockingRule[] | null; act(() => { result = ref.current.validateAndGetValue(); }); expect(result).toBeNull(); expect(screen.getByText(/Rule Name is required/i)).toBeTruthy(); expect(screen.queryByText(/Rule Expression is required/i)).toBeNull(); }); it("returns null and shows an expression error when a rule is missing its expression", async () => { const user = userEvent.setup(); const ref = React.createRef(); renderEditor(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); // Fill only the name, leave expression empty await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); let result: PatronBlockingRule[] | null; act(() => { result = ref.current.validateAndGetValue(); }); expect(result).toBeNull(); expect(screen.queryByText(/Rule Name is required/i)).toBeNull(); expect(screen.getByText(/Rule Expression is required/i)).toBeTruthy(); }); it("returns null and shows both errors when a rule has neither name nor expression", async () => { const user = userEvent.setup(); const ref = React.createRef(); renderEditor(); // Add rule but leave both fields empty await user.click(screen.getByRole("button", { name: /Add Rule/i })); let result: PatronBlockingRule[] | null; act(() => { result = ref.current.validateAndGetValue(); }); expect(result).toBeNull(); expect(screen.getByText(/Rule Name is required/i)).toBeTruthy(); expect(screen.getByText(/Rule Expression is required/i)).toBeTruthy(); }); it("clears prior client errors on a subsequent call that succeeds", async () => { const user = userEvent.setup(); const ref = React.createRef(); renderEditor(); await user.click(screen.getByRole("button", { name: /Add Rule/i })); // First call: both fields empty → errors shown act(() => { ref.current.validateAndGetValue(); }); expect(screen.getByText(/Rule Name is required/i)).toBeTruthy(); expect(screen.getByText(/Rule Expression is required/i)).toBeTruthy(); // Fill in both fields await user.type(screen.getByLabelText(/Rule Name/i), "My Rule"); await user.type(screen.getByLabelText(/Rule Expression/i), "expr"); // Second call: valid → errors gone, rules returned let result: PatronBlockingRule[] | null; act(() => { result = ref.current.validateAndGetValue(); }); expect(result).toHaveLength(1); expect(result[0].name).toBe("My Rule"); expect(screen.queryByText(/Rule Name is required/i)).toBeNull(); expect(screen.queryByText(/Rule Expression is required/i)).toBeNull(); }); it("returns an empty array (not null) when there are no rules at all", () => { const ref = React.createRef(); renderEditor(); let result: PatronBlockingRule[] | null; act(() => { result = ref.current.validateAndGetValue(); }); expect(result).toEqual([]); }); }); describe("PatronBlockingRulesEditor — help modal and available fields prefetch", () => { afterEach(() => { fetchMock.mockReset(); }); it("renders a Help button in the header", () => { fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); renderEditor( ); expect( screen.getByRole("button", { name: /patron blocking rules help/i }) ).toBeTruthy(); }); it("prefetches available fields on mount when serviceId is provided", async () => { fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); renderEditor( ); // The prefetch call is fired on mount. await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( VALIDATE_URL, expect.objectContaining({ method: "POST" }) ) ); }); it("opens the help modal when the Help button is clicked", async () => { const user = userEvent.setup(); fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); renderEditor( ); await user.click( screen.getByRole("button", { name: /patron blocking rules help/i }) ); expect(screen.getByText(/Patron Blocking Rules — Help/i)).toBeTruthy(); }); it("shows available fields in the help modal after a successful prefetch", async () => { const user = userEvent.setup(); fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); renderEditor( ); // Wait for the prefetch to settle await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( VALIDATE_URL, expect.objectContaining({ method: "POST" }) ) ); await user.click( screen.getByRole("button", { name: /patron blocking rules help/i }) ); // Field names and sample values should appear in the modal table await waitFor(() => { expect(screen.getByText("fines")).toBeTruthy(); expect(screen.getByText("2.50")).toBeTruthy(); expect(screen.getByText("patron_identifier")).toBeTruthy(); expect(screen.getByText("12345")).toBeTruthy(); }); }); it("shows an unavailability message when serviceId is not provided", async () => { const user = userEvent.setup(); // No serviceId → prefetch skipped; no fetch call expected. renderEditor(); await user.click( screen.getByRole("button", { name: /patron blocking rules help/i }) ); expect( screen.getByText( /Save the service before template variables can be fetched/i ) ).toBeTruthy(); }); it("shows an error message when the prefetch fails with a 400", async () => { const user = userEvent.setup(); fetchMock.post(VALIDATE_URL, { status: 400, body: { detail: "Patron auth service not found." }, }); renderEditor( ); await user.click( screen.getByRole("button", { name: /patron blocking rules help/i }) ); await waitFor(() => expect(screen.getByText(/Patron auth service not found/i)).toBeTruthy() ); }); it("updates available fields after a successful blur validation", async () => { const user = userEvent.setup(); const updatedFields = { fines: "5.00", new_field: "hello" }; fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); renderEditor( ); // Add a rule and blur the expression field with the updated-fields mock await user.click(screen.getByRole("button", { name: /Add Rule/i })); await user.type(screen.getByLabelText(/Rule Name/i), "Test Rule"); fetchMock.mockReset(); fetchMock.post(VALIDATE_URL, { status: 200, body: { available_fields: updatedFields }, }); await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0"); await user.tab(); // Open help modal and verify updated fields are shown await user.click( screen.getByRole("button", { name: /patron blocking rules help/i }) ); await waitFor(() => { expect(screen.getByText("new_field")).toBeTruthy(); expect(screen.getByText("hello")).toBeTruthy(); }); }); it("closes the help modal when the close button is clicked", async () => { const user = userEvent.setup(); fetchMock.post(VALIDATE_URL, SUCCESS_RESPONSE); renderEditor( ); await user.click( screen.getByRole("button", { name: /patron blocking rules help/i }) ); expect(screen.getByText(/Patron Blocking Rules — Help/i)).toBeTruthy(); // Close via the modal's × button (first close-named button; footer Close is last). await user.click(screen.getAllByRole("button", { name: /close/i })[0]); await waitFor(() => expect(screen.queryByText(/Patron Blocking Rules — Help/i)).toBeNull() ); }); });