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()
);
});
});