From cf356c403e566ca3443e2c54687491d28ef6fcc9 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Mon, 13 Jul 2026 10:00:45 -0300 Subject: [PATCH 1/5] Rewrite admin, announcement and shared-primitive test suites with React Testing Library --- .../__tests__/AnnouncementForm-test.tsx | 193 ----- .../__tests__/AnnouncementsSection-test.tsx | 113 --- .../__tests__/ChangePasswordForm-test.tsx | 137 ---- .../__tests__/DiscoveryServices-test.tsx | 54 -- .../__tests__/EditableInput-test.tsx | 254 ------ src/components/__tests__/Header-test.tsx | 321 -------- .../IndividualAdminEditForm-test.tsx | 720 ------------------ .../__tests__/IndividualAdmins-test.tsx | 81 -- .../__tests__/LibraryRegistration-test.tsx | 449 ----------- .../NeighborhoodAnalyticsForm-test.tsx | 150 ---- .../__tests__/TextWithEditMode-test.tsx | 207 ----- src/components/__tests__/ToolTip-test.tsx | 65 -- .../jest/components/AnnouncementForm.test.tsx | 202 +++++ .../components/AnnouncementsSection.test.tsx | 140 ++++ .../components/ChangePasswordForm.test.tsx | 154 ++++ .../components/DiscoveryServices.test.tsx | 161 +++- tests/jest/components/EditableInput.test.tsx | 84 +- tests/jest/components/Header.test.tsx | 421 ++++++++++ .../IndividualAdminEditForm.test.tsx | 482 +++++++++++- .../jest/components/IndividualAdmins.test.tsx | 42 +- .../components/LibraryRegistration.test.tsx | 321 ++++++++ .../NeighborhoodAnalyticsForm.test.tsx | 167 ++++ .../jest/components/TextWithEditMode.test.tsx | 218 ++++++ tests/jest/components/ToolTip.test.tsx | 46 ++ 24 files changed, 2421 insertions(+), 2761 deletions(-) delete mode 100644 src/components/__tests__/AnnouncementForm-test.tsx delete mode 100644 src/components/__tests__/AnnouncementsSection-test.tsx delete mode 100644 src/components/__tests__/ChangePasswordForm-test.tsx delete mode 100644 src/components/__tests__/DiscoveryServices-test.tsx delete mode 100644 src/components/__tests__/EditableInput-test.tsx delete mode 100644 src/components/__tests__/Header-test.tsx delete mode 100644 src/components/__tests__/IndividualAdminEditForm-test.tsx delete mode 100644 src/components/__tests__/IndividualAdmins-test.tsx delete mode 100644 src/components/__tests__/LibraryRegistration-test.tsx delete mode 100644 src/components/__tests__/NeighborhoodAnalyticsForm-test.tsx delete mode 100644 src/components/__tests__/TextWithEditMode-test.tsx delete mode 100644 src/components/__tests__/ToolTip-test.tsx create mode 100644 tests/jest/components/AnnouncementForm.test.tsx create mode 100644 tests/jest/components/AnnouncementsSection.test.tsx create mode 100644 tests/jest/components/ChangePasswordForm.test.tsx create mode 100644 tests/jest/components/Header.test.tsx create mode 100644 tests/jest/components/LibraryRegistration.test.tsx create mode 100644 tests/jest/components/NeighborhoodAnalyticsForm.test.tsx create mode 100644 tests/jest/components/TextWithEditMode.test.tsx create mode 100644 tests/jest/components/ToolTip.test.tsx diff --git a/src/components/__tests__/AnnouncementForm-test.tsx b/src/components/__tests__/AnnouncementForm-test.tsx deleted file mode 100644 index ef33a2fe9f..0000000000 --- a/src/components/__tests__/AnnouncementForm-test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/* eslint-disable */ -import { expect } from "chai"; -import { stub, spy } from "sinon"; - -import * as React from "react"; -import { mount } from "enzyme"; - -import AnnouncementForm from "../AnnouncementForm"; -import EditableInput from "../EditableInput"; - -describe("AnnouncementForm", () => { - let wrapper; - let add; - beforeEach(() => { - add = stub(); - wrapper = mount(); - }); - it("renders the input fields", () => { - let fields = wrapper.find(EditableInput); - expect(fields.length).to.equal(3); - checkDefaultValues(); - - let contentField = fields.at(0); - expect(contentField.props().elementType).to.equal("textarea"); - expect(contentField.props().minLength).to.equal(15); - expect(contentField.props().maxLength).to.equal(350); - expect(contentField.props().label).to.equal( - "New Announcement Text (15-350 characters)" - ); - expect(contentField.props().description).to.equal( - "(Current length: 0/350)" - ); - - let startDateField = fields.at(1); - expect(startDateField.props().type).to.equal("date"); - expect(startDateField.props().label).to.equal("Start Date"); - expect(startDateField.props().description).to.equal( - "If no start date is chosen, the default start date is today's date." - ); - - let endDateField = fields.at(2); - expect(endDateField.props().type).to.equal("date"); - expect(endDateField.props().label).to.equal("End Date"); - expect(endDateField.props().description).to.equal( - "If no expiration date is chosen, the default expiration date is 2 months from the start date." - ); - }); - it("renders the buttons", () => { - let buttons = wrapper.find("button"); - expect(buttons.length).to.equal(2); - expect(buttons.at(0).text()).to.equal("Add"); - expect(buttons.at(1).text()).to.equal("Cancel"); - }); - it("keeps track of whether the content is too short or too long", () => { - let counter = wrapper.find(".description").at(0); - expect(counter.text()).to.equal("(Current length: 0/350)"); - expect(counter.parent().hasClass("wrong-length")).to.be.true; - expect(wrapper.find("button").at(0).prop("disabled")).to.be.true; - - fillOutForm(); - - counter = wrapper.find(".description").at(0); - expect(counter.text()).to.equal("(Current length: 66/350)"); - expect(counter.parent().hasClass("wrong-length")).to.be.false; - expect(wrapper.find("button").at(0).prop("disabled")).not.to.be.true; - - let longString = - "Here's some extremely/gratuitously long content. The point of the content is just to get up to 350 characters so that I can test whether the class name will change. I realize even as I am typing this that it probably would have been a better idea to do it with filler text; oh well...? Anyway, I have now written the most boring announcement EVER."; - let contentField = wrapper.find("textarea"); - contentField.getDOMNode().value = longString; - contentField.simulate("change"); - - counter = wrapper.find(".description").at(0); - expect(counter.text()).to.equal("(Current length: 350/350)"); - expect(counter.parent().hasClass("wrong-length")).to.be.true; - }); - let fillOutForm = () => { - checkDefaultState(); - checkDefaultValues(); - expect(add.callCount).to.equal(0); - - let contentString = - "Here is some sample content which comes out to over 15 characters."; - let contentField = wrapper.find("textarea"); - contentField.getDOMNode().value = contentString; - contentField.simulate("change"); - let start = wrapper.find("input").at(0); - start.getDOMNode().value = "2020-06-01"; - start.simulate("change"); - let finish = wrapper.find("input").at(1); - finish.getDOMNode().value = "2020-07-01"; - finish.simulate("change"); - - expect(wrapper.state().content).to.equal(contentString); - expect(wrapper.state().start).to.equal("2020-06-01"); - expect(wrapper.state().finish).to.equal("2020-07-01"); - }; - let checkDefaultState = () => { - expect(wrapper.state().content).to.equal(""); - expect(wrapper.state().start).to.equal( - wrapper.instance().getDefaultDates()[0] - ); - expect(wrapper.state().finish).to.equal( - wrapper.instance().getDefaultDates()[1] - ); - }; - let checkDefaultValues = () => { - expect(wrapper.find("textarea").text()).to.equal(""); - expect(wrapper.find("input").at(0).props().value).to.equal( - wrapper.instance().getDefaultDates()[0] - ); - expect(wrapper.find("input").at(1).props().value).to.equal( - wrapper.instance().getDefaultDates()[1] - ); - }; - it("adds a new announcement", () => { - fillOutForm(); - wrapper.find("button").at(0).simulate("click"); - expect(add.callCount).to.equal(1); - expect(add.args[0][0].content).to.equal( - "Here is some sample content which comes out to over 15 characters." - ); - expect(add.args[0][0].start).to.equal("2020-06-01"); - expect(add.args[0][0].finish).to.equal("2020-07-01"); - }); - it("cancels adding a new announcement", () => { - fillOutForm(); - wrapper.find("button").at(1).simulate("click"); - checkDefaultState(); - checkDefaultValues(); - }); - it("edits an existing announcement", () => { - wrapper.setProps({ - content: - "Here is some sample content which comes out to over 15 characters.", - start: "07/01/2020", - finish: "08/01/2020", - }); - expect(wrapper.state().content).to.equal( - "Here is some sample content which comes out to over 15 characters." - ); - expect(wrapper.state().start).to.equal("2020-07-01"); - expect(wrapper.state().finish).to.equal("2020-08-01"); - expect(wrapper.find("textarea").text()).to.equal( - "Here is some sample content which comes out to over 15 characters." - ); - expect(wrapper.find("input").at(0).props().value).to.equal("2020-07-01"); - expect(wrapper.find("input").at(1).props().value).to.equal("2020-08-01"); - let contentField = wrapper.find("textarea"); - contentField.getDOMNode().value = - "Here is an edited version of the content"; - contentField.simulate("change"); - wrapper.find("button").at(0).simulate("click"); - expect(add.callCount).to.equal(1); - expect(add.args[0][0].content).to.equal( - "Here is an edited version of the content" - ); - checkDefaultState(); - checkDefaultValues(); - }); - it("cancels editing an existing announcement", () => { - let spyCancel = spy(wrapper.instance(), "cancel"); - wrapper.setProps({ - content: - "Here is some sample content which comes out to over 15 characters.", - start: "07/01/2020", - finish: "08/01/2020", - }); - expect(wrapper.state().content).to.equal( - "Here is some sample content which comes out to over 15 characters." - ); - expect(wrapper.state().start).to.equal("2020-07-01"); - expect(wrapper.state().finish).to.equal("2020-08-01"); - expect(wrapper.find("textarea").text()).to.equal( - "Here is some sample content which comes out to over 15 characters." - ); - expect(wrapper.find("input").at(0).props().value).to.equal("2020-07-01"); - expect(wrapper.find("input").at(1).props().value).to.equal("2020-08-01"); - let contentField = wrapper.find("textarea"); - contentField.getDOMNode().value = - "Here is an edited version of the content"; - contentField.simulate("change"); - wrapper.find("button").at(1).simulate("click"); - expect(add.callCount).to.equal(1); - expect(add.args[0][0].content).to.equal( - "Here is an edited version of the content" - ); - expect(spyCancel.callCount).to.equal(1); - checkDefaultState(); - checkDefaultValues(); - spyCancel.restore(); - }); -}); diff --git a/src/components/__tests__/AnnouncementsSection-test.tsx b/src/components/__tests__/AnnouncementsSection-test.tsx deleted file mode 100644 index 123042f505..0000000000 --- a/src/components/__tests__/AnnouncementsSection-test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { mount } from "enzyme"; - -import AnnouncementsSection from "../AnnouncementsSection"; - -describe("AnnouncementsSection", () => { - let wrapper; - const announcements = [ - { - content: "First Announcement", - start: "2020-05-12", - finish: "2020-06-12", - id: "perm_1", - }, - { - content: "Second Announcement", - start: "2020-05-28", - finish: "2020-06-28", - id: "perm_2", - }, - ]; - const setting = { - description: "announcements", - format: "date-range", - key: "announcements", - label: "Announcements", - type: "list", - }; - - beforeEach(() => { - wrapper = mount( - - ); - }); - it("renders a list of announcements", () => { - expect(wrapper.find("ul").find("h4").text()).to.equal( - "Scheduled Announcements:" - ); - const list = wrapper.find(".announcements-ul"); - const announcement1 = list.find(".announcement").at(0); - const announcement2 = list.find(".announcement").at(1); - expect(announcement1.find("span").text()).to.equal("First Announcement"); - expect(announcement1.find(".dates").text()).to.equal( - "05/12/2020 – 06/12/2020" - ); - expect(announcement2.find("span").text()).to.equal("Second Announcement"); - expect(announcement2.find(".dates").text()).to.equal( - "05/28/2020 – 06/28/2020" - ); - }); - it("renders a form", () => { - expect(wrapper.find(".announcement-form").length).to.equal(1); - }); - it("adds an announcement", () => { - expect(wrapper.state().currentAnnouncements.length).to.equal(2); - expect(wrapper.find(".announcement").length).to.equal(2); - const form = wrapper.find(".announcement-form"); - const textarea = form.find("textarea"); - textarea.getDOMNode().value = "Third Announcement"; - textarea.simulate("change"); - form.find("button").at(0).simulate("click"); - expect(wrapper.state().currentAnnouncements.length).to.equal(3); - expect(wrapper.state().currentAnnouncements[2].id).to.equal("temp_1"); - expect(wrapper.find(".announcement").length).to.equal(3); - }); - it("edits an announcement", () => { - expect(wrapper.state().editing).to.be.null; - expect(wrapper.state().currentAnnouncements.length).to.equal(2); - expect(wrapper.find(".announcement").length).to.equal(2); - const editButton = wrapper.find("button").at(0); - expect(editButton.text()).to.equal("Edit"); - editButton.simulate("click"); - expect(wrapper.state().editing.content).to.equal("First Announcement"); - expect(wrapper.state().editing.start).to.equal("2020-05-12"); - expect(wrapper.state().editing.finish).to.equal("2020-06-12"); - expect(wrapper.state().currentAnnouncements.length).to.equal(1); - expect(wrapper.state().currentAnnouncements[0].content).to.equal( - "Second Announcement" - ); - expect(wrapper.find(".announcement").length).to.equal(1); - }); - it("deletes an announcement", () => { - const confirmStub = stub(window, "confirm").returns(true); - expect(wrapper.state().currentAnnouncements.length).to.equal(2); - expect(wrapper.find(".announcement").length).to.equal(2); - const deleteButton = wrapper.find("button").at(1); - expect(deleteButton.text()).to.equal("Delete"); - deleteButton.simulate("click"); - expect(wrapper.state().currentAnnouncements.length).to.equal(1); - expect(wrapper.find(".announcement").length).to.equal(1); - expect(wrapper.find(".announcement").at(0).text()).to.contain( - "Second Announcement" - ); - confirmStub.restore(); - }); - it("removes any temporary IDs before submitting the list of announcements", () => { - wrapper.setState({ - currentAnnouncements: announcements.concat({ - content: "temp", - start: "2020-05-28", - finish: "2020-06-28", - id: "temp_1", - }), - }); - const list = wrapper.instance().getValue(); - expect(list[0].id).to.equal("perm_1"); - expect(list[1].id).to.equal("perm_2"); - expect(list[2].id).to.be.undefined; - }); -}); diff --git a/src/components/__tests__/ChangePasswordForm-test.tsx b/src/components/__tests__/ChangePasswordForm-test.tsx deleted file mode 100644 index 907c471f61..0000000000 --- a/src/components/__tests__/ChangePasswordForm-test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { mount } from "enzyme"; - -import { ChangePasswordForm } from "../ChangePasswordForm"; -import LoadingIndicator from "@thepalaceproject/web-opds-client/lib/components/LoadingIndicator"; -import ErrorMessage from "../ErrorMessage"; -import EditableInput from "../EditableInput"; - -describe("ChangePasswordForm", () => { - let wrapper; - let changePassword; - - beforeEach(() => { - changePassword = stub().returns( - new Promise((resolve) => resolve()) - ); - wrapper = mount( - - ); - }); - - it("shows ErrorMessage on request error", () => { - let error = wrapper.find(ErrorMessage); - expect(error.length).to.equal(0); - const fetchError = { - status: 500, - response: "response", - url: "", - }; - wrapper.setProps({ fetchError }); - error = wrapper.find(ErrorMessage); - expect(error.length).to.equal(1); - }); - - it("shows LoadingIndicator", () => { - let loading = wrapper.find(LoadingIndicator); - expect(loading.length).to.equal(0); - - wrapper.setProps({ isFetching: true }); - loading = wrapper.find(LoadingIndicator); - expect(loading.length).to.equal(1); - }); - - it("shows validation error", () => { - let error = wrapper.find(".alert-danger"); - expect(error.length).to.equal(0); - - wrapper.setState({ success: false, error: "Error!" }); - error = wrapper.find(".alert-danger"); - expect(error.length).to.equal(1); - expect(error.text()).to.equal("Error!"); - }); - - it("shows success message", () => { - let success = wrapper.find(".alert-success"); - expect(success.length).to.equal(0); - - wrapper.setState({ success: true, error: null }); - success = wrapper.find(".alert-success"); - expect(success.length).to.equal(1); - expect(success.text()).to.equal("Password changed successfully"); - }); - - it("shows password inputs", () => { - const inputs = wrapper.find(EditableInput); - expect(inputs.length).to.equal(2); - expect(inputs.at(0).prop("label")).to.contain("New Password"); - expect(inputs.at(1).prop("label")).to.contain("Confirm New Password"); - expect(inputs.at(0).prop("type")).to.equal("password"); - expect(inputs.at(1).prop("type")).to.equal("password"); - }); - - it("does not submit a blank or partially blank form", () => { - const formData = new (window as any).FormData(); - wrapper.instance().save(formData); - expect(changePassword.callCount).to.equal(0); - expect(wrapper.instance().state.error).to.contain( - "Fields cannot be blank." - ); - formData.append("password", "newPassword"); - wrapper.instance().save(formData); - expect(changePassword.callCount).to.equal(0); - expect(wrapper.instance().state.error).to.contain( - "Fields cannot be blank." - ); - }); - - it("checks if passwords match", () => { - wrapper = mount( - - ); - const formData = new (window as any).FormData(); - formData.append("password", "newPassword"); - formData.append("confirm_password", "somethingElse"); - - wrapper.instance().save(formData); - - expect(changePassword.callCount).to.equal(0); - expect(wrapper.instance().state.error).to.contain("Passwords do not match"); - }); - - it("submits new password", async () => { - wrapper = mount( - - ); - const formData = new (window as any).FormData(); - formData.append("password", "newPassword"); - formData.append("confirm_password", "newPassword"); - wrapper.instance().save(formData); - - expect(changePassword.callCount).to.equal(1); - const calledWith = changePassword.args[0][0]; - expect(calledWith.get("password")).to.equal("newPassword"); - - // Let the call stack clear so the callback after editItem will run. - const pause = (): Promise => { - return new Promise((resolve) => setTimeout(resolve, 0)); - }; - await pause(); - expect(wrapper.instance().state.success).to.equal(true); - }); -}); diff --git a/src/components/__tests__/DiscoveryServices-test.tsx b/src/components/__tests__/DiscoveryServices-test.tsx deleted file mode 100644 index 7d90c836f5..0000000000 --- a/src/components/__tests__/DiscoveryServices-test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { shallow } from "enzyme"; - -import { DiscoveryServices } from "../DiscoveryServices"; - -describe("DiscoveryServices", () => { - let wrapper; - let registerLibrary; - let fetchLibraryRegistrations; - let registrationStage; - - beforeEach(() => { - registerLibrary = stub().returns( - new Promise((resolve) => resolve()) - ); - registrationStage = "production"; - fetchLibraryRegistrations = stub(); - wrapper = shallow( - - ); - }); - - it("includes registerLibrary in child context, and fetches library registrations on mount and after registering", async () => { - const context = wrapper.instance().getChildContext(); - - expect(fetchLibraryRegistrations.callCount).to.equal(1); - - const library = { short_name: "nypl" }; - context.registerLibrary(library, registrationStage); - - expect(registerLibrary.callCount).to.equal(1); - const formData = registerLibrary.args[0][0]; - expect(formData.get("library_short_name")).to.equal("nypl"); - expect(formData.get("integration_id")).to.equal("2"); - expect(formData.get("registration_stage")).to.equal("production"); - - const pause = new Promise((resolve) => setTimeout(resolve, 0)); - await pause; - expect(fetchLibraryRegistrations.callCount).to.equal(2); - }); -}); diff --git a/src/components/__tests__/EditableInput-test.tsx b/src/components/__tests__/EditableInput-test.tsx deleted file mode 100644 index 408ffad78e..0000000000 --- a/src/components/__tests__/EditableInput-test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { shallow, mount } from "enzyme"; - -import EditableInput from "../EditableInput"; - -describe("EditableInput", () => { - let wrapper; - - beforeEach(() => { - wrapper = shallow( - - - - ); - }); - - it("should not show the error display style", () => { - const fieldError = wrapper.find(".field-error"); - - expect(fieldError.length).to.equal(0); - }); - - it("shows label from props", () => { - let label = wrapper.find("label"); - expect(label.text()).to.contain("label"); - - wrapper.setProps({ type: "checkbox" }); - label = wrapper.find("label"); - expect(label.text()).to.contain("label"); - - wrapper.setProps({ type: "radio" }); - label = wrapper.find("label"); - expect(label.text()).to.contain("label"); - }); - - it("shows description from props", () => { - const description = wrapper.find(".description"); - expect(description.html()).to.contain("

description

"); - }); - - it("shows initial value from props, even if zero", () => { - expect(wrapper.state().value).to.equal("initial value"); - let input = wrapper.find("input"); - expect(input.prop("value")).to.equal("initial value"); - expect(input.prop("checked")).to.equal(true); - - wrapper.setProps({ value: 0}); - expect(wrapper.state().value).to.equal(0); - input = wrapper.find("input"); - expect(input.prop("value")).to.equal(0); - }); - - it("shows initial checked from props", () => { - expect(wrapper.state("checked")).to.equal(true); - const input = wrapper.find("input"); - expect(input.prop("checked")).to.equal(true); - }); - - it("optionally shows extra content", () => { - let extra = wrapper.find(".with-add-on"); - expect(extra.length).to.equal(0); - - wrapper.setProps({ extraContent: Extra content! }); - - extra = wrapper.find(".with-add-on"); - expect(extra.length).to.equal(1); - expect(extra.text()).to.equal("Extra content!"); - }); - - it("shows children from props", () => { - const option = wrapper.find("option"); - expect(option.text()).to.equal("option"); - }); - - it("directly sets a new value", () => { - const wrapper = mount( - - ); - expect(wrapper.state()["value"]).to.equal("initial value"); - expect(wrapper.instance().getValue()).to.equal("initial value"); - wrapper.instance().setValue("updated"); - expect(wrapper.state()["value"]).to.equal("updated"); - expect(wrapper.instance().getValue()).to.equal("updated"); - }); - - it("updates state and value when props change", () => { - wrapper.setProps({ value: "new value", checked: false }); - expect(wrapper.state().value).to.equal("new value"); - expect(wrapper.state().checked).to.equal(false); - const input = wrapper.find("input"); - expect(input.prop("value")).to.equal("new value"); - expect(input.prop("checked")).to.equal(false); - }); - - it("updates value in state when input changes", () => { - let wrapper = mount( - - ); - let input = wrapper.find("input"); - let inputElement = input.getDOMNode(); - inputElement.value = "new value"; - input.simulate("change"); - expect(wrapper.state().value).to.equal("new value"); - - // This also works with a checkbox. - wrapper = mount( - - ); - input = wrapper.find("input"); - inputElement = input.getDOMNode(); - inputElement.checked = false; - input.simulate("change"); - expect(wrapper.state().checked).to.equal(false); - - // And a radio. - wrapper = mount( - - ); - input = wrapper.find("input"); - inputElement = input.getDOMNode(); - inputElement.checked = false; - input.simulate("change"); - expect(wrapper.state().checked).to.equal(false); - }); - - it("disables", () => { - const wrapper = mount( - - ); - const input = wrapper.find("input"); - expect(input.prop("disabled")).to.equal(true); - }); - - it("calls provided onChange", () => { - const onChange = stub(); - const wrapper = mount( - - ); - - const input = wrapper.find("input"); - const inputElement = input.getDOMNode(); - inputElement.value = "new value"; - input.simulate("change"); - expect(onChange.callCount).to.equal(1); - }); - - it("clears input", () => { - wrapper = mount( - - ); - - (wrapper.instance() as EditableInput).clear(); - expect(wrapper.state("value")).to.equal(""); - expect(wrapper.state("checked")).to.equal(false); - const inputElement = wrapper.find("input").getDOMNode(); - expect(inputElement.value).to.equal(""); - expect(inputElement.checked).to.equal(false); - }); - - it("shows as an error field style with client or server error", () => { - wrapper = mount( - - ); - - let fieldError = wrapper.find(".field-error"); - - expect(fieldError.length).to.equal(1); - - wrapper = mount( - - ); - - fieldError = wrapper.find(".field-error"); - expect(fieldError.length).to.equal(1); - }); -}); diff --git a/src/components/__tests__/Header-test.tsx b/src/components/__tests__/Header-test.tsx deleted file mode 100644 index c6ae4aabd1..0000000000 --- a/src/components/__tests__/Header-test.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; -import * as PropTypes from "prop-types"; -import * as React from "react"; -import { shallow, mount } from "enzyme"; - -import { Header } from "../Header"; -import EditableInput from "../EditableInput"; -import { NavItem } from "react-bootstrap"; -import { Link } from "react-router"; -import Admin from "../../models/Admin"; -import title from "../../utils/title"; - -describe("Header", () => { - let wrapper; - let context; - - const libraryManager = new Admin([{ role: "manager", library: "nypl" }]); - const librarian = new Admin([{ role: "librarian", library: "nypl" }]); - const systemAdmin = new Admin([{ role: "system", library: "nypl" }]); - const router = { - createHref: stub(), - push: stub(), - isActive: stub(), - replace: stub(), - go: stub(), - goBack: stub(), - goForward: stub(), - setRouteLeaveHook: stub(), - getCurrentLocation: stub(), - }; - const childContextTypes = { - router: PropTypes.object.isRequired, - pathFor: PropTypes.func.isRequired, - admin: PropTypes.object.isRequired, - }; - - beforeEach(() => { - context = { library: () => "nypl", admin: libraryManager, router }; - - wrapper = shallow(
, { context }); - }); - - describe("rendering", () => { - it("renders h1 heading", () => { - const heading = wrapper.find("h1"); - expect(heading.text()).to.equal(title()); - }); - - it("shows the brand name", () => { - expect(wrapper.containsMatchingElement({title()})).to.equal( - true - ); - }); - - it("shows library dropdown when libraries are available", () => { - let select = wrapper.find(EditableInput); - expect(select.length).to.equal(0); - - const libraries = [ - { short_name: "nypl", name: "NYPL" }, - { short_name: "bpl" }, - ]; - wrapper.setProps({ libraries }); - select = wrapper.find(EditableInput); - expect(select.length).to.equal(1); - expect(select.props().elementType).to.equal("select"); - expect(select.props().value).to.equal("nypl"); - let options = select.children(); - expect(options.length).to.equal(2); - expect(options.at(0).text()).to.equal("NYPL"); - expect(options.at(0).props().value).to.equal("nypl"); - expect(options.at(1).text()).to.equal("bpl"); - expect(options.at(1).props().value).to.equal("bpl"); - - wrapper.setContext({ library: () => "bpl", admin: libraryManager }); - select = wrapper.find(EditableInput); - expect(select.props().value).to.equal("bpl"); - - wrapper.setContext({ admin: libraryManager }); - select = wrapper.find(EditableInput); - options = select.children(); - expect(options.length).to.equal(3); - expect(options.at(0).text()).to.equal("Select a library"); - expect(options.at(1).text()).to.equal("NYPL"); - expect(options.at(1).props().value).to.equal("nypl"); - expect(options.at(2).text()).to.equal("bpl"); - expect(options.at(2).props().value).to.equal("bpl"); - }); - - it("shows catalog links when there's a library", () => { - let navItems = wrapper.find(NavItem); - - expect(navItems.length).to.equal(2); - - const mainNavItem = navItems.at(0); - expect(mainNavItem.prop("href")).to.contain("/nypl%2Fgroups"); - expect(mainNavItem.children().text()).to.equal("Catalog"); - - const hiddenLink = navItems.at(1); - expect(hiddenLink.prop("href")).to.contain("/nypl%2Fadmin%2Fsuppressed"); - expect(hiddenLink.children().text()).to.equal("Hidden Books"); - - wrapper.setContext({ admin: libraryManager }); - navItems = wrapper.find(NavItem); - expect(navItems.length).to.equal(0); - }); - - it("shows sitewide links, non-catalog library links, and system configuration link for library manager", () => { - let links = wrapper.find(Link); - expect(links.length).to.equal(4); - - let dashboardLink = links.at(0); - expect(dashboardLink.prop("to")).to.equal("/admin/web/dashboard/nypl"); - expect(dashboardLink.children().text()).to.equal("Dashboard"); - - const listsLink = links.at(1); - expect(listsLink.prop("to")).to.equal("/admin/web/lists/nypl"); - expect(listsLink.children().text()).to.equal("Lists"); - - const lanesLink = links.at(2); - expect(lanesLink.prop("to")).to.equal("/admin/web/lanes/nypl"); - expect(lanesLink.children().text()).to.equal("Lanes"); - - let settingsLink = links.at(3); - expect(settingsLink.prop("to")).to.equal("/admin/web/config/"); - expect(settingsLink.children().text()).to.equal("System Configuration"); - - // no selected library - wrapper.setContext({ admin: libraryManager }); - links = wrapper.find(Link); - dashboardLink = links.at(0); - expect(dashboardLink.prop("to")).to.equal("/admin/web/dashboard/"); - - settingsLink = links.at(1); - expect(settingsLink.prop("to")).to.equal("/admin/web/config/"); - }); - - it("shows sitewide, system configuration, and non-catalog library links for librarian", () => { - wrapper.setContext({ library: () => "nypl", admin: librarian }); - - const links = wrapper.find(Link); - expect(links.length).to.equal(3); - - const dashboardLink = links.at(0); - expect(dashboardLink.prop("to")).to.equal("/admin/web/dashboard/nypl"); - expect(dashboardLink.children().text()).to.equal("Dashboard"); - - const listsLink = links.at(1); - expect(listsLink.prop("to")).to.equal("/admin/web/lists/nypl"); - expect(listsLink.children().text()).to.equal("Lists"); - - const sysConfigLink = links.at(2); - expect(sysConfigLink.prop("to")).to.equal("/admin/web/config/"); - expect(sysConfigLink.children().text()).to.equal("System Configuration"); - }); - - it("shows account dropdown when the admin has an email", () => { - const admin = new Admin( - [{ role: "librarian", library: "nypl" }], - "admin@nypl.org" - ); - wrapper.setContext({ library: () => "nypl", admin: admin, router }); - const emailBtn = wrapper.find(".account-dropdown-toggle").render(); - expect(emailBtn.length).to.equal(1); - expect(emailBtn.text()).to.contain("admin@nypl.org"); - }); - - describe("patron manager display", () => { - it("does not show Patron Manager link for librarian", () => { - wrapper.setContext({ library: () => "nypl", admin: librarian }); - const links = wrapper.find(Link); - expect(links.length).to.equal(3); - links.forEach((link) => { - expect(link.children().text()).to.not.equal("Patrons"); - }); - }); - it("does not show Patron Manager link for library manager", () => { - wrapper.setContext({ library: () => "nypl", admin: libraryManager }); - const links = wrapper.find(Link); - expect(links.length).to.equal(4); - links.forEach((link) => { - expect(link.children().text()).to.not.equal("Patrons"); - }); - }); - it("shows Patron Manager link for system admin", () => { - wrapper.setContext({ library: () => "nypl", admin: systemAdmin }); - const links = wrapper.find(Link); - const patronManagerLink = links.at(3); - expect(links.length).to.equal(6); - expect(patronManagerLink.children().text()).to.equal("Patrons"); - }); - }); - describe("troubleshooting display", () => { - it("does not show Troubleshooting link for librarian", () => { - wrapper.setContext({ library: () => "nypl", admin: librarian }); - const links = wrapper.find(Link); - expect(links.length).to.equal(3); - links.forEach((link) => { - expect(link.children().text()).to.not.equal("Troubleshooting"); - }); - }); - it("does not show Troubleshooting link for library manager", () => { - wrapper.setContext({ library: () => "nypl", admin: libraryManager }); - const links = wrapper.find(Link); - expect(links.length).to.equal(4); - links.forEach((link) => { - expect(link.children().text()).to.not.equal("Troubleshooting"); - }); - }); - it("shows Troubleshooting link for system admin", () => { - wrapper.setContext({ library: () => "nypl", admin: systemAdmin }); - const links = wrapper.find(Link); - expect(links.length).to.equal(6); - expect(links.at(5).children().text()).to.equal("Troubleshooting"); - }); - }); - }); - - describe("behavior", () => { - it("fetches libraries on mount", () => { - const fetchLibraries = stub(); - wrapper = shallow(
, { - context: { admin: libraryManager }, - }); - expect(fetchLibraries.callCount).to.equal(1); - }); - - it("does not fetch libraries on mount if a fetch is already in progress", () => { - const fetchLibraries = stub(); - - wrapper = shallow( -
, - { - context: { admin: libraryManager }, - } - ); - - expect(fetchLibraries.callCount).to.equal(0); - }); - - it("changes library", async () => { - const libraries = [ - { short_name: "nypl", name: "NYPL" }, - { short_name: "bpl" }, - ]; - const fullContext = Object.assign({}, context, { - pathFor: stub().returns("url"), - router, - admin: libraryManager, - }); - wrapper = mount(
, { - context: fullContext, - childContextTypes, - }); - const select = wrapper.find("select") as any; - const selectElement = select.getDOMNode(); - selectElement.value = "bpl"; - select.simulate("change"); - - expect(fullContext.router.push.callCount).to.equal(1); - expect(fullContext.router.push.args[0][0]).to.equal( - "/admin/web/collection/bpl%2Fgroups" - ); - }); - - it("toggles account dropdown", () => { - const admin = new Admin( - [{ role: "librarian", library: "nypl" }], - "admin@nypl.org" - ); - context = { library: () => "nypl", admin, router }; - const fullContext = Object.assign({}, context, { - pathFor: stub().returns("url"), - router, - admin, - }); - wrapper = mount(
, { context: fullContext, childContextTypes }); - - const toggle = wrapper.find(".account-dropdown-toggle").hostNodes(); - let dropdownItems = wrapper.find("ul.dropdown-menu li"); - let dropdownLinks = wrapper.find("ul.dropdown-menu li a"); - expect(dropdownItems.length).to.equal(0); - expect(dropdownLinks.length).to.equal(0); - - toggle.simulate("click"); - dropdownItems = wrapper.find("ul.dropdown-menu li"); - dropdownLinks = wrapper.find("ul.dropdown-menu li a"); - expect(dropdownItems.length).to.equal(3); - expect(dropdownLinks.length).to.equal(2); - - const loggedInAs = dropdownItems.at(0); - expect(loggedInAs.text()).to.equal("Logged in as a user"); - const changePassword = dropdownLinks.at(0); - expect(changePassword.parent().prop("to")).to.equal( - "/admin/web/account/" - ); - // The first anchor element is from the Link component. - const signOut = dropdownLinks.find("a").at(1); - expect(signOut.prop("href")).to.equal("/admin/sign_out"); - - toggle.simulate("click"); - dropdownLinks = wrapper.find("ul.dropdown-menu li"); - expect(dropdownLinks.length).to.equal(0); - }); - }); - - it("displays the user's level of permissions", () => { - const permissions = (args) => - wrapper - .instance() - .displayPermissions(...args) - .props.children.join(""); - expect(permissions([true, true])).to.equal("Logged in as a system admin"); - expect(permissions([true, false])).to.equal("Logged in as a system admin"); - expect(permissions([false, true])).to.equal( - "Logged in as an administrator" - ); - expect(permissions([false, false])).to.equal("Logged in as a user"); - }); -}); diff --git a/src/components/__tests__/IndividualAdminEditForm-test.tsx b/src/components/__tests__/IndividualAdminEditForm-test.tsx deleted file mode 100644 index ed256d0a08..0000000000 --- a/src/components/__tests__/IndividualAdminEditForm-test.tsx +++ /dev/null @@ -1,720 +0,0 @@ -import { expect } from "chai"; -import { stub, spy } from "sinon"; - -import * as React from "react"; -import { mount } from "enzyme"; - -import IndividualAdminEditForm from "../IndividualAdminEditForm"; -import EditableInput from "../EditableInput"; -import Admin from "../../models/Admin"; -import { Button, Form } from "library-simplified-reusable-components"; -import { AdminRoleData } from "../../interfaces"; - -describe("IndividualAdminEditForm", () => { - let wrapper; - let save; - const adminData = { - email: "test@nypl.org", - password: "password", - }; - const allLibraries = [{ short_name: "nypl" }, { short_name: "bpl" }]; - - const systemAdmin: AdminRoleData[] = [{ role: "system" }]; - const managerAll: AdminRoleData[] = [{ role: "manager-all" }]; - const librarianAll: AdminRoleData[] = [{ role: "librarian-all" }]; - const nyplManager: AdminRoleData[] = [{ role: "manager", library: "nypl" }]; - const bplManager: AdminRoleData[] = [{ role: "manager", library: "bpl" }]; - const bothManager: AdminRoleData[] = [ - { role: "manager", library: "nypl" }, - { role: "manager", library: "bpl" }, - ]; - const nyplLibrarian: AdminRoleData[] = [ - { role: "librarian", library: "nypl" }, - ]; - const bplLibrarian: AdminRoleData[] = [{ role: "librarian", library: "bpl" }]; - const bothLibrarian: AdminRoleData[] = [ - { role: "librarian", library: "nypl" }, - { role: "librarian", library: "bpl" }, - ]; - const nyplManagerLibrarianAll: AdminRoleData[] = [ - { role: "manager", library: "nypl" }, - { role: "librarian-all" }, - ]; - - const editableInputByName = (name) => { - const inputs = wrapper.find(EditableInput); - if (inputs.length >= 1) { - return inputs.filterWhere((input) => input.props().name === name); - } - return []; - }; - - describe("rendering", () => { - beforeEach(() => { - save = stub(); - wrapper = mount( - , - { context: { admin: new Admin(systemAdmin) } } - ); - }); - - it("renders email", () => { - let input = editableInputByName("email"); - expect(input.props().value).not.to.be.ok; - - wrapper.setProps({ item: adminData }); - input = editableInputByName("email"); - expect(input.props().value).to.equal("test@nypl.org"); - }); - - it("renders password if admin is allowed to edit it", () => { - const input = editableInputByName("password"); - expect(input.props().value).not.to.be.ok; - - const expectPasswordEditable = ( - targetAdminRoles, - editingAdminRoles, - editable: boolean = true - ) => { - const targetAdmin = new Admin(targetAdminRoles); - const editingAdmin = new Admin(editingAdminRoles); - wrapper.setProps({ item: targetAdmin }); - wrapper.setContext({ admin: editingAdmin }); - const input = editableInputByName("password"); - if (editable) { - expect(input.length).to.equal(1); - // The old password isn't shown even if it's editable. - expect(input.props().value).not.to.be.ok; - } else { - expect(input.length).to.equal(0); - } - }; - - expectPasswordEditable([], systemAdmin); - expectPasswordEditable([], managerAll); - expectPasswordEditable([], librarianAll, false); - expectPasswordEditable([], nyplManager); - expectPasswordEditable([], nyplLibrarian, false); - expectPasswordEditable([], nyplManagerLibrarianAll); - - expectPasswordEditable(systemAdmin, systemAdmin); - expectPasswordEditable(systemAdmin, managerAll, false); - expectPasswordEditable(systemAdmin, librarianAll, false); - expectPasswordEditable(systemAdmin, nyplManager, false); - expectPasswordEditable(systemAdmin, nyplLibrarian, false); - expectPasswordEditable(systemAdmin, nyplManagerLibrarianAll, false); - - expectPasswordEditable(managerAll, systemAdmin); - expectPasswordEditable(managerAll, managerAll); - expectPasswordEditable(managerAll, librarianAll, false); - expectPasswordEditable(managerAll, nyplManager, false); - expectPasswordEditable(managerAll, nyplLibrarian, false); - expectPasswordEditable(managerAll, nyplManagerLibrarianAll, false); - - expectPasswordEditable(librarianAll, systemAdmin); - expectPasswordEditable(librarianAll, managerAll); - expectPasswordEditable(librarianAll, librarianAll, false); - expectPasswordEditable(librarianAll, nyplManager, false); - expectPasswordEditable(librarianAll, nyplLibrarian, false); - expectPasswordEditable(librarianAll, nyplManagerLibrarianAll, false); - - expectPasswordEditable(nyplManager, systemAdmin); - expectPasswordEditable(nyplManager, managerAll); - expectPasswordEditable(nyplManager, librarianAll, false); - expectPasswordEditable(nyplManager, nyplManager); - expectPasswordEditable(nyplManager, nyplLibrarian, false); - expectPasswordEditable(nyplManager, nyplManagerLibrarianAll); - - expectPasswordEditable(bplManager, systemAdmin); - expectPasswordEditable(bplManager, managerAll); - expectPasswordEditable(bplManager, librarianAll, false); - expectPasswordEditable(bplManager, nyplManager, false); - expectPasswordEditable(bplManager, nyplLibrarian, false); - expectPasswordEditable(bplManager, nyplManagerLibrarianAll, false); - - expectPasswordEditable(bothManager, systemAdmin); - expectPasswordEditable(bothManager, managerAll); - expectPasswordEditable(bothManager, librarianAll, false); - expectPasswordEditable(bothManager, nyplManager); - expectPasswordEditable(bothManager, nyplLibrarian, false); - expectPasswordEditable(bothManager, nyplManagerLibrarianAll); - - expectPasswordEditable(nyplLibrarian, systemAdmin); - expectPasswordEditable(nyplLibrarian, managerAll); - expectPasswordEditable(nyplLibrarian, librarianAll, false); - expectPasswordEditable(nyplLibrarian, nyplManager); - expectPasswordEditable(nyplLibrarian, nyplLibrarian, false); - expectPasswordEditable(nyplLibrarian, nyplManagerLibrarianAll); - - expectPasswordEditable(bplLibrarian, systemAdmin); - expectPasswordEditable(bplLibrarian, managerAll); - expectPasswordEditable(bplLibrarian, librarianAll, false); - expectPasswordEditable(bplLibrarian, nyplManager, false); - expectPasswordEditable(bplLibrarian, nyplLibrarian, false); - expectPasswordEditable(bplLibrarian, nyplManagerLibrarianAll, false); - - expectPasswordEditable(nyplManagerLibrarianAll, systemAdmin); - expectPasswordEditable(nyplManagerLibrarianAll, managerAll); - expectPasswordEditable(nyplManagerLibrarianAll, librarianAll, false); - expectPasswordEditable(nyplManagerLibrarianAll, nyplManager); - expectPasswordEditable(nyplManagerLibrarianAll, nyplLibrarian, false); - expectPasswordEditable(nyplManagerLibrarianAll, nyplManagerLibrarianAll); - }); - - it("has a save button if a save function is supplied", () => { - const saveButton = wrapper.find(Button); - expect(saveButton.length).to.equal(1); - }); - - it("does not have a save button if no save function is supplied", () => { - wrapper.setProps({ save: undefined }); - const saveButton = wrapper.find(Button); - expect(saveButton.length).to.equal(0); - }); - - describe("roles", () => { - const expectRole = ( - startingRoles, - role, - shouldBeChecked: boolean, - shouldBeEnabled: boolean = true - ) => { - const adminDataWithRoles = Object.assign({}, adminData, { - roles: startingRoles, - }); - wrapper.setProps({ item: adminDataWithRoles }); - const input = editableInputByName(role); - expect(input.props().checked).to.equal(shouldBeChecked); - expect(input.props().disabled).to.equal(!shouldBeEnabled); - }; - - it("renders system admin role", () => { - expectRole([], "system", false); - expectRole(systemAdmin, "system", true); - expectRole(managerAll, "system", false); - expectRole(bothLibrarian, "system", false); - - wrapper.setContext({ admin: new Admin(managerAll) }); - expectRole([], "system", false, false); - expectRole(systemAdmin, "system", true, false); - expectRole(managerAll, "system", false, false); - expectRole(bothLibrarian, "system", false, false); - }); - - it("renders manager all role", () => { - expectRole([], "manager-all", false); - expectRole(systemAdmin, "manager-all", true); - expectRole(managerAll, "manager-all", true); - expectRole(bothManager, "manager-all", false); - expectRole(nyplManager, "manager-all", false); - expectRole(librarianAll, "manager-all", false); - - wrapper.setContext({ admin: new Admin(librarianAll) }); - expectRole([], "manager-all", false, false); - expectRole(systemAdmin, "manager-all", true, false); - expectRole(managerAll, "manager-all", true, false); - expectRole(bothManager, "manager-all", false, false); - expectRole(nyplManager, "manager-all", false, false); - expectRole(librarianAll, "manager-all", false, false); - - wrapper.setContext({ admin: new Admin(nyplManager) }); - expectRole([], "manager-all", false, false); - expectRole(systemAdmin, "manager-all", true, false); - expectRole(managerAll, "manager-all", true, false); - expectRole(bothManager, "manager-all", false, false); - expectRole(nyplManager, "manager-all", false, false); - expectRole(librarianAll, "manager-all", false, false); - }); - - it("renders librarian all role", () => { - expectRole([], "librarian-all", false); - expectRole(systemAdmin, "librarian-all", true); - expectRole(managerAll, "librarian-all", true); - expectRole(bothManager, "librarian-all", false); - expectRole(nyplManager, "librarian-all", false); - expectRole(librarianAll, "librarian-all", true); - expectRole(bothLibrarian, "librarian-all", false); - expectRole(nyplLibrarian, "librarian-all", false); - - wrapper.setContext({ admin: new Admin(nyplManager) }); - expectRole([], "librarian-all", false, false); - expectRole(systemAdmin, "librarian-all", true, false); - expectRole(managerAll, "librarian-all", true, false); - expectRole(bothManager, "librarian-all", false, false); - expectRole(nyplManager, "librarian-all", false, false); - expectRole(librarianAll, "librarian-all", true, false); - expectRole(bothLibrarian, "librarian-all", false, false); - expectRole(nyplLibrarian, "librarian-all", false, false); - - wrapper.setContext({ admin: new Admin(librarianAll) }); - expectRole([], "librarian-all", false, false); - expectRole(systemAdmin, "librarian-all", true, false); - expectRole(managerAll, "librarian-all", true, false); - expectRole(bothManager, "librarian-all", false, false); - expectRole(nyplManager, "librarian-all", false, false); - expectRole(librarianAll, "librarian-all", true, false); - expectRole(bothLibrarian, "librarian-all", false, false); - expectRole(nyplLibrarian, "librarian-all", false, false); - }); - - it("renders manager role for each library", () => { - expectRole([], "manager-nypl", false); - expectRole(systemAdmin, "manager-nypl", true); - expectRole(managerAll, "manager-nypl", true); - expectRole(bothManager, "manager-nypl", true); - expectRole(nyplManager, "manager-nypl", true); - expectRole(librarianAll, "manager-nypl", false); - expectRole(bplManager, "manager-nypl", false); - expectRole(nyplLibrarian, "manager-nypl", false); - - expectRole(systemAdmin, "manager-bpl", true); - expectRole(managerAll, "manager-bpl", true); - expectRole(bothManager, "manager-bpl", true); - expectRole(nyplManager, "manager-bpl", false); - expectRole(librarianAll, "manager-bpl", false); - expectRole(bplManager, "manager-bpl", true); - expectRole(nyplLibrarian, "manager-bpl", false); - - wrapper.setContext({ admin: new Admin(nyplManager) }); - - expectRole([], "manager-nypl", false, true); - expectRole(systemAdmin, "manager-nypl", true, false); - expectRole(managerAll, "manager-nypl", true, false); - expectRole(bothManager, "manager-nypl", true, true); - expectRole(nyplManager, "manager-nypl", true, true); - expectRole(librarianAll, "manager-nypl", false, true); - expectRole(bplManager, "manager-nypl", false, true); - expectRole(nyplLibrarian, "manager-nypl", false, true); - - expectRole(systemAdmin, "manager-bpl", true, false); - expectRole(managerAll, "manager-bpl", true, false); - expectRole(bothManager, "manager-bpl", true, false); - expectRole(nyplManager, "manager-bpl", false, false); - expectRole(librarianAll, "manager-bpl", false, false); - expectRole(bplManager, "manager-bpl", true, false); - expectRole(nyplLibrarian, "manager-bpl", false, false); - - wrapper.setContext({ admin: new Admin(managerAll) }); - - expectRole([], "manager-nypl", false, true); - expectRole(systemAdmin, "manager-nypl", true, false); - expectRole(managerAll, "manager-nypl", true, true); - expectRole(bothManager, "manager-nypl", true, true); - expectRole(nyplManager, "manager-nypl", true, true); - expectRole(librarianAll, "manager-nypl", false, true); - expectRole(bplManager, "manager-nypl", false, true); - expectRole(nyplLibrarian, "manager-nypl", false, true); - - expectRole(systemAdmin, "manager-bpl", true, false); - expectRole(managerAll, "manager-bpl", true, true); - expectRole(bothManager, "manager-bpl", true, true); - expectRole(nyplManager, "manager-bpl", false, true); - expectRole(librarianAll, "manager-bpl", false, true); - expectRole(bplManager, "manager-bpl", true, true); - expectRole(nyplLibrarian, "manager-bpl", false, true); - }); - - it("renders librarian role for each library", () => { - expectRole([], "librarian-nypl", false); - expectRole(systemAdmin, "librarian-nypl", true); - expectRole(managerAll, "librarian-nypl", true); - expectRole(bothManager, "librarian-nypl", true); - expectRole(nyplManager, "librarian-nypl", true); - expectRole(librarianAll, "librarian-nypl", true); - expectRole(bplManager, "librarian-nypl", false); - expectRole(nyplLibrarian, "librarian-nypl", true); - expectRole(bplLibrarian, "librarian-nypl", false); - - expectRole(systemAdmin, "librarian-bpl", true); - expectRole(managerAll, "librarian-bpl", true); - expectRole(bothManager, "librarian-bpl", true); - expectRole(nyplManager, "librarian-bpl", false); - expectRole(librarianAll, "librarian-bpl", true); - expectRole(bplManager, "librarian-bpl", true); - expectRole(nyplLibrarian, "librarian-bpl", false); - expectRole(bplLibrarian, "librarian-bpl", true); - - wrapper.setContext({ admin: new Admin(nyplManager) }); - - expectRole([], "librarian-nypl", false, true); - expectRole(systemAdmin, "librarian-nypl", true, false); - expectRole(managerAll, "librarian-nypl", true, false); - expectRole(bothManager, "librarian-nypl", true, true); - expectRole(nyplManager, "librarian-nypl", true, true); - expectRole(librarianAll, "librarian-nypl", true, false); - expectRole(bplManager, "librarian-nypl", false, true); - expectRole(nyplLibrarian, "librarian-nypl", true, true); - expectRole(bplLibrarian, "librarian-nypl", false, true); - - expectRole(systemAdmin, "librarian-bpl", true, false); - expectRole(managerAll, "librarian-bpl", true, false); - expectRole(bothManager, "librarian-bpl", true, false); - expectRole(nyplManager, "librarian-bpl", false, false); - expectRole(librarianAll, "librarian-bpl", true, false); - expectRole(bplManager, "librarian-bpl", true, false); - expectRole(nyplLibrarian, "librarian-bpl", false, false); - expectRole(bplLibrarian, "librarian-bpl", true, false); - - wrapper.setContext({ admin: new Admin(managerAll) }); - - expectRole([], "librarian-nypl", false, true); - expectRole(systemAdmin, "librarian-nypl", true, false); - expectRole(managerAll, "librarian-nypl", true, true); - expectRole(bothManager, "librarian-nypl", true, true); - expectRole(nyplManager, "librarian-nypl", true, true); - expectRole(librarianAll, "librarian-nypl", true, true); - expectRole(bplManager, "librarian-nypl", false, true); - expectRole(nyplLibrarian, "librarian-nypl", true, true); - expectRole(bplLibrarian, "librarian-nypl", false, true); - - expectRole(systemAdmin, "librarian-bpl", true, false); - expectRole(managerAll, "librarian-bpl", true, true); - expectRole(bothManager, "librarian-bpl", true, true); - expectRole(nyplManager, "librarian-bpl", false, true); - expectRole(librarianAll, "librarian-bpl", true, true); - expectRole(bplManager, "librarian-bpl", true, true); - expectRole(nyplLibrarian, "librarian-bpl", false, true); - expectRole(bplLibrarian, "librarian-bpl", true, true); - }); - - it("does not render roles when setting up first admin", () => { - wrapper.setContext({ settingUp: true }); - for (const role of [ - "system", - "manager-all", - "librarian-all", - "manager-nypl", - "librarian-nypl", - ]) { - const input = editableInputByName(role); - expect(input.length).to.equal(0); - } - }); - }); - - it("requires the correct fields", () => { - // Both the email and password fields are required. - const required = wrapper.find(".required-field"); - expect(required.length).to.equal(2); - expect(wrapper.html()).not.to.contain("(Optional)"); - }); - }); - - describe("behavior", () => { - beforeEach(() => { - save = stub().returns( - new Promise((resolve) => resolve()) - ); - wrapper = mount( - , - { context: { admin: new Admin(systemAdmin) } } - ); - }); - - describe("roles", () => { - let input; - let adminDataWithRoles; - const allRoles = [ - "system", - "manager-all", - "librarian-all", - "manager-nypl", - "manager-bpl", - "librarian-nypl", - "librarian-bpl", - ]; - const expectRoles = (expected) => { - for (const role of allRoles) { - const shouldBeChecked = expected.includes(role); - expect(editableInputByName(role).props().checked).to.equal( - shouldBeChecked - ); - } - }; - - it("changes system admin role", () => { - adminDataWithRoles = Object.assign({}, adminData, { - roles: nyplManagerLibrarianAll, - }); - wrapper.setProps({ item: adminDataWithRoles }); - - input = editableInputByName("system"); - input.find("input").simulate("change"); - expectRoles(allRoles); - - input.find("input").simulate("change"); - expectRoles([]); - }); - - it("changes manager all role", () => { - input = editableInputByName("manager-all"); - input.find("input").simulate("change"); - expectRoles([ - "manager-all", - "librarian-all", - "manager-nypl", - "manager-bpl", - "librarian-nypl", - "librarian-bpl", - ]); - - input.find("input").simulate("change"); - expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: systemAdmin, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: nyplManagerLibrarianAll, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles([ - "manager-all", - "librarian-all", - "manager-nypl", - "manager-bpl", - "librarian-nypl", - "librarian-bpl", - ]); - }); - - it("changes librarian all role", () => { - input = editableInputByName("librarian-all"); - input.find("input").simulate("change"); - expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); - - input.find("input").simulate("change"); - expectRoles([]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: systemAdmin, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles([]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: nyplManagerLibrarianAll, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["manager-nypl", "librarian-nypl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: nyplLibrarian, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); - }); - - it("changes manager role for each library", () => { - const role = "manager-nypl"; - input = editableInputByName(role); - input.find("input").simulate("change"); - expectRoles(["manager-nypl", "librarian-nypl"]); - - input.find("input").simulate("change"); - expectRoles(["librarian-nypl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: systemAdmin, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles([ - "librarian-all", - "manager-bpl", - "librarian-nypl", - "librarian-bpl", - ]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: managerAll, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles([ - "librarian-all", - "manager-bpl", - "librarian-nypl", - "librarian-bpl", - ]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: nyplManagerLibrarianAll, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: nyplLibrarian, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["manager-nypl", "librarian-nypl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: bplLibrarian, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["manager-nypl", "librarian-nypl", "librarian-bpl"]); - }); - - it("changes librarian role for each library", () => { - input = editableInputByName("librarian-nypl"); - input.find("input").simulate("change"); - expectRoles(["librarian-nypl"]); - - input.find("input").simulate("change"); - expectRoles([]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: systemAdmin, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["manager-bpl", "librarian-bpl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: managerAll, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["manager-bpl", "librarian-bpl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: nyplManagerLibrarianAll, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["librarian-bpl"]); - - adminDataWithRoles = Object.assign({}, adminData, { - roles: bplLibrarian, - }); - wrapper.setProps({ item: adminDataWithRoles }); - input.find("input").simulate("change"); - expectRoles(["librarian-nypl", "librarian-bpl"]); - }); - }); - - it("calls save when the save button is clicked", () => { - const saveButton = wrapper.find(Button); - saveButton.simulate("click"); - expect(save.callCount).to.equal(1); - }); - - it("calls save when the form is submitted directly", () => { - wrapper.find(Form).props().onSubmit(); - expect(save.callCount).to.equal(1); - }); - - const fillOutFormFields = () => { - // Filling out the form is the preliminary step for the next 5 tests; - // might as well write it out just once! - - const emailInput = wrapper.find("input[name='email']"); - const emailInputElement = emailInput.getDOMNode(); - emailInputElement.value = "newEmail"; - emailInput.simulate("change"); - - const pwInput = wrapper.find("input[name='password']"); - const pwInputElement = pwInput.getDOMNode(); - pwInputElement.value = "newPassword"; - pwInput.simulate("change"); - }; - - it("calls handleData", () => { - const handleData = spy(wrapper.instance(), "handleData"); - fillOutFormFields(); - - wrapper.find(Form).find(Button).simulate("click"); - - expect(handleData.args[0][0].get("email")).to.equal("newEmail"); - expect(handleData.args[0][0].get("password")).to.equal("newPassword"); - expect(handleData.callCount).to.equal(1); - handleData.restore(); - }); - - it("submits data", () => { - fillOutFormFields(); - const librarianAllInput = editableInputByName("librarian-all"); - librarianAllInput.find("input").simulate("change"); - const managerNyplInput = editableInputByName("manager-nypl"); - managerNyplInput.find("input").simulate("change"); - - const saveButton = wrapper.find(Button); - saveButton.simulate("click"); - - const formData = save.args[0][0]; - expect(formData.get("email")).to.equal("newEmail"); - expect(formData.get("password")).to.equal("newPassword"); - expect(formData.get("roles")).to.equal( - JSON.stringify([ - { role: "librarian-all" }, - { role: "manager", library: "nypl" }, - ]) - ); - expect(save.callCount).to.equal(1); - }); - - it("clears the form", () => { - fillOutFormFields(); - let emailInput = wrapper.find("input[name='email']"); - expect(emailInput.props().value).to.contain("newEmail"); - - const newProps = { responseBody: "new admin", ...wrapper.props() }; - wrapper.setProps(newProps); - - emailInput = wrapper.find("input[name='email']"); - expect(emailInput.props().value).not.to.contain("newEmail"); - }); - - it("doesn't clear the form if there's an error message", () => { - fillOutFormFields(); - const emailInput = wrapper.find("input[name='email']"); - expect(emailInput.props().value).to.contain("newEmail"); - - const newProps = { fetchError: "ERROR", ...wrapper.props() }; - wrapper.setProps(newProps); - - expect(emailInput.props().value).to.contain("newEmail"); - }); - - it("submits system admin when setting up", () => { - wrapper.setContext({ settingUp: true }); - fillOutFormFields(); - - const saveButton = wrapper.find(Button); - saveButton.simulate("click"); - - expect(save.callCount).to.equal(1); - const formData = save.args[0][0]; - expect(formData.get("email")).to.equal("newEmail"); - expect(formData.get("password")).to.equal("newPassword"); - expect(formData.get("roles")).to.equal( - JSON.stringify([{ role: "system" }]) - ); - }); - }); -}); diff --git a/src/components/__tests__/IndividualAdmins-test.tsx b/src/components/__tests__/IndividualAdmins-test.tsx deleted file mode 100644 index f51392198c..0000000000 --- a/src/components/__tests__/IndividualAdmins-test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { expect } from "chai"; -import * as PropTypes from "prop-types"; -import * as React from "react"; -import { mount } from "enzyme"; -import { IndividualAdmins } from "../IndividualAdmins"; - -describe("IndividualAdmins", () => { - let isSystemAdmin: boolean; - let isLibraryManager: boolean; - let wrapper; - beforeEach(() => { - isSystemAdmin = false; - isLibraryManager = false; - - wrapper = mount( - , - { - context: { - settingUp: true, - admin: { - isSystemAdmin: () => isSystemAdmin, - isLibraryManagerOfSomeLibrary: () => isLibraryManager, - }, - }, - childContextTypes: { settingUp: PropTypes.bool.isRequired }, - } - ); - }); - - it("shows welcome message on setup", () => { - const header = wrapper.find("h2"); - expect(header.text()).to.equal("Welcome!"); - }); - - it("shows setup message on setup", () => { - const formHeader = wrapper.find("h3"); - expect(formHeader.text()).to.equal("Set up your system admin account"); - }); - - it("shows the correct content when not setting up", () => { - wrapper.setProps({ settingUp: false }); - const header = wrapper.find("h2"); - const formHeader = wrapper.find("h3"); - expect(header.text()).to.equal("Individual admin configuration"); - expect(formHeader.text()).to.equal("Create a new individual admin"); - }); - - it("allows library managers to create items", () => { - isLibraryManager = true; - expect(wrapper.instance().canCreate()).to.equal(true); - }); - - it("allows system admins to delete items", () => { - isSystemAdmin = true; - expect(wrapper.instance().canDelete()).to.equal(true); - }); - - it("allows library managers to edit items", () => { - isLibraryManager = true; - expect(wrapper.instance().canEdit()).to.equal(true); - }); - - it("does not allow non-library managers to create items", () => { - expect(wrapper.instance().canCreate()).to.equal(false); - }); - - it("does not allow non-system admins to delete items", () => { - expect(wrapper.instance().canDelete()).to.equal(false); - - isLibraryManager = true; - expect(wrapper.instance().canDelete()).to.equal(false); - }); - - it("does not allow non-library managers to edit items", () => { - expect(wrapper.instance().canEdit()).to.equal(false); - }); -}); diff --git a/src/components/__tests__/LibraryRegistration-test.tsx b/src/components/__tests__/LibraryRegistration-test.tsx deleted file mode 100644 index 2701fb4655..0000000000 --- a/src/components/__tests__/LibraryRegistration-test.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import { expect } from "chai"; -import { stub } from "sinon"; - -import * as React from "react"; -import { shallow, mount } from "enzyme"; - -import LibraryRegistration from "../LibraryRegistration"; -import LibraryRegistrationForm from "../LibraryRegistrationForm"; -import EditableInput from "../EditableInput"; - -describe("LibraryRegistration", () => { - let wrapper; - let save; - let registerLibrary; - const protocol = "protocol 1"; - const serviceData = { - id: 1, - protocol, - }; - const protocolsData = [ - { - name: protocol, - label: "protocol 1 label", - supports_registration: true, - supports_staging: true, - settings: [], - library_settings: [], - }, - ]; - const allLibraries = [ - { short_name: "nypl", name: "New York Public Library", uuid: "1" }, - { short_name: "bpl", name: "Brooklyn Public Library", uuid: "2" }, - { short_name: "qpl", name: "Queens Public Library", uuid: "3" }, - ]; - const libraryRegistrationsData = [ - { - id: 1, - libraries: [ - { short_name: "nypl", status: "success", stage: "production" }, - { short_name: "bpl", status: "failure", stage: "testing" }, - ], - }, - ]; - const servicesData = { - discovery_services: [serviceData], - protocols: protocolsData, - allLibraries: allLibraries, - libraryRegistrations: libraryRegistrationsData, - }; - - describe("rendering", () => { - beforeEach(() => { - save = stub(); - registerLibrary = stub(); - wrapper = shallow( - - ); - }); - - it("doesn't render libraries in create form", () => { - const libraries = wrapper.find(".service-with-registrations-library"); - expect(libraries.length).to.equal(0); - }); - - it("doesn't render libraries in edit form if protocol doesn't support registration", () => { - const protocolWithoutRegistration = Object.assign({}, protocolsData[0], { - supports_registration: false, - }); - const servicesDataWithoutRegistration = { - discovery_services: [serviceData], - protocols: [protocolWithoutRegistration], - allLibraries: allLibraries, - }; - wrapper = mount( - - ); - - const libraries = wrapper.find(".service-with-registrations-library"); - expect(libraries.length).to.equal(0); - }); - - it("renders all libraries in edit form, with registration status", () => { - wrapper.setProps({ item: serviceData }); - const libraries = wrapper.find(".service-with-registrations-library"); - expect(libraries.length).to.equal(3); - expect(libraries.at(0).text()).to.contain("New York Public Library"); - expect(libraries.at(0).html()).to.contain("Registered"); - expect(libraries.at(1).text()).to.contain("Brooklyn Public Library"); - expect(libraries.at(1).html()).to.contain("Registration failed"); - expect(libraries.at(2).text()).to.contain("Queens Public Library"); - expect(libraries.at(2).html()).to.contain("Not registered"); - }); - - it("provides links to the libraries' edit forms", () => { - wrapper.setProps({ item: serviceData }); - const libraries = wrapper.find(".service-with-registrations-library"); - expect(libraries.length).to.equal(3); - - const link1 = libraries.at(0).find("a"); - const link2 = libraries.at(1).find("a"); - const link3 = libraries.at(2).find("a"); - - expect(link1.props().href).to.equal("/admin/web/config/libraries/edit/1"); - expect(link2.props().href).to.equal("/admin/web/config/libraries/edit/2"); - expect(link3.props().href).to.equal("/admin/web/config/libraries/edit/3"); - }); - - describe("rendering registration information", () => { - beforeEach(() => { - save = stub(); - registerLibrary = stub(); - wrapper = shallow( - - ); - }); - - it("should not render the staging dropdown if it is not supported", () => { - servicesData.protocols[0].supports_staging = false; - - wrapper = mount( - - ); - - const libraryRegistrationInfo = wrapper.find( - ".library-registration-info" - ); - const nypl = libraryRegistrationInfo.at(0); - const bpl = libraryRegistrationInfo.at(1); - const qpl = libraryRegistrationInfo.at(2); - - const nyplCurrentStage = nypl.find(".current-stage"); - const bplCurrentStage = bpl.find(".current-stage"); - const qplCurrentStage = qpl.find(".current-stage"); - const nyplEditableInput = nypl.find(EditableInput); - const bplEditableInput = bpl.find(EditableInput); - const qplEditableInput = qpl.find(EditableInput); - - // The dropdown to update the current staging level should not be rendered - expect(nyplCurrentStage.length).to.equal(0); - expect(bplCurrentStage.length).to.equal(0); - expect(qplCurrentStage.length).to.equal(0); - expect(nyplEditableInput.length).to.equal(0); - expect(bplEditableInput.length).to.equal(0); - expect(qplEditableInput.length).to.equal(0); - - // Registration status and button should still appear - const nyplRegistrationStatus = nypl.find("span"); - const bplRegistrationStatus = bpl.find("span"); - const qplRegistrationStatus = bpl.find("span"); - - const nyplRegistrationStatusBtn = nypl.find("button"); - const bplRegistrationStatusBtn = bpl.find("button"); - const qplRegistrationStatusBtn = bpl.find("button"); - - expect(nyplRegistrationStatus.text()).to.equal("Registered"); - expect(nyplRegistrationStatus.prop("className")).to.equal("bg-success"); - expect(bplRegistrationStatus.text()).to.equal("Registration failed"); - expect(bplRegistrationStatus.prop("className")).to.equal("bg-danger"); - expect(qplRegistrationStatus.text()).to.equal("Registration failed"); - expect(qplRegistrationStatus.prop("className")).to.equal("bg-danger"); - - expect(nyplRegistrationStatusBtn.length).to.equal(1); - expect(nyplRegistrationStatusBtn.text()).to.equal( - "Update registration" - ); - expect(bplRegistrationStatusBtn.length).to.equal(1); - expect(bplRegistrationStatusBtn.text()).to.equal("Retry registration"); - expect(qplRegistrationStatusBtn.length).to.equal(1); - expect(qplRegistrationStatusBtn.text()).to.equal("Retry registration"); - - servicesData.protocols[0].supports_staging = true; - }); - - it("should render the current registration stage", () => { - // Adding an additional library to display the currect stage when - // they are in the "testing" stage and their registration was - // successful, unlike being in the "testing" stage and - // an unsuccessful registration. - const libraryRegistrationsDataWBoston = [ - { - id: 1, - libraries: [ - { short_name: "nypl", status: "success", stage: "production" }, - { short_name: "bpl", status: "failure", stage: "testing" }, - { short_name: "boston", status: "success", stage: "testing" }, - ], - }, - ]; - const allLibrariesWBoston = [ - { short_name: "nypl", name: "New York Public Library", uuid: "1" }, - { short_name: "bpl", name: "Brooklyn Public Library", uuid: "2" }, - { short_name: "qpl", name: "Queens Public Library", uuid: "3" }, - { short_name: "boston", name: "Boston Public Library", uuid: "4" }, - ]; - servicesData.allLibraries = allLibrariesWBoston; - servicesData.libraryRegistrations = libraryRegistrationsDataWBoston; - - save = stub(); - registerLibrary = stub(); - wrapper = mount( - - ); - const libraryRegistrationInfo = wrapper.find( - ".library-registration-info" - ); - - const nypl = libraryRegistrationInfo.at(0); - const bpl = libraryRegistrationInfo.at(1); - const qpl = libraryRegistrationInfo.at(2); - const bostonpl = libraryRegistrationInfo.at(3); - - const nyplCurrentStage = nypl.find(".current-stage span"); - const bplCurrentStage = bpl.find(".current-stage span"); - const qplCurrentStage = qpl.find(".current-stage span"); - const bostonCurrentStage = bostonpl.find(".current-stage span"); - - expect(nyplCurrentStage.text()).to.equal("Current Stage: production"); - expect(bplCurrentStage.text()).to.equal("No current stage"); - expect(qplCurrentStage.text()).to.equal("No current stage"); - expect(bostonCurrentStage.text()).to.equal("Current Stage: testing"); - }); - - it("should render a registration stage type dropdown with two options", () => { - const libraryRegistrationInfo = wrapper.find( - ".library-registration-info" - ); - const bpl = libraryRegistrationInfo.at(1); - const bplEditableInput = bpl.find(EditableInput); - const options = bplEditableInput.find("option"); - - expect(options.length).to.equal(2); - expect(options.at(0).text()).to.equal("Testing"); - expect(options.at(1).text()).to.equal("Production"); - }); - - it("should render a registration stage select if the library is not in production", () => { - const libraryRegistrationInfo = wrapper.find( - ".library-registration-info" - ); - - const nypl = libraryRegistrationInfo.at(0); - const bpl = libraryRegistrationInfo.at(1); - const qpl = libraryRegistrationInfo.at(2); - - const nyplEditableInput = nypl.find(EditableInput); - const bplEditableInput = bpl.find(EditableInput); - const qplEditableInput = qpl.find(EditableInput); - - // Once a library is registered in production, there's no - // backing out of it so remove the dropdown. - expect(nyplEditableInput.length).to.equal(0); - expect(bplEditableInput.length).to.equal(1); - expect(qplEditableInput.length).to.equal(1); - }); - - it("should render a form", () => { - const forms = wrapper.find(LibraryRegistrationForm); - - const nyplForm = forms.at(0); - expect(nyplForm.prop("library").name).to.equal(allLibraries[0].name); - expect(nyplForm.prop("checked")).to.be.true; - expect(nyplForm.prop("buttonText")).to.equal("Update registration"); - - const bplForm = forms.at(1); - expect(bplForm.prop("library").name).to.equal(allLibraries[1].name); - expect(bplForm.prop("checked")).to.be.false; - expect(bplForm.prop("buttonText")).to.equal("Retry registration"); - - const qplForm = forms.at(2); - expect(qplForm.prop("library").name).to.equal(allLibraries[2].name); - expect(qplForm.prop("checked")).to.be.false; - expect(qplForm.prop("buttonText")).to.equal("Register"); - }); - }); - }); - - describe("behavior", () => { - beforeEach(() => { - save = stub(); - registerLibrary = stub(); - wrapper = mount( - - ); - }); - - it("registers a library", () => { - const libraries = wrapper.find(".service-with-registrations-library"); - expect(registerLibrary.callCount).to.equal(0); - const nyplButton = libraries.at(0).find("button"); - nyplButton.simulate("click"); - - expect(registerLibrary.callCount).to.equal(1); - - const bplButton = libraries.at(1).find("button"); - bplButton.simulate("click"); - - expect(registerLibrary.callCount).to.equal(2); - - const qplButton = libraries.at(2).find("button"); - qplButton.simulate("click"); - - expect(registerLibrary.callCount).to.equal(3); - }); - - it("should update the registration stage type in the state from the dropdown", async () => { - save = stub(); - registerLibrary = stub(); - wrapper = mount( - - ); - const libraryRegistrationInfo = wrapper.find( - ".library-registration-info" - ); - const bpl = libraryRegistrationInfo.at(1); - const bplEditableInput = bpl.find(EditableInput); - - expect(wrapper.state().registration_stage.bpl).to.equal(undefined); - expect(bplEditableInput.props().value).to.equal("testing"); - - bplEditableInput - .props() - .onChange({ short_name: "bpl", name: "Brooklyn Public Library" }); - - expect(wrapper.state().registration_stage.bpl).to.equal("testing"); - expect(bplEditableInput.props().value).to.equal("testing"); - }); - - it("should update the state stage value when using the dropdown per library", () => { - save = stub(); - registerLibrary = stub(); - wrapper = mount( - - ); - - const libraryRegistrationInfo = wrapper.find( - ".library-registration-info" - ); - const bpl = libraryRegistrationInfo.at(1); - const bplSelect = bpl.find("select"); - const bplSelectElement = bplSelect.getDOMNode(); - const qpl = libraryRegistrationInfo.at(2); - const qplSelect = qpl.find("select"); - const qplSelectElement = qplSelect.getDOMNode(); - - expect(wrapper.state().registration_stage).to.eql({}); - - bplSelectElement.value = "testing"; - bplSelect.simulate("change"); - - expect(wrapper.state().registration_stage).to.eql({ - bpl: "testing", - }); - - bplSelectElement.value = "production"; - bplSelect.simulate("change"); - - expect(wrapper.state().registration_stage).to.eql({ - bpl: "production", - }); - - qplSelectElement.value = "testing"; - qplSelect.simulate("change"); - - expect(wrapper.state().registration_stage).to.eql({ - bpl: "production", - qpl: "testing", - }); - - qplSelectElement.value = "production"; - qplSelect.simulate("change"); - - expect(wrapper.state().registration_stage).to.eql({ - bpl: "production", - qpl: "production", - }); - }); - }); -}); diff --git a/src/components/__tests__/NeighborhoodAnalyticsForm-test.tsx b/src/components/__tests__/NeighborhoodAnalyticsForm-test.tsx deleted file mode 100644 index 6c2457187a..0000000000 --- a/src/components/__tests__/NeighborhoodAnalyticsForm-test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { expect } from "chai"; -import * as React from "react"; -import { mount } from "enzyme"; - -import EditableInput from "../EditableInput"; -import NeighborhoodAnalyticsForm from "../NeighborhoodAnalyticsForm"; -import { Panel } from "library-simplified-reusable-components"; - -describe("NeighborhoodAnalyticsForm", () => { - let wrapper; - let patronAuthSetting; - let analyticsSetting; - - beforeEach(() => { - patronAuthSetting = { - key: "neighborhood_mode", - label: "Patron Auth", - options: [ - { key: "disabled", label: "Off" }, - { key: "choice1", label: "Choice 1" }, - { key: "choice2", label: "Choice 2" }, - ], - }; - analyticsSetting = { - key: "location_source", - label: "Analytics", - options: [ - { key: "disabled", label: "Off" }, - { key: "choice1", label: "Choice 1" }, - { key: "choice2", label: "Choice 2" }, - ], - }; - wrapper = mount(); - }); - - const chooseOption = (option: string) => { - const select = wrapper.find("select") as any; - const selectElement = select.getDOMNode(); - selectElement.value = option; - select.simulate("change"); - wrapper.update(); - }; - - it("renders a panel", () => { - const panel = wrapper.find(Panel); - expect(panel.length).to.equal(1); - expect(panel.prop("headerText")).to.contain( - "Patron Neighborhood Analytics" - ); - }); - - it("renders an EditableInput component", () => { - const input = wrapper.find(EditableInput); - expect(input.length).to.equal(1); - const select = input.find("select"); - expect(select.length).to.equal(1); - select.find("option").map((o, idx) => { - expect(o.prop("value")).to.equal( - wrapper.prop("setting").options[idx].key - ); - expect(o.text()).to.equal(wrapper.prop("setting").options[idx].label); - }); - }); - - it("renders a warning message", () => { - let warning = wrapper.find(".bg-warning"); - expect(warning.length).to.equal(0); - chooseOption("disabled"); - warning = wrapper.find(".bg-warning"); - expect(warning.length).to.equal(0); - chooseOption("choice1"); - warning = wrapper.find(".bg-warning"); - expect(warning.length).to.equal(1); - expect(warning.text()).to.equal( - "This feature will work only if it is also enabled in your local analytics service configuration settings." - ); - expect(warning.find("a").prop("href")).to.equal( - "/admin/web/config/analytics" - ); - - wrapper.setProps({ setting: analyticsSetting }); - warning = wrapper.find(".bg-warning"); - expect(warning.length).to.equal(1); - expect(warning.text()).to.equal( - "This feature will work only if it is also enabled in your patron authentication service configuration settings." - ); - expect(warning.find("a").prop("href")).to.equal( - "/admin/web/config/patronAuth" - ); - }); - - it("updates the state when an option is chosen", () => { - expect(wrapper.state()["selected"]).to.equal(""); - chooseOption("choice1"); - expect(wrapper.state()["selected"]).to.equal("choice1"); - }); - - it("updates the state to match the currentValue prop", () => { - wrapper = mount( - - ); - expect(wrapper.state()["selected"]).to.equal("choice1"); - }); - - it("tells the user whether the feature is currently enabled", () => { - let headerText = wrapper.find(".panel-heading span").text(); - expect(headerText).to.equal("Patron Neighborhood Analytics: Disabled"); - chooseOption("choice1"); - headerText = wrapper.find(".panel-heading span").text(); - expect(headerText).to.equal("Patron Neighborhood Analytics: Enabled"); - chooseOption("choice2"); - headerText = wrapper.find(".panel-heading span").text(); - expect(headerText).to.equal("Patron Neighborhood Analytics: Enabled"); - chooseOption("disabled"); - headerText = wrapper.find(".panel-heading span").text(); - expect(headerText).to.equal("Patron Neighborhood Analytics: Disabled"); - }); - - it("determines whether the feature is currently enabled", () => { - expect(wrapper.instance().isEnabled()).not.to.be.true; - chooseOption("choice1"); - expect(wrapper.instance().isEnabled()).to.be.true; - chooseOption("choice2"); - expect(wrapper.instance().isEnabled()).to.be.true; - chooseOption("disabled"); - expect(wrapper.instance().isEnabled()).not.to.be.true; - }); - - it("provides the name of the paired service", () => { - expect(wrapper.instance().getPairedService(patronAuthSetting)).to.eql([ - "/admin/web/config/analytics", - "local analytics service configuration settings", - ]); - expect(wrapper.instance().getPairedService(analyticsSetting)).to.eql([ - "/admin/web/config/patronAuth", - "patron authentication service configuration settings", - ]); - }); - - it("returns the value of the currently selected option", () => { - expect(wrapper.instance().getValue()).to.equal(""); - chooseOption("choice1"); - expect(wrapper.instance().getValue()).to.equal("choice1"); - chooseOption("choice2"); - expect(wrapper.instance().getValue()).to.equal("choice2"); - }); -}); diff --git a/src/components/__tests__/TextWithEditMode-test.tsx b/src/components/__tests__/TextWithEditMode-test.tsx deleted file mode 100644 index 1bc1c3b1f8..0000000000 --- a/src/components/__tests__/TextWithEditMode-test.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { expect } from "chai"; -import { spy, stub } from "sinon"; - -import * as React from "react"; -import { mount } from "enzyme"; -import { Button } from "library-simplified-reusable-components"; - -import TextWithEditMode from "../TextWithEditMode"; -import EditableInput from "../EditableInput"; - -describe("TextWithEditMode", () => { - let onUpdate; - - beforeEach(() => { - onUpdate = stub(); - }); - - it("renders text", () => { - const wrapper = mount( - - ); - expect(wrapper.text()).to.contain("test"); - expect(wrapper.text()).to.contain("Edit editable thing"); - - wrapper.setProps({ text: "other text" }); - expect(wrapper.text()).not.to.contain("test"); - expect(wrapper.text()).to.contain("other text"); - }); - - it("starts in edit mode if there's no text", () => { - const wrapper = mount( - - ); - const input = wrapper.find(EditableInput); - expect(input.length).to.equal(1); - expect(input.prop("placeholder")).to.equal("editable thing"); - expect(input.prop("value")).to.equal(""); - const button = wrapper.find(Button); - expect(button.text()).to.contain("Save editable thing"); - }); - - it("switches to edit mode", () => { - const wrapper = mount( - - ); - let input = wrapper.find(EditableInput); - expect(input.length).to.equal(0); - const editButton = wrapper.find(Button); - editButton.simulate("click"); - input = wrapper.find(EditableInput); - expect(input.length).to.equal(1); - expect(input.prop("placeholder")).to.equal("editable thing"); - expect(input.prop("value")).to.equal("test"); - - const saveButton = wrapper.find(Button); - expect(saveButton.text()).to.contain("Save"); - expect(saveButton.text()).not.to.contain("Edit"); - }); - - it("switches out of edit mode", () => { - const wrapper = mount( - - ); - let input = wrapper.find(EditableInput); - expect(input.length).to.equal(1); - - const getValueStub = stub(EditableInput.prototype, "getValue").returns( - "new value" - ); - - const saveButton = wrapper.find(Button); - saveButton.simulate("click"); - input = wrapper.find(EditableInput); - expect(input.length).to.equal(0); - expect(onUpdate.callCount).to.equal(1); - expect(onUpdate.args[0][0]).to.equal("new value"); - - expect(wrapper.text()).to.contain("new value"); - - const editButton = wrapper.find(Button); - expect(editButton.text()).to.contain("Edit"); - expect(editButton.text()).not.to.contain("Save"); - - getValueStub.restore(); - }); - - it("gets text", () => { - let wrapper = mount( - - ); - expect((wrapper.instance() as TextWithEditMode).getText()).to.equal("test"); - - // From edit mode, it returns the current input value and exists edit mode. - const getValueStub = stub(EditableInput.prototype, "getValue").returns( - "new value" - ); - wrapper = mount( - - ); - expect((wrapper.instance() as TextWithEditMode).getText()).to.equal( - "new value" - ); - wrapper.update(); - const input = wrapper.find(EditableInput); - expect(input.length).to.equal(0); - - getValueStub.restore(); - }); - - it("resets", () => { - const getValueStub = stub(EditableInput.prototype, "getValue").returns( - "new value" - ); - let wrapper = mount( - - ); - let saveButton = wrapper.find(Button); - saveButton.simulate("click"); - expect(wrapper.text()).to.contain("new value"); - expect(onUpdate.callCount).to.equal(1); - - (wrapper.instance() as TextWithEditMode).reset(); - wrapper.update(); - expect(wrapper.text()).not.to.contain("new value"); - const input = wrapper.find(EditableInput); - expect(input.length).to.equal(1); - expect(onUpdate.callCount).to.equal(2); - expect(onUpdate.args[1][0]).to.be.undefined; - - wrapper = mount( - - ); - const editButton = wrapper.find(Button); - editButton.simulate("click"); - saveButton = wrapper.find(Button); - saveButton.simulate("click"); - expect(wrapper.text()).not.to.contain("test"); - expect(wrapper.text()).to.contain("new value"); - expect(onUpdate.callCount).to.equal(3); - - (wrapper.instance() as TextWithEditMode).reset(); - wrapper.update(); - expect(wrapper.text()).not.to.contain("new value"); - expect(wrapper.text()).to.contain("test"); - expect(onUpdate.callCount).to.equal(4); - expect(onUpdate.args[3][0]).to.equal("test"); - - getValueStub.restore(); - }); - - it("optionally disables the save button if the text is blank", () => { - const wrapper = mount( - - ); - let button = wrapper.find("button"); - expect(button.prop("disabled")).to.be.true; - wrapper.setState({ text: "test" }); - button = wrapper.find("button"); - expect(button.prop("disabled")).not.to.be.true; - }); - - it("updates the text state", () => { - const wrapper = mount( - - ); - const spySetText = spy(wrapper.instance(), "setText"); - wrapper.setProps({ setText: spySetText }); - expect(spySetText.callCount).to.equal(0); - expect(wrapper.state().text).to.equal(""); - const input = wrapper.find("input"); - input.getDOMNode().value = "test"; - input.simulate("change"); - expect(spySetText.callCount).to.equal(1); - expect(spySetText.args[0][0]).to.equal("test"); - expect(wrapper.state().text).to.equal("test"); - spySetText.restore(); - }); -}); diff --git a/src/components/__tests__/ToolTip-test.tsx b/src/components/__tests__/ToolTip-test.tsx deleted file mode 100644 index 9dcf6e4b64..0000000000 --- a/src/components/__tests__/ToolTip-test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { expect } from "chai"; - -import * as React from "react"; -import { shallow } from "enzyme"; -import { spy } from "sinon"; -import ToolTip from "../ToolTip"; - -describe("ToolTip", () => { - let wrapper; - let trigger; - - beforeEach(() => { - trigger =

Hover

; - wrapper = shallow(); - }); - - it("renders the trigger element", () => { - const trigger = wrapper.find("h1"); - expect(trigger.length).to.equal(1); - expect(trigger.text()).to.equal("Hover"); - }); - - it("renders the tooltip", () => { - const tooltip = wrapper.find(".tool-tip"); - expect(tooltip.length).to.equal(1); - expect(tooltip.prop("dangerouslySetInnerHTML")["__html"]).to.equal( - "ToolTip Content" - ); - expect(tooltip.html()).to.include("ToolTip Content"); - }); - - it("initially hides the tooltip", () => { - expect(wrapper.state()["show"]).to.be.false; - expect(wrapper.find(".tool-tip").hasClass("hide")).to.be.true; - }); - - it("shows the tooltip on mouseEnter", () => { - const spyShowToolTip = spy(wrapper.instance(), "showToolTip"); - wrapper.setProps({ show: spyShowToolTip }); - - wrapper.simulate("mouseEnter"); - - expect(spyShowToolTip.callCount).to.equal(1); - expect(wrapper.find(".tool-tip").hasClass("hide")).to.be.false; - expect(wrapper.state()["show"]).to.be.true; - - spyShowToolTip.restore(); - }); - - it("hides the tooltip on mouseLeave", () => { - wrapper.setState({ show: true }); - expect(wrapper.find(".tool-tip").hasClass("hide")).to.be.false; - - const spyHideToolTip = spy(wrapper.instance(), "hideToolTip"); - wrapper.setProps({ hide: spyHideToolTip }); - - wrapper.simulate("mouseLeave"); - - expect(spyHideToolTip.callCount).to.equal(1); - expect(wrapper.find(".tool-tip").hasClass("hide")).to.be.true; - expect(wrapper.state()["show"]).to.be.false; - - spyHideToolTip.restore(); - }); -}); diff --git a/tests/jest/components/AnnouncementForm.test.tsx b/tests/jest/components/AnnouncementForm.test.tsx new file mode 100644 index 0000000000..1128d3b23a --- /dev/null +++ b/tests/jest/components/AnnouncementForm.test.tsx @@ -0,0 +1,202 @@ +import * as React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import AnnouncementForm from "../../../src/components/AnnouncementForm"; + +// 66 characters — within the 15-350 valid range. +const CONTENT = + "Here is some sample content which comes out to over 15 characters."; + +const getTextarea = () => screen.getByRole("textbox") as HTMLTextAreaElement; +const getDateInputs = (container: HTMLElement) => + Array.from( + container.querySelectorAll('input[type="date"]') + ); +const getCounter = () => screen.getByText(/Current length:/); + +// Enter valid content and a start/finish date. +const fillOutForm = async (container: HTMLElement) => { + const user = userEvent.setup(); + await user.type(getTextarea(), CONTENT); + const [start, finish] = getDateInputs(container); + // Date inputs are read via a ref, so `fireEvent.change` (set value + dispatch) + // reliably sets the value. + fireEvent.change(start, { target: { value: "2020-06-01" } }); + fireEvent.change(finish, { target: { value: "2020-07-01" } }); +}; + +describe("AnnouncementForm", () => { + let add: jest.Mock; + beforeEach(() => { + add = jest.fn(); + }); + + it("renders the input fields", () => { + const { container } = render(); + + // Content field: a textarea carrying the 15/350 length constraints. + const textarea = getTextarea(); + expect(textarea.tagName).toBe("TEXTAREA"); + expect(textarea).toHaveAttribute("minlength", "15"); + expect(textarea).toHaveAttribute("maxlength", "350"); + expect( + screen.getByText("New Announcement Text (15-350 characters)") + ).toBeInTheDocument(); + expect(screen.getByText("(Current length: 0/350)")).toBeInTheDocument(); + + // Two date fields, each with its label and description. + const dates = getDateInputs(container); + expect(dates).toHaveLength(2); + expect(screen.getByText("Start Date")).toBeInTheDocument(); + expect(screen.getByText(/If no start date is chosen/)).toBeInTheDocument(); + expect(screen.getByText("End Date")).toBeInTheDocument(); + expect( + screen.getByText(/If no expiration date is chosen/) + ).toBeInTheDocument(); + }); + + it("renders the buttons", () => { + render(); + expect(screen.getAllByRole("button")).toHaveLength(2); + expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + }); + + it("keeps track of whether the content is too short or too long", async () => { + const { container } = render(); + + // Empty content: 0/350, flagged as the wrong length, Add disabled. + expect(getCounter()).toHaveTextContent("(Current length: 0/350)"); + expect(getCounter().parentElement).toHaveClass("wrong-length"); + expect(screen.getByRole("button", { name: "Add" })).toBeDisabled(); + + // Valid content: length flag cleared and Add enabled. + await fillOutForm(container); + expect(getCounter()).toHaveTextContent("(Current length: 66/350)"); + expect(getCounter().parentElement).not.toHaveClass("wrong-length"); + expect(screen.getByRole("button", { name: "Add" })).not.toBeDisabled(); + + // Content at the 350-character cap is flagged as too long again. + const longString = + "Here's some extremely/gratuitously long content. The point of the content is just to get up to 350 characters so that I can test whether the class name will change. I realize even as I am typing this that it probably would have been a better idea to do it with filler text; oh well...? Anyway, I have now written the most boring announcement EVER."; + fireEvent.change(getTextarea(), { target: { value: longString } }); + expect(getCounter()).toHaveTextContent("(Current length: 350/350)"); + expect(getCounter().parentElement).toHaveClass("wrong-length"); + }); + + it("adds a new announcement", async () => { + const user = userEvent.setup(); + const { container } = render(); + await fillOutForm(container); + + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(add).toHaveBeenCalledTimes(1); + expect(add.mock.calls[0][0].content).toBe(CONTENT); + expect(add.mock.calls[0][0].start).toBe("2020-06-01"); + expect(add.mock.calls[0][0].finish).toBe("2020-07-01"); + }); + + it("cancels adding a new announcement", async () => { + const user = userEvent.setup(); + const { container } = render(); + const [start0, finish0] = getDateInputs(container); + const defaultStart = start0.value; + const defaultFinish = finish0.value; + + await fillOutForm(container); + expect(getTextarea()).toHaveValue(CONTENT); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + // Cancelling a brand-new announcement adds nothing and restores the + // empty content field and the default dates. + expect(add).not.toHaveBeenCalled(); + expect(getTextarea()).toHaveValue(""); + expect(getCounter()).toHaveTextContent("(Current length: 0/350)"); + const [start1, finish1] = getDateInputs(container); + expect(start1.value).toBe(defaultStart); + expect(finish1.value).toBe(defaultFinish); + }); + + it("edits an existing announcement", async () => { + const user = userEvent.setup(); + const { container, rerender } = render(); + const [start0, finish0] = getDateInputs(container); + const defaultStart = start0.value; + const defaultFinish = finish0.value; + + // Supplying an existing announcement's props switches the form to edit mode. + rerender( + + ); + + // The form is populated with the existing announcement (dates reformatted). + expect(getTextarea()).toHaveValue(CONTENT); + const [start1, finish1] = getDateInputs(container); + expect(start1.value).toBe("2020-07-01"); + expect(finish1.value).toBe("2020-08-01"); + + // Edit the content and save it. + fireEvent.change(getTextarea(), { + target: { value: "Here is an edited version of the content" }, + }); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(add).toHaveBeenCalledTimes(1); + expect(add.mock.calls[0][0].content).toBe( + "Here is an edited version of the content" + ); + + // The form resets to an empty content field and the default dates. + expect(getTextarea()).toHaveValue(""); + const [start2, finish2] = getDateInputs(container); + expect(start2.value).toBe(defaultStart); + expect(finish2.value).toBe(defaultFinish); + }); + + it("cancels editing an existing announcement", async () => { + const user = userEvent.setup(); + const { container, rerender } = render(); + const [start0, finish0] = getDateInputs(container); + const defaultStart = start0.value; + const defaultFinish = finish0.value; + + rerender( + + ); + + expect(getTextarea()).toHaveValue(CONTENT); + const [start1, finish1] = getDateInputs(container); + expect(start1.value).toBe("2020-07-01"); + expect(finish1.value).toBe("2020-08-01"); + + fireEvent.change(getTextarea(), { + target: { value: "Here is an edited version of the content" }, + }); + // Cancelling while editing re-submits the (edited) announcement rather than + // discarding it — unlike cancelling a brand-new one. + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(add).toHaveBeenCalledTimes(1); + expect(add.mock.calls[0][0].content).toBe( + "Here is an edited version of the content" + ); + + expect(getTextarea()).toHaveValue(""); + const [start2, finish2] = getDateInputs(container); + expect(start2.value).toBe(defaultStart); + expect(finish2.value).toBe(defaultFinish); + }); +}); diff --git a/tests/jest/components/AnnouncementsSection.test.tsx b/tests/jest/components/AnnouncementsSection.test.tsx new file mode 100644 index 0000000000..1bd6b2af78 --- /dev/null +++ b/tests/jest/components/AnnouncementsSection.test.tsx @@ -0,0 +1,140 @@ +import * as React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import AnnouncementsSection from "../../../src/components/AnnouncementsSection"; +import { SettingData } from "../../../src/interfaces"; + +const announcements = [ + { + content: "First Announcement", + start: "2020-05-12", + finish: "2020-06-12", + id: "perm_1", + }, + { + content: "Second Announcement", + start: "2020-05-28", + finish: "2020-06-28", + id: "perm_2", + }, +]; + +const setting: SettingData = { + description: "announcements", + format: "date-range", + key: "announcements", + label: "Announcements", + type: "list", +}; + +const renderSection = (ref?: React.RefObject) => + render( + ({ ...a }))} + /> + ); + +const announcementEls = (container: HTMLElement) => + Array.from(container.querySelectorAll(".announcement")); + +describe("AnnouncementsSection", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders a list of announcements", () => { + const { container } = renderSection(); + + expect( + screen.getByRole("heading", { name: "Scheduled Announcements:" }) + ).toBeInTheDocument(); + + const items = announcementEls(container); + expect(items).toHaveLength(2); + + // Sorted by start date: First (05/12) then Second (05/28). + expect( + within(items[0]).getByText("First Announcement") + ).toBeInTheDocument(); + expect( + within(items[0]).getByText("05/12/2020 – 06/12/2020") + ).toBeInTheDocument(); + expect( + within(items[1]).getByText("Second Announcement") + ).toBeInTheDocument(); + expect( + within(items[1]).getByText("05/28/2020 – 06/28/2020") + ).toBeInTheDocument(); + }); + + it("renders a form", () => { + const { container } = renderSection(); + expect(container.querySelectorAll(".announcement-form")).toHaveLength(1); + }); + + it("adds an announcement", async () => { + const user = userEvent.setup(); + const { container } = renderSection(); + expect(announcementEls(container)).toHaveLength(2); + + // The only textbox on the page is the form's content field. + await user.type(screen.getByRole("textbox"), "Third Announcement"); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(announcementEls(container)).toHaveLength(3); + expect(screen.getByText("Third Announcement")).toBeInTheDocument(); + }); + + it("edits an announcement", async () => { + const user = userEvent.setup(); + const { container } = renderSection(); + expect(announcementEls(container)).toHaveLength(2); + + // Each announcement has its own Edit button; edit the first one. + await user.click(screen.getAllByRole("button", { name: "Edit" })[0]); + + // The edited announcement leaves the list and is loaded into the form. + const items = announcementEls(container); + expect(items).toHaveLength(1); + expect( + within(items[0]).getByText("Second Announcement") + ).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("First Announcement"); + }); + + it("deletes an announcement", async () => { + const user = userEvent.setup(); + jest.spyOn(window, "confirm").mockReturnValue(true); + const { container } = renderSection(); + expect(announcementEls(container)).toHaveLength(2); + + await user.click(screen.getAllByRole("button", { name: "Delete" })[0]); + + const items = announcementEls(container); + expect(items).toHaveLength(1); + expect( + within(items[0]).getByText("Second Announcement") + ).toBeInTheDocument(); + }); + + it("removes any temporary IDs before submitting the list of announcements", async () => { + const user = userEvent.setup(); + const ref = React.createRef(); + renderSection(ref); + + // Add a third announcement through the form; it is assigned a temporary id. + await user.type(screen.getByRole("textbox"), "Third Announcement"); + await user.click(screen.getByRole("button", { name: "Add" })); + + // `getValue` is the imperative API the parent form calls on submit: it keeps + // the permanent ids and strips the temporary one. + const list = ref.current.getValue(); + expect(list[0].id).toBe("perm_1"); + expect(list[1].id).toBe("perm_2"); + expect(list[2].id).toBeUndefined(); + }); +}); diff --git a/tests/jest/components/ChangePasswordForm.test.tsx b/tests/jest/components/ChangePasswordForm.test.tsx new file mode 100644 index 0000000000..ef6a85cc09 --- /dev/null +++ b/tests/jest/components/ChangePasswordForm.test.tsx @@ -0,0 +1,154 @@ +import * as React from "react"; +import { installFormDataShim } from "../testUtils/formDataShim"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders } from "../testUtils/withProviders"; +import ConnectedChangePasswordForm, { + ChangePasswordForm, +} from "../../../src/components/ChangePasswordForm"; +// The reusable-components `Form` builds `new FormData(formElement)` on +// submit, which the unit jsdom env's undici FormData rejects; install the +// shared shim that reads the form's successful controls. +installFormDataShim(); +afterEach(() => { + jest.restoreAllMocks(); +}); + +// Render the unconnected class so tests can pass a `changePassword` spy and +// drive props directly. A separate test below renders the connected default +// export to cover the connect wiring (mapStateToProps/mapDispatchToProps). +const renderUnconnected = ( + props: Partial> = {} +) => + render( + + ); + +const passwordInput = (container: HTMLElement) => + container.querySelector('input[name="password"]') as HTMLInputElement; +const confirmInput = (container: HTMLElement) => + container.querySelector('input[name="confirm_password"]') as HTMLInputElement; + +describe("ChangePasswordForm", () => { + it("shows an error message when there is a fetch error", () => { + const { container, rerender } = renderUnconnected(); + expect(container.querySelector(".alert-danger")).not.toBeInTheDocument(); + + rerender( + + ); + + expect(container.querySelector(".alert-danger")).toBeInTheDocument(); + expect(screen.getByText("Error: response")).toBeInTheDocument(); + }); + + it("shows a loading indicator while fetching", () => { + const { rerender } = renderUnconnected({ isFetching: false }); + expect(screen.queryByText("Loading")).not.toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText("Loading")).toBeInTheDocument(); + }); + + it("renders the two labeled password inputs", () => { + const { container } = renderUnconnected(); + + expect(passwordInput(container)).toHaveAttribute("type", "password"); + expect(confirmInput(container)).toHaveAttribute("type", "password"); + + const labels = container.querySelectorAll("label.control-label"); + expect(labels[0]).toHaveTextContent("New Password"); + expect(labels[1]).toHaveTextContent("Confirm New Password"); + }); + + it("does not submit a blank or partially blank form", async () => { + const user = userEvent.setup(); + const changePassword = jest.fn().mockResolvedValue(undefined); + const { container } = renderUnconnected({ changePassword }); + const submit = screen.getByRole("button", { name: "Submit" }); + + // Both fields blank. + await user.click(submit); + expect(changePassword).not.toHaveBeenCalled(); + expect(screen.getByText("Fields cannot be blank.")).toBeInTheDocument(); + + // Only the first field filled. + await user.type(passwordInput(container), "newPassword"); + await user.click(submit); + expect(changePassword).not.toHaveBeenCalled(); + expect(screen.getByText("Fields cannot be blank.")).toBeInTheDocument(); + }); + + it("does not submit when the passwords do not match", async () => { + const user = userEvent.setup(); + const changePassword = jest.fn().mockResolvedValue(undefined); + const { container } = renderUnconnected({ changePassword }); + + await user.type(passwordInput(container), "newPassword"); + await user.type(confirmInput(container), "somethingElse"); + await user.click(screen.getByRole("button", { name: "Submit" })); + + expect(changePassword).not.toHaveBeenCalled(); + expect(screen.getByText("Passwords do not match.")).toBeInTheDocument(); + }); + + it("submits the new password and shows a success message", async () => { + const user = userEvent.setup(); + const changePassword = jest.fn().mockResolvedValue(undefined); + const { container } = renderUnconnected({ changePassword }); + + await user.type(passwordInput(container), "newPassword"); + await user.type(confirmInput(container), "newPassword"); + await user.click(screen.getByRole("button", { name: "Submit" })); + + expect(changePassword).toHaveBeenCalledTimes(1); + const submittedData = changePassword.mock.calls[0][0]; + expect(submittedData.get("password")).toBe("newPassword"); + expect(submittedData.get("confirm_password")).toBe("newPassword"); + + expect( + await screen.findByText("Password changed successfully") + ).toBeInTheDocument(); + }); + + it("dispatches the password change through the connected store", async () => { + const user = userEvent.setup(); + const fetchSpy = jest + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("ok", { status: 200 })); + + const { container } = renderWithProviders( + + ); + + await user.type(passwordInput(container), "newPassword"); + await user.type(confirmInput(container), "newPassword"); + await user.click(screen.getByRole("button", { name: "Submit" })); + + expect( + await screen.findByText("Password changed successfully") + ).toBeInTheDocument(); + expect(fetchSpy).toHaveBeenCalledWith( + "/admin/change_password", + expect.objectContaining({ method: "POST" }) + ); + }); +}); diff --git a/tests/jest/components/DiscoveryServices.test.tsx b/tests/jest/components/DiscoveryServices.test.tsx index 63b80012c6..b46205126a 100644 --- a/tests/jest/components/DiscoveryServices.test.tsx +++ b/tests/jest/components/DiscoveryServices.test.tsx @@ -1,19 +1,18 @@ import * as React from "react"; -import { fireEvent } from "@testing-library/react"; +import { installFormDataShim } from "../testUtils/formDataShim"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { DiscoveryServices } from "../../../src/components/DiscoveryServices"; +import DiscoveryServicesConnected from "../../../src/components/DiscoveryServices"; import renderWithContext from "../testUtils/renderWithContext"; +import { renderWithProviders } from "../testUtils/withProviders"; +import buildStore from "../../../src/store"; import { ConfigurationSettings, DiscoveryServicesData, } from "../../../src/interfaces"; import { defaultFeatureFlags } from "../../../src/utils/featureFlags"; -// NB: This adds tests to the already existing tests in: -// - `src/components/__tests__/DiscoveryServices-test.tsx`. -// -// Those tests should eventually be migrated here and -// adapted to the Jest/React Testing Library paradigm. - describe("DiscoveryServices - registered library disclosure", () => { // ── Shared fixtures ─────────────────────────────────────────────────────── @@ -543,3 +542,151 @@ describe("DiscoveryServices - registered library disclosure", () => { expect(container.querySelectorAll(".associated-items")).toHaveLength(0); }); }); + +// These exercise behavior the disclosure tests above do not: the connect() +// wiring (mapStateToProps / mapDispatchToProps, which only run when the CONNECTED +// default export mounts) and the getChildContext().registerLibrary closure +// (which only runs when a library is registered from the edit form). + +describe("DiscoveryServices - connected wiring and registration", () => { + const allLibraries = [ + { short_name: "nypl", name: "NYPL", uuid: "uuid-nypl" }, + ]; + + const sysAdminConfig: Partial = { + csrfToken: "", + featureFlags: defaultFeatureFlags, + roles: [{ role: "system" }], + }; + + // A loaded `libraries` slice persists through the on-mount discovery fetches + // (the libraries reducer ignores those actions), so mapStateToProps takes its + // `data.allLibraries` branch on every render. + const loadedLibrariesState = { + data: { libraries: allLibraries }, + isFetching: false, + isEditing: false, + fetchError: null, + formError: null, + isLoaded: true, + responseBody: null, + successMessage: null, + }; + + /** + * Stub `fetch` so the panel's on-mount fetches resolve with `body`. A fresh + * Response per call avoids "body already used" when several fetches run. + */ + const stubFetch = (body: unknown) => + jest.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("connected default export", () => { + it("wires mapStateToProps/mapDispatchToProps: fetches on mount and renders the list", async () => { + const listData = { + discovery_services: [ + { id: 2, protocol: "test protocol", name: "Test Discovery" }, + ], + protocols: [{ name: "test protocol", label: "TP", settings: [] }], + }; + stubFetch(listData); + // A fresh store, preloaded with a loaded libraries slice, isolates this + // test and exercises mapStateToProps' allLibraries branch. + const store = buildStore({ + editor: { libraries: loadedLibrariesState }, + } as any); + + const { container } = renderWithProviders( + , + { reduxProviderProps: { store } } + ); + + // The list appears once the connected component's on-mount fetch resolves + // and mapStateToProps feeds the fetched data back in as props. + expect( + await screen.findByRole("heading", { level: 3, name: "Test Discovery" }) + ).toBeInTheDocument(); + + const editLink = container.querySelector("a.edit-item"); + expect(editLink).not.toBeNull(); + expect(editLink.getAttribute("href")).toBe( + "/admin/web/config/discovery/edit/2" + ); + }); + }); + + describe("registerLibrary child context (edit mode)", () => { + // The reusable-components `Form` builds `new FormData(formElement)` on + // submit, which the unit jsdom env's undici FormData rejects; install the + // shared shim that reads the form's successful controls. + installFormDataShim(); + const renderEditMode = ( + registerLibrary: jest.Mock, + fetchLibraryRegistrations: jest.Mock + ) => + renderWithContext( + , + sysAdminConfig + ); + + it("fetches library registrations on mount, and registers a library (building the right FormData) and refetches on click", async () => { + const user = userEvent.setup(); + const registerLibrary = jest.fn().mockResolvedValue(undefined); + const fetchLibraryRegistrations = jest.fn().mockResolvedValue(undefined); + renderEditMode(registerLibrary, fetchLibraryRegistrations); + + // getChildContext supplies registerLibrary to the edit form; the edit form + // surfaces a "Register" button for each library. + expect(fetchLibraryRegistrations).toHaveBeenCalledTimes(1); + expect(registerLibrary).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Register" })); + + expect(registerLibrary).toHaveBeenCalledTimes(1); + const formData = registerLibrary.mock.calls[0][0]; + expect(formData.get("library_short_name")).toBe("nypl"); + expect(formData.get("integration_id")).toBe("2"); + expect(formData.get("registration_stage")).toBe("testing"); + + // After a successful registration, the child context refetches. + await waitFor(() => + expect(fetchLibraryRegistrations).toHaveBeenCalledTimes(2) + ); + }); + }); +}); diff --git a/tests/jest/components/EditableInput.test.tsx b/tests/jest/components/EditableInput.test.tsx index 06d32eadbe..2b0128b634 100644 --- a/tests/jest/components/EditableInput.test.tsx +++ b/tests/jest/components/EditableInput.test.tsx @@ -1,5 +1,6 @@ import * as React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import EditableInput from "../../../src/components/EditableInput"; describe("EditableInput", () => { @@ -62,3 +63,84 @@ describe("EditableInput", () => { expect(textboxes[5]).toHaveAccessibleDescription(""); }); }); + +// Value/change handling, the prop-sync in componentWillReceiveProps, and the +// imperative ref API (getValue/getChecked/setValue/clear) that parents like +// ProtocolFormField use. +describe("EditableInput - value, change, and imperative API", () => { + it("updates as the user types and reports the value via getValue()", async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const ref = React.createRef(); + render( + + ); + + const input = screen.getByRole("textbox", { name: "Field" }); + await user.type(input, "hello"); + + expect(input).toHaveValue("hello"); + expect(ref.current?.getValue()).toBe("hello"); + expect(onChange).toHaveBeenCalled(); + }); + + it("toggles a checkbox and reports it via getChecked()", async () => { + const user = userEvent.setup(); + const ref = React.createRef(); + render( + + ); + + const box = screen.getByRole("checkbox", { name: "Check" }); + await user.click(box); + + expect(box).toBeChecked(); + expect(ref.current?.getChecked()).toBe(true); + }); + + it("does not fire onChange when readOnly", () => { + const onChange = jest.fn(); + render(); + + // `userEvent.type` honors the DOM's read-only semantics and never dispatches + // a change event, so it would never reach handleChange. Dispatch directly, so + // the component's own readOnly guard is what stops onChange. + fireEvent.change(screen.getByRole("textbox", { name: "RO" }), { + target: { value: "y" }, + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("syncs its value when the value prop changes", () => { + const { rerender } = render(); + const input = screen.getByRole("textbox", { name: "F" }); + expect(input).toHaveValue("a"); + + rerender(); + expect(input).toHaveValue("b"); + }); + + it("syncs its checked state when the checked prop changes", () => { + const { rerender } = render( + + ); + const box = screen.getByRole("checkbox", { name: "C" }); + expect(box).not.toBeChecked(); + + rerender(); + expect(box).toBeChecked(); + }); + + it("setValue and clear update the value imperatively", () => { + const ref = React.createRef(); + render(); + const input = screen.getByRole("textbox", { name: "F" }); + + act(() => ref.current?.setValue("z")); + expect(input).toHaveValue("z"); + + act(() => ref.current?.clear()); + expect(input).toHaveValue(""); + }); +}); diff --git a/tests/jest/components/Header.test.tsx b/tests/jest/components/Header.test.tsx new file mode 100644 index 0000000000..361a1c6641 --- /dev/null +++ b/tests/jest/components/Header.test.tsx @@ -0,0 +1,421 @@ +import * as React from "react"; +import * as PropTypes from "prop-types"; +import { render, screen, within, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Provider } from "react-redux"; + +import { Header } from "../../../src/components/Header"; +import HeaderWithStore from "../../../src/components/Header"; +import Admin from "../../../src/models/Admin"; +import title from "../../../src/utils/title"; +import buildStore from "../../../src/store"; + +// Render react-router's Link as a marker `
` so we can read its target +// (`data-to`) and text without a real Router in context. (A bare `` without an +// href would fail jsx-a11y, so use a div.) +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + Link: (props) => ( +
+ {props.children} +
+ ), +})); + +// Header reads `library`, `router`, and `admin` from the legacy React context that +// the OPDS web client supplies in production. Supply them via a small legacy +// context provider (also carrying the `editorStore`/`csrfToken` the connected +// default export needs). +class LegacyContextProvider extends React.Component<{ context: any }> { + static childContextTypes = { + library: PropTypes.func, + router: PropTypes.object, + admin: PropTypes.object, + editorStore: PropTypes.object, + csrfToken: PropTypes.string, + }; + getChildContext() { + return this.props.context; + } + render() { + return this.props.children; + } +} + +const makeRouter = () => ({ + createHref: jest.fn(), + push: jest.fn(), + isActive: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + setRouteLeaveHook: jest.fn(), + getCurrentLocation: jest.fn(), +}); + +const libraryManager = new Admin([{ role: "manager", library: "nypl" }]); +const librarian = new Admin([{ role: "librarian", library: "nypl" }]); +const systemAdmin = new Admin([{ role: "system", library: "nypl" }]); + +type RenderHeaderOptions = { + // Pass `null` to model an absent `library` in context (a destructuring default + // cannot distinguish an omitted key from an explicit `undefined`, so `null` is + // the sentinel for "no library selected"). Header treats null/undefined alike. + library?: (() => string) | null; + admin?: Admin; + router?: ReturnType; + props?: Record; +}; + +const renderHeader = ({ + library = () => "nypl", + admin = libraryManager, + router = makeRouter(), + props = {}, +}: RenderHeaderOptions = {}) => + render( + +
+ + ); + +describe("Header", () => { + describe("rendering", () => { + it("renders the site title as an h1", () => { + renderHeader(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + title() + ); + }); + + it("shows the brand image", () => { + renderHeader(); + expect(screen.getByAltText(title())).toBeInTheDocument(); + }); + + it("shows no library dropdown when there are no libraries", () => { + renderHeader(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + }); + + it("shows a library dropdown listing the available libraries", () => { + const libraries = [ + { short_name: "nypl", name: "NYPL" }, + { short_name: "bpl" }, + ]; + renderHeader({ props: { libraries } }); + + const select = screen.getByRole("combobox"); + expect(select).toHaveValue("nypl"); + + const options = within(select).getAllByRole("option"); + expect(options).toHaveLength(2); + expect(options[0]).toHaveTextContent("NYPL"); + expect(options[0]).toHaveValue("nypl"); + expect(options[1]).toHaveTextContent("bpl"); + expect(options[1]).toHaveValue("bpl"); + }); + + it("reflects the selected library as the dropdown value", () => { + const libraries = [ + { short_name: "nypl", name: "NYPL" }, + { short_name: "bpl" }, + ]; + renderHeader({ library: () => "bpl", props: { libraries } }); + expect(screen.getByRole("combobox")).toHaveValue("bpl"); + }); + + it("prepends a 'Select a library' option when no library is selected", () => { + const libraries = [ + { short_name: "nypl", name: "NYPL" }, + { short_name: "bpl" }, + ]; + renderHeader({ library: null, props: { libraries } }); + + const options = within(screen.getByRole("combobox")).getAllByRole( + "option" + ); + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent("Select a library"); + expect(options[1]).toHaveTextContent("NYPL"); + expect(options[1]).toHaveValue("nypl"); + expect(options[2]).toHaveTextContent("bpl"); + expect(options[2]).toHaveValue("bpl"); + }); + + it("shows the catalog nav links when a library is selected", () => { + const { container } = renderHeader(); + + const catalogLinks = container.querySelectorAll( + 'a[href^="/admin/web/collection/"]' + ); + expect(catalogLinks).toHaveLength(2); + + const catalog = container.querySelector('a[href*="nypl%2Fgroups"]'); + expect(catalog).toHaveTextContent("Catalog"); + const hidden = container.querySelector( + 'a[href*="nypl%2Fadmin%2Fsuppressed"]' + ); + expect(hidden).toHaveTextContent("Hidden Books"); + }); + + it("hides the catalog nav links when no library is selected", () => { + const { container } = renderHeader({ library: null }); + expect( + container.querySelectorAll('a[href^="/admin/web/collection/"]') + ).toHaveLength(0); + }); + + it("shows dashboard, lists, lanes, and system configuration links for a library manager", () => { + renderHeader({ admin: libraryManager }); + + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(4); + expect(links[0]).toHaveTextContent("Dashboard"); + expect(links[0]).toHaveAttribute("data-to", "/admin/web/dashboard/nypl"); + expect(links[1]).toHaveTextContent("Lists"); + expect(links[1]).toHaveAttribute("data-to", "/admin/web/lists/nypl"); + expect(links[2]).toHaveTextContent("Lanes"); + expect(links[2]).toHaveAttribute("data-to", "/admin/web/lanes/nypl"); + expect(links[3]).toHaveTextContent("System Configuration"); + expect(links[3]).toHaveAttribute("data-to", "/admin/web/config/"); + }); + + it("shows only site-wide dashboard and system configuration links when no library is selected", () => { + renderHeader({ library: null, admin: libraryManager }); + + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(2); + expect(links[0]).toHaveTextContent("Dashboard"); + expect(links[0]).toHaveAttribute("data-to", "/admin/web/dashboard/"); + expect(links[1]).toHaveTextContent("System Configuration"); + expect(links[1]).toHaveAttribute("data-to", "/admin/web/config/"); + }); + + it("shows dashboard, lists, and system configuration links for a librarian", () => { + renderHeader({ admin: librarian }); + + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(3); + expect(links[0]).toHaveTextContent("Dashboard"); + expect(links[0]).toHaveAttribute("data-to", "/admin/web/dashboard/nypl"); + expect(links[1]).toHaveTextContent("Lists"); + expect(links[1]).toHaveAttribute("data-to", "/admin/web/lists/nypl"); + expect(links[2]).toHaveTextContent("System Configuration"); + expect(links[2]).toHaveAttribute("data-to", "/admin/web/config/"); + }); + + it("shows the account dropdown toggle when the admin has an email", () => { + const admin = new Admin( + [{ role: "librarian", library: "nypl" }], + "admin@nypl.org" + ); + const { container } = renderHeader({ admin }); + + const toggle = container.querySelector("button.account-dropdown-toggle"); + expect(toggle).toBeInTheDocument(); + expect(toggle).toHaveTextContent("admin@nypl.org"); + }); + + describe("patron manager display", () => { + it("does not show the Patrons link for a librarian", () => { + renderHeader({ admin: librarian }); + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(3); + expect(links.some((link) => link.textContent === "Patrons")).toBe( + false + ); + }); + + it("does not show the Patrons link for a library manager", () => { + renderHeader({ admin: libraryManager }); + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(4); + expect(links.some((link) => link.textContent === "Patrons")).toBe( + false + ); + }); + + it("shows the Patrons link for a system admin", () => { + renderHeader({ admin: systemAdmin }); + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(6); + expect(links[3]).toHaveTextContent("Patrons"); + }); + }); + + describe("troubleshooting display", () => { + it("does not show the Troubleshooting link for a librarian", () => { + renderHeader({ admin: librarian }); + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(3); + expect( + links.some((link) => link.textContent === "Troubleshooting") + ).toBe(false); + }); + + it("does not show the Troubleshooting link for a library manager", () => { + renderHeader({ admin: libraryManager }); + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(4); + expect( + links.some((link) => link.textContent === "Troubleshooting") + ).toBe(false); + }); + + it("shows the Troubleshooting link for a system admin", () => { + renderHeader({ admin: systemAdmin }); + const links = screen.getAllByTestId("Link"); + expect(links).toHaveLength(6); + expect(links[5]).toHaveTextContent("Troubleshooting"); + }); + }); + }); + + describe("behavior", () => { + it("fetches libraries on mount", () => { + const fetchLibraries = jest.fn(); + renderHeader({ admin: libraryManager, props: { fetchLibraries } }); + expect(fetchLibraries).toHaveBeenCalledTimes(1); + }); + + it("does not fetch libraries on mount if a fetch is already in progress", () => { + const fetchLibraries = jest.fn(); + renderHeader({ + admin: libraryManager, + props: { fetchLibraries, isFetchingLibraries: true }, + }); + expect(fetchLibraries).not.toHaveBeenCalled(); + }); + + it("navigates to the selected library's catalog when the dropdown changes", async () => { + const user = userEvent.setup(); + const router = makeRouter(); + const libraries = [ + { short_name: "nypl", name: "NYPL" }, + { short_name: "bpl" }, + ]; + renderHeader({ router, props: { libraries } }); + + await user.selectOptions(screen.getByRole("combobox"), "bpl"); + + expect(router.push).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith( + "/admin/web/collection/bpl%2Fgroups" + ); + }); + + it("toggles the account dropdown open and closed", async () => { + const user = userEvent.setup(); + const admin = new Admin( + [{ role: "librarian", library: "nypl" }], + "admin@nypl.org" + ); + const { container } = renderHeader({ admin }); + + expect( + container.querySelector("ul.dropdown-menu") + ).not.toBeInTheDocument(); + + const toggle = container.querySelector( + "button.account-dropdown-toggle" + ) as HTMLElement; + await user.click(toggle); + + const menu = container.querySelector("ul.dropdown-menu") as HTMLElement; + expect(menu).toBeInTheDocument(); + expect(menu.querySelectorAll("li")).toHaveLength(3); + expect(menu.querySelector("li.permissions")).toHaveTextContent( + "Logged in as a user" + ); + + // The change-password entry is a react-router Link (mocked to a marker div). + const changePassword = within(menu).getByTestId("Link"); + expect(changePassword).toHaveTextContent("Change password"); + expect(changePassword).toHaveAttribute("data-to", "/admin/web/account/"); + + // The sign-out entry is a real anchor. + const signOut = within(menu).getByRole("link", { name: "Sign out" }); + expect(signOut).toHaveAttribute("href", "/admin/sign_out"); + + await user.click(toggle); + expect( + container.querySelector("ul.dropdown-menu") + ).not.toBeInTheDocument(); + }); + + it("labels the permission level in the account dropdown", async () => { + const user = userEvent.setup(); + const openDropdownPermissions = async (admin: Admin) => { + const { container, unmount } = renderHeader({ admin }); + const toggle = container.querySelector( + "button.account-dropdown-toggle" + ) as HTMLElement; + await user.click(toggle); + const text = container.querySelector( + "ul.dropdown-menu li.permissions" + )?.textContent; + unmount(); + return text; + }; + + expect( + await openDropdownPermissions( + new Admin([{ role: "system", library: "nypl" }], "admin@nypl.org") + ) + ).toBe("Logged in as a system admin"); + expect( + await openDropdownPermissions( + new Admin([{ role: "manager", library: "nypl" }], "admin@nypl.org") + ) + ).toBe("Logged in as an administrator"); + expect( + await openDropdownPermissions( + new Admin([{ role: "librarian", library: "nypl" }], "admin@nypl.org") + ) + ).toBe("Logged in as a user"); + }); + }); + + describe("connected (default export)", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("wires libraries from the store and fetches them on mount", async () => { + // The connected default export reads `libraries`/`isFetchingLibraries` from + // the redux store and dispatches `fetchLibraries` on mount, which hits the + // network — stub fetch so mounting settles without a real request. + const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ libraries: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + const store = buildStore(); + + render( + + "nypl", + admin: libraryManager, + router: makeRouter(), + }} + > + + + + ); + + expect( + await screen.findByRole("heading", { level: 1 }) + ).toHaveTextContent(title()); + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(fetchSpy.mock.calls[0][0]).toContain("/admin/libraries"); + }); + }); +}); diff --git a/tests/jest/components/IndividualAdminEditForm.test.tsx b/tests/jest/components/IndividualAdminEditForm.test.tsx index 6686e8fb52..be52f389b5 100644 --- a/tests/jest/components/IndividualAdminEditForm.test.tsx +++ b/tests/jest/components/IndividualAdminEditForm.test.tsx @@ -1,9 +1,10 @@ import * as React from "react"; +import { installFormDataShim } from "../testUtils/formDataShim"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import IndividualAdminEditForm from "../../../src/components/IndividualAdminEditForm"; import renderWithContext from "../testUtils/renderWithContext"; -import { ConfigurationSettings } from "../../../src/interfaces"; +import { AdminRoleData, ConfigurationSettings } from "../../../src/interfaces"; describe("IndividualAdminEditForm", () => { it("clears the role checkboxes after save", async () => { @@ -126,3 +127,482 @@ describe("IndividualAdminEditForm", () => { expect(gammaUserCheckbox).not.toBeChecked(); }); }); + +// Assertions on props (checked/disabled/value) are made against the rendered +// checkboxes and inputs; role-change behavior is driven with real clicks. +describe("IndividualAdminEditForm - rendered inputs and role changes", () => { + // The reusable-components `Form` builds `new FormData(formElement)` on + // submit, which the unit jsdom env's undici FormData rejects; install the + // shared shim that reads the form's successful controls. + installFormDataShim(); + const adminData = { email: "test@nypl.org", password: "password" }; + const allLibraries = [ + { name: "NYPL", short_name: "nypl" }, + { name: "BPL", short_name: "bpl" }, + ]; + const baseData = { individualAdmins: [adminData], allLibraries }; + + const systemAdmin: AdminRoleData[] = [{ role: "system" }]; + const managerAll: AdminRoleData[] = [{ role: "manager-all" }]; + const librarianAll: AdminRoleData[] = [{ role: "librarian-all" }]; + const nyplManager: AdminRoleData[] = [{ role: "manager", library: "nypl" }]; + const bplManager: AdminRoleData[] = [{ role: "manager", library: "bpl" }]; + const nyplLibrarian: AdminRoleData[] = [ + { role: "librarian", library: "nypl" }, + ]; + const bplLibrarian: AdminRoleData[] = [{ role: "librarian", library: "bpl" }]; + const nyplManagerLibrarianAll: AdminRoleData[] = [ + { role: "manager", library: "nypl" }, + { role: "librarian-all" }, + ]; + + const configFor = ( + roles: AdminRoleData[], + settingUp = false + ): Partial => ({ + csrfToken: "", + featureFlags: {}, + roles, + settingUp, + }); + + const systemConfig = configFor(systemAdmin); + + let save: jest.Mock; + + const baseProps = (overrides: Record = {}) => ({ + data: baseData, + disabled: false, + save, + urlBase: "url base", + listDataKey: "admins", + ...overrides, + }); + + const renderForm = ( + overrides: Record = {}, + config: Partial = systemConfig + ) => + renderWithContext( + , + config + ); + + const roleNames: Record = { + system: /^system admin$/i, + "manager-all": /^administrator$/i, + "librarian-all": /^user$/i, + "manager-nypl": /administrator of nypl/i, + "manager-bpl": /administrator of bpl/i, + "librarian-nypl": /user of nypl/i, + "librarian-bpl": /user of bpl/i, + }; + const allRoles = Object.keys(roleNames); + + const roleCheckbox = (role: string) => + screen.getByRole("checkbox", { name: roleNames[role] }); + + const expectRoles = (expected: string[]) => { + for (const role of allRoles) { + if (expected.includes(role)) { + expect(roleCheckbox(role)).toBeChecked(); + } else { + expect(roleCheckbox(role)).not.toBeChecked(); + } + } + }; + + // A fresh item object each time, so the component's + // UNSAFE_componentWillReceiveProps resets its admin state from the new roles. + const setRoles = (rerender: (ui: React.ReactElement) => void, roles) => + rerender( + + ); + + const saveButton = (container: HTMLElement) => + container.querySelector("button:not(.panel-heading)"); + + beforeEach(() => { + save = jest.fn().mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + it("renders the email field, read-only with a value when editing an admin", () => { + const { container, rerender } = renderForm(); + let email = container.querySelector( + 'input[name="email"]' + ); + expect(email).not.toBeNull(); + expect(email.value).toBe(""); + + rerender( + + ); + email = container.querySelector('input[name="email"]'); + expect(email.value).toBe("test@nypl.org"); + expect(email.readOnly).toBe(true); + }); + + it("requires the email and password fields", () => { + const { container } = renderForm(); + expect(container.querySelectorAll(".required-field")).toHaveLength(2); + expect(container.innerHTML).not.toContain("(Optional)"); + }); + + it("shows the password field only when the editor may change it", () => { + const passwordShownFor = ( + targetRoles: AdminRoleData[] | undefined, + editingRoles: AdminRoleData[] + ) => { + const overrides = targetRoles ? { item: { roles: targetRoles } } : {}; + const { container, unmount } = renderForm( + overrides, + configFor(editingRoles) + ); + const shown = !!container.querySelector('input[name="password"]'); + unmount(); + return shown; + }; + + // No target admin at all → always editable. + expect(passwordShownFor(undefined, systemAdmin)).toBe(true); + // Target has no roles → editable iff the editor manages some library. + expect(passwordShownFor([], managerAll)).toBe(true); + expect(passwordShownFor([], librarianAll)).toBe(false); + // Target is a system admin → editable only by a system admin. + expect(passwordShownFor(systemAdmin, systemAdmin)).toBe(true); + expect(passwordShownFor(systemAdmin, managerAll)).toBe(false); + // Target is a sitewide manager → editable only by a sitewide manager. + expect(passwordShownFor(managerAll, managerAll)).toBe(true); + expect(passwordShownFor(managerAll, librarianAll)).toBe(false); + // Target manages a single library → editable by a manager of that library. + expect(passwordShownFor(nyplManager, nyplManager)).toBe(true); + expect(passwordShownFor(nyplManager, bplManager)).toBe(false); + }); + + it("has a save button only when a save function is supplied", () => { + const withSave = renderForm(); + expect(saveButton(withSave.container)).not.toBeNull(); + withSave.unmount(); + + const withoutSave = renderForm({ save: undefined }); + expect(saveButton(withoutSave.container)).toBeNull(); + }); + + it("does not render role checkboxes when setting up the first admin", () => { + renderForm({}, configFor(systemAdmin, true)); + for (const role of [ + "system", + "manager-all", + "librarian-all", + "manager-nypl", + "librarian-nypl", + ]) { + expect( + screen.queryByRole("checkbox", { name: roleNames[role] }) + ).toBeNull(); + } + }); + + // ── Disabled state (isDisabled) ────────────────────────────────────────────── + + it("disables every role checkbox when the form is disabled", () => { + renderForm({ disabled: true }); + expect(roleCheckbox("system")).toBeDisabled(); + expect(roleCheckbox("manager-all")).toBeDisabled(); + expect(roleCheckbox("manager-nypl")).toBeDisabled(); + }); + + it("lets a system-admin editor edit every role", () => { + renderForm(); + for (const role of allRoles) { + expect(roleCheckbox(role)).toBeEnabled(); + } + }); + + it("restricts a sitewide-manager editor from the system-admin role", () => { + renderForm({}, configFor(managerAll)); + expect(roleCheckbox("system")).toBeDisabled(); + expect(roleCheckbox("manager-all")).toBeEnabled(); + expect(roleCheckbox("librarian-all")).toBeEnabled(); + expect(roleCheckbox("manager-nypl")).toBeEnabled(); + }); + + it("restricts a sitewide-librarian editor from the manager roles", () => { + renderForm({}, configFor(librarianAll)); + expect(roleCheckbox("system")).toBeDisabled(); + expect(roleCheckbox("manager-all")).toBeDisabled(); + expect(roleCheckbox("librarian-all")).toBeDisabled(); + }); + + it("restricts a single-library manager editor to that library", () => { + renderForm({}, configFor(nyplManager)); + expect(roleCheckbox("manager-all")).toBeDisabled(); + expect(roleCheckbox("manager-nypl")).toBeEnabled(); + expect(roleCheckbox("manager-bpl")).toBeDisabled(); + expect(roleCheckbox("librarian-nypl")).toBeEnabled(); + expect(roleCheckbox("librarian-bpl")).toBeDisabled(); + }); + + it("disables a per-library box when a sitewide role is selected but the editor is not sitewide", () => { + const { rerender } = renderForm( + { item: { ...adminData, roles: managerAll } }, + configFor(nyplManager) + ); + // manager-all is selected on the target; a non-sitewide editor can't touch it. + expect(roleCheckbox("manager-nypl")).toBeDisabled(); + expect(roleCheckbox("manager-bpl")).toBeDisabled(); + + rerender( + + ); + expect(roleCheckbox("librarian-nypl")).toBeDisabled(); + expect(roleCheckbox("librarian-bpl")).toBeDisabled(); + }); + + it("also restricts an editor when the target admin is a system admin", () => { + renderForm( + { item: { ...adminData, roles: systemAdmin } }, + configFor(managerAll) + ); + // isSelected("system") is true, so a non-system editor can edit nothing. + expect(roleCheckbox("manager-all")).toBeDisabled(); + }); + + // ── Role-change behavior (handleRoleChange) ────────────────────────────────── + + it("changes the system admin role", async () => { + const user = userEvent.setup(); + renderForm({ item: { ...adminData, roles: nyplManagerLibrarianAll } }); + + await user.click(roleCheckbox("system")); + expectRoles(allRoles); + + await user.click(roleCheckbox("system")); + expectRoles([]); + }); + + it("changes the manager-all role", async () => { + const user = userEvent.setup(); + const { rerender } = renderForm(); + + await user.click(roleCheckbox("manager-all")); + expectRoles([ + "manager-all", + "librarian-all", + "manager-nypl", + "manager-bpl", + "librarian-nypl", + "librarian-bpl", + ]); + + await user.click(roleCheckbox("manager-all")); + expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); + + setRoles(rerender, systemAdmin); + await user.click(roleCheckbox("manager-all")); + expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); + + setRoles(rerender, nyplManagerLibrarianAll); + await user.click(roleCheckbox("manager-all")); + expectRoles([ + "manager-all", + "librarian-all", + "manager-nypl", + "manager-bpl", + "librarian-nypl", + "librarian-bpl", + ]); + }); + + it("changes the librarian-all role", async () => { + const user = userEvent.setup(); + const { rerender } = renderForm(); + + await user.click(roleCheckbox("librarian-all")); + expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); + + await user.click(roleCheckbox("librarian-all")); + expectRoles([]); + + setRoles(rerender, systemAdmin); + await user.click(roleCheckbox("librarian-all")); + expectRoles([]); + + setRoles(rerender, nyplManagerLibrarianAll); + await user.click(roleCheckbox("librarian-all")); + expectRoles(["manager-nypl", "librarian-nypl"]); + + setRoles(rerender, nyplLibrarian); + await user.click(roleCheckbox("librarian-all")); + expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); + }); + + it("changes the manager role for each library", async () => { + const user = userEvent.setup(); + const { rerender } = renderForm(); + + await user.click(roleCheckbox("manager-nypl")); + expectRoles(["manager-nypl", "librarian-nypl"]); + + await user.click(roleCheckbox("manager-nypl")); + expectRoles(["librarian-nypl"]); + + setRoles(rerender, systemAdmin); + await user.click(roleCheckbox("manager-nypl")); + expectRoles([ + "librarian-all", + "manager-bpl", + "librarian-nypl", + "librarian-bpl", + ]); + + setRoles(rerender, managerAll); + await user.click(roleCheckbox("manager-nypl")); + expectRoles([ + "librarian-all", + "manager-bpl", + "librarian-nypl", + "librarian-bpl", + ]); + + setRoles(rerender, nyplManagerLibrarianAll); + await user.click(roleCheckbox("manager-nypl")); + expectRoles(["librarian-all", "librarian-nypl", "librarian-bpl"]); + + setRoles(rerender, nyplLibrarian); + await user.click(roleCheckbox("manager-nypl")); + expectRoles(["manager-nypl", "librarian-nypl"]); + + setRoles(rerender, bplLibrarian); + await user.click(roleCheckbox("manager-nypl")); + expectRoles(["manager-nypl", "librarian-nypl", "librarian-bpl"]); + }); + + it("changes the librarian role for each library", async () => { + const user = userEvent.setup(); + const { rerender } = renderForm(); + + await user.click(roleCheckbox("librarian-nypl")); + expectRoles(["librarian-nypl"]); + + await user.click(roleCheckbox("librarian-nypl")); + expectRoles([]); + + setRoles(rerender, systemAdmin); + await user.click(roleCheckbox("librarian-nypl")); + expectRoles(["manager-bpl", "librarian-bpl"]); + + setRoles(rerender, managerAll); + await user.click(roleCheckbox("librarian-nypl")); + expectRoles(["manager-bpl", "librarian-bpl"]); + + setRoles(rerender, nyplManagerLibrarianAll); + await user.click(roleCheckbox("librarian-nypl")); + expectRoles(["librarian-bpl"]); + + setRoles(rerender, bplLibrarian); + await user.click(roleCheckbox("librarian-nypl")); + expectRoles(["librarian-nypl", "librarian-bpl"]); + }); + + // ── Submission (submit / handleData) ───────────────────────────────────────── + + it("calls save when the save button is clicked", async () => { + const user = userEvent.setup(); + const { container } = renderForm(); + await user.click(saveButton(container)); + expect(save).toHaveBeenCalledTimes(1); + }); + + it("submits the entered email, password, and selected roles", async () => { + const user = userEvent.setup(); + const { container } = renderForm(); + + await user.type( + container.querySelector('input[name="email"]'), + "newEmail" + ); + await user.type( + container.querySelector('input[name="password"]'), + "newPassword" + ); + await user.click(roleCheckbox("librarian-all")); + await user.click(roleCheckbox("manager-nypl")); + + await user.click(saveButton(container)); + + expect(save).toHaveBeenCalledTimes(1); + const formData = save.mock.calls[0][0]; + expect(formData.get("email")).toBe("newEmail"); + expect(formData.get("password")).toBe("newPassword"); + expect(formData.get("roles")).toBe( + JSON.stringify([ + { role: "librarian-all" }, + { role: "manager", library: "nypl" }, + ]) + ); + }); + + it("submits only a system-admin role when setting up the first admin", async () => { + const user = userEvent.setup(); + const { container } = renderForm({}, configFor(systemAdmin, true)); + + await user.type( + container.querySelector('input[name="email"]'), + "newEmail" + ); + await user.type( + container.querySelector('input[name="password"]'), + "newPassword" + ); + + await user.click(saveButton(container)); + + expect(save).toHaveBeenCalledTimes(1); + const formData = save.mock.calls[0][0]; + expect(formData.get("email")).toBe("newEmail"); + expect(formData.get("password")).toBe("newPassword"); + expect(formData.get("roles")).toBe(JSON.stringify([{ role: "system" }])); + }); + + it("clears the form after a successful save", async () => { + const user = userEvent.setup(); + const { container, rerender } = renderForm(); + const email = () => + container.querySelector('input[name="email"]'); + + await user.type(email(), "newEmail"); + expect(email().value).toBe("newEmail"); + + // The presence of responseBody (and no error) signals a successful save. + rerender( + + ); + expect(email().value).toBe(""); + }); + + it("does not clear the form when there is an error", async () => { + const user = userEvent.setup(); + const { container, rerender } = renderForm(); + const email = () => + container.querySelector('input[name="email"]'); + + await user.type(email(), "newEmail"); + expect(email().value).toBe("newEmail"); + + rerender( + + ); + expect(email().value).toBe("newEmail"); + }); +}); diff --git a/tests/jest/components/IndividualAdmins.test.tsx b/tests/jest/components/IndividualAdmins.test.tsx index ad5d216dd2..72af85d58c 100644 --- a/tests/jest/components/IndividualAdmins.test.tsx +++ b/tests/jest/components/IndividualAdmins.test.tsx @@ -1,19 +1,16 @@ import * as React from "react"; -import { fireEvent } from "@testing-library/react"; -import { IndividualAdmins } from "../../../src/components/IndividualAdmins"; +import { fireEvent, screen } from "@testing-library/react"; +import ConnectedIndividualAdmins, { + IndividualAdmins, +} from "../../../src/components/IndividualAdmins"; import renderWithContext from "../testUtils/renderWithContext"; +import { renderWithProviders } from "../testUtils/withProviders"; import { ConfigurationSettings, IndividualAdminsData, } from "../../../src/interfaces"; import { defaultFeatureFlags } from "../../../src/utils/featureFlags"; -// NB: This adds tests to the already existing tests in: -// - `src/components/__tests__/IndividualAdmins-test.tsx`. -// -// Those tests should eventually be migrated here and -// adapted to the Jest/React Testing Library paradigm. - describe("IndividualAdmins - role association disclosure", () => { // ── Shared fixtures ─────────────────────────────────────────────────────── @@ -388,3 +385,32 @@ describe("IndividualAdmins - role association disclosure", () => { expect(container.querySelectorAll(".associated-items")).toHaveLength(0); }); }); + +// Exercises the connected default export so mapStateToProps/mapDispatchToProps +// are covered. The RBAC gating is already covered by the disclosure tests above +// and by tests/jest/businessRules/roleBasedAccess.test.ts. +describe("IndividualAdmins - connect wiring", () => { + afterEach(() => jest.restoreAllMocks()); + + it("renders the connected default export, fetching on mount", async () => { + jest.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ individualAdmins: [] }), { + headers: { "Content-Type": "application/json" }, + }) + ); + + renderWithProviders( + , + { appConfigSettings: { roles: [{ role: "system" }] } } + ); + + // A system admin sees the create link once the mount fetch settles. + expect( + await screen.findByText("Create new individual admin") + ).toBeInTheDocument(); + }); +}); diff --git a/tests/jest/components/LibraryRegistration.test.tsx b/tests/jest/components/LibraryRegistration.test.tsx new file mode 100644 index 0000000000..a5a786d320 --- /dev/null +++ b/tests/jest/components/LibraryRegistration.test.tsx @@ -0,0 +1,321 @@ +import * as React from "react"; +import { installFormDataShim } from "../testUtils/formDataShim"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import LibraryRegistration from "../../../src/components/LibraryRegistration"; +// The reusable-components `Form` builds `new FormData(formElement)` on +// submit, which the unit jsdom env's undici FormData rejects; install the +// shared shim that reads the form's successful controls. +installFormDataShim(); +const protocol = "protocol 1"; +const serviceData = { id: 1, protocol }; + +const makeProtocols = (overrides: Record = {}) => [ + { + name: protocol, + label: "protocol 1 label", + supports_registration: true, + supports_staging: true, + settings: [], + library_settings: [], + ...overrides, + }, +]; + +const allLibraries = [ + { short_name: "nypl", name: "New York Public Library", uuid: "1" }, + { short_name: "bpl", name: "Brooklyn Public Library", uuid: "2" }, + { short_name: "qpl", name: "Queens Public Library", uuid: "3" }, +]; + +const libraryRegistrations = [ + { + id: 1, + libraries: [ + { short_name: "nypl", status: "success", stage: "production" }, + { short_name: "bpl", status: "failure", stage: "testing" }, + ], + }, +]; + +const makeData = (overrides: Record = {}) => ({ + discovery_services: [serviceData], + protocols: makeProtocols(), + allLibraries, + libraryRegistrations, + ...overrides, +}); + +const renderReg = ({ + data, + registerLibrary, + ...props +}: Record = {}) => { + const register = registerLibrary ?? jest.fn(); + const result = render( + + ); + return { ...result, registerLibrary: register }; +}; + +const libraries = (container: HTMLElement) => + Array.from( + container.querySelectorAll( + ".service-with-registrations-library" + ) + ); +const registrationInfos = (container: HTMLElement) => + Array.from( + container.querySelectorAll(".library-registration-info") + ); + +describe("LibraryRegistration", () => { + describe("rendering", () => { + it("doesn't render libraries in create form", () => { + // No `item` prop => nothing rendered. + const { container } = renderReg(); + expect(libraries(container)).toHaveLength(0); + }); + + it("doesn't render libraries in edit form if protocol doesn't support registration", () => { + const { container } = renderReg({ + item: serviceData, + data: makeData({ + protocols: makeProtocols({ supports_registration: false }), + }), + }); + expect(libraries(container)).toHaveLength(0); + }); + + it("treats a protocol that is absent from the list as unsupported", () => { + // protocolSupportsType falls through to `false` when the selected protocol + // matches no entry in data.protocols, so no registration UI renders. + const { container } = renderReg({ + item: serviceData, + protocol: "protocol-not-in-the-list", + }); + expect(libraries(container)).toHaveLength(0); + }); + + it("renders all libraries in edit form, with registration status", () => { + const { container } = renderReg({ item: serviceData }); + const libs = libraries(container); + expect(libs).toHaveLength(3); + + expect(libs[0]).toHaveTextContent("New York Public Library"); + expect(libs[0]).toHaveTextContent("Registered"); + expect(libs[1]).toHaveTextContent("Brooklyn Public Library"); + expect(libs[1]).toHaveTextContent("Registration failed"); + expect(libs[2]).toHaveTextContent("Queens Public Library"); + expect(libs[2]).toHaveTextContent("Not registered"); + }); + + it("provides links to the libraries' edit forms", () => { + const { container } = renderReg({ item: serviceData }); + const libs = libraries(container); + expect(libs).toHaveLength(3); + + expect(libs[0].querySelector("a")).toHaveAttribute( + "href", + "/admin/web/config/libraries/edit/1" + ); + expect(libs[1].querySelector("a")).toHaveAttribute( + "href", + "/admin/web/config/libraries/edit/2" + ); + expect(libs[2].querySelector("a")).toHaveAttribute( + "href", + "/admin/web/config/libraries/edit/3" + ); + }); + + it("should not render the staging dropdown if it is not supported", () => { + const { container } = renderReg({ + item: serviceData, + data: makeData({ + protocols: makeProtocols({ supports_staging: false }), + }), + }); + const infos = registrationInfos(container); + expect(infos).toHaveLength(3); + + // No current-stage section and no stage dropdown for any library. + infos.forEach((info) => { + expect(info.querySelector(".current-stage")).not.toBeInTheDocument(); + expect(info.querySelector("select")).not.toBeInTheDocument(); + }); + + // Registration status and button still appear. The first span in each + // info is the status span. + const [nypl, bpl, qpl] = infos; + expect(nypl.querySelector("span")).toHaveTextContent("Registered"); + expect(nypl.querySelector("span")).toHaveClass("bg-success"); + expect(bpl.querySelector("span")).toHaveTextContent( + "Registration failed" + ); + expect(bpl.querySelector("span")).toHaveClass("bg-danger"); + expect(qpl.querySelector("span")).toHaveTextContent("Not registered"); + expect(qpl.querySelector("span")).toHaveClass("bg-warning"); + + expect(nypl.querySelector("button")).toHaveTextContent( + "Update registration" + ); + expect(bpl.querySelector("button")).toHaveTextContent( + "Retry registration" + ); + expect(qpl.querySelector("button")).toHaveTextContent("Register"); + }); + + it("should render the current registration stage", () => { + // An additional library shows the current stage when it is in the + // "testing" stage and its registration was successful. + const libraryRegistrationsWBoston = [ + { + id: 1, + libraries: [ + { short_name: "nypl", status: "success", stage: "production" }, + { short_name: "bpl", status: "failure", stage: "testing" }, + { short_name: "boston", status: "success", stage: "testing" }, + ], + }, + ]; + const allLibrariesWBoston = [ + ...allLibraries, + { short_name: "boston", name: "Boston Public Library", uuid: "4" }, + ]; + const { container } = renderReg({ + item: serviceData, + data: makeData({ + allLibraries: allLibrariesWBoston, + libraryRegistrations: libraryRegistrationsWBoston, + }), + }); + const [nypl, bpl, qpl, boston] = registrationInfos(container); + + expect(nypl.querySelector(".current-stage span")).toHaveTextContent( + "Current Stage: production" + ); + expect(bpl.querySelector(".current-stage span")).toHaveTextContent( + "No current stage" + ); + expect(qpl.querySelector(".current-stage span")).toHaveTextContent( + "No current stage" + ); + expect(boston.querySelector(".current-stage span")).toHaveTextContent( + "Current Stage: testing" + ); + }); + + it("should render a registration stage type dropdown with two options", () => { + const { container } = renderReg({ item: serviceData }); + const bpl = registrationInfos(container)[1]; + const options = bpl.querySelectorAll("option"); + expect(options).toHaveLength(2); + expect(options[0]).toHaveTextContent("Testing"); + expect(options[1]).toHaveTextContent("Production"); + }); + + it("should render a registration stage select if the library is not in production", () => { + const { container } = renderReg({ item: serviceData }); + const [nypl, bpl, qpl] = registrationInfos(container); + + // Once a library is registered in production, the dropdown is removed. + expect(nypl.querySelectorAll("select")).toHaveLength(0); + expect(bpl.querySelectorAll("select")).toHaveLength(1); + expect(qpl.querySelectorAll("select")).toHaveLength(1); + }); + + it("should render a form", () => { + // The status of each library determines its button text. (The + // `checked`/`library` props of each form are only DOM-observable through + // the button text and status here, since there is no terms-of-service + // checkbox in this data.) + const { container } = renderReg({ item: serviceData }); + const libs = libraries(container); + + expect(libs[0]).toHaveTextContent("New York Public Library"); + expect(libs[0].querySelector("button")).toHaveTextContent( + "Update registration" + ); + expect(libs[1]).toHaveTextContent("Brooklyn Public Library"); + expect(libs[1].querySelector("button")).toHaveTextContent( + "Retry registration" + ); + expect(libs[2]).toHaveTextContent("Queens Public Library"); + expect(libs[2].querySelector("button")).toHaveTextContent("Register"); + }); + }); + + describe("behavior", () => { + it("registers a library", async () => { + const user = userEvent.setup(); + const registerLibrary = jest.fn(); + const { container } = renderReg({ item: serviceData, registerLibrary }); + const libs = libraries(container); + expect(registerLibrary).not.toHaveBeenCalled(); + + await user.click(libs[0].querySelector("button")); + expect(registerLibrary).toHaveBeenCalledTimes(1); + // nypl is registered in production, so it always registers in production. + expect(registerLibrary.mock.calls[0][0]).toMatchObject({ + short_name: "nypl", + }); + expect(registerLibrary.mock.calls[0][1]).toBe("production"); + + await user.click(libs[1].querySelector("button")); + expect(registerLibrary).toHaveBeenCalledTimes(2); + // bpl defaults to the "testing" stage. + expect(registerLibrary.mock.calls[1][0]).toMatchObject({ + short_name: "bpl", + }); + expect(registerLibrary.mock.calls[1][1]).toBe("testing"); + + await user.click(libs[2].querySelector("button")); + expect(registerLibrary).toHaveBeenCalledTimes(3); + expect(registerLibrary.mock.calls[2][0]).toMatchObject({ + short_name: "qpl", + }); + expect(registerLibrary.mock.calls[2][1]).toBe("testing"); + }); + + it("registers each library with the stage selected from its own dropdown", async () => { + const user = userEvent.setup(); + const registerLibrary = jest.fn(); + const { container } = renderReg({ item: serviceData, registerLibrary }); + const [, bpl, qpl] = registrationInfos(container); + + // Choosing "production" for bpl registers bpl in production... + await user.selectOptions(bpl.querySelector("select"), "production"); + await user.click(bpl.querySelector("button")); + expect(registerLibrary.mock.calls[0][0]).toMatchObject({ + short_name: "bpl", + }); + expect(registerLibrary.mock.calls[0][1]).toBe("production"); + + // ...without affecting qpl, which still defaults to "testing". + await user.click(qpl.querySelector("button")); + expect(registerLibrary.mock.calls[1][0]).toMatchObject({ + short_name: "qpl", + }); + expect(registerLibrary.mock.calls[1][1]).toBe("testing"); + + // Each library's dropdown updates its own stage independently. + await user.selectOptions(qpl.querySelector("select"), "production"); + await user.click(qpl.querySelector("button")); + expect(registerLibrary.mock.calls[2][0]).toMatchObject({ + short_name: "qpl", + }); + expect(registerLibrary.mock.calls[2][1]).toBe("production"); + }); + }); +}); diff --git a/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx b/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx new file mode 100644 index 0000000000..56e95987a5 --- /dev/null +++ b/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx @@ -0,0 +1,167 @@ +import * as React from "react"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import NeighborhoodAnalyticsForm from "../../../src/components/NeighborhoodAnalyticsForm"; +import { SettingData } from "../../../src/interfaces"; + +const patronAuthSetting: SettingData = { + key: "neighborhood_mode", + label: "Patron Auth", + options: [ + { key: "disabled", label: "Off" }, + { key: "choice1", label: "Choice 1" }, + { key: "choice2", label: "Choice 2" }, + ], +}; + +const analyticsSetting: SettingData = { + key: "location_source", + label: "Analytics", + options: [ + { key: "disabled", label: "Off" }, + { key: "choice1", label: "Choice 1" }, + { key: "choice2", label: "Choice 2" }, + ], +}; + +// The Panel renders its body collapsed and `aria-hidden` by default, so the +// form is present in the DOM but hidden from the accessibility tree; query it +// through the container. +const getSelect = (container: HTMLElement) => + container.querySelector("select") as HTMLSelectElement; +const panelTitle = (container: HTMLElement) => + container.querySelector(".panel-title") as HTMLElement; +const warning = (container: HTMLElement) => + container.querySelector(".bg-warning") as HTMLElement | null; + +describe("NeighborhoodAnalyticsForm", () => { + it("renders a panel with the feature name", () => { + const { container } = render( + + ); + expect(container.querySelector(".panel")).toBeInTheDocument(); + expect(panelTitle(container)).toHaveTextContent( + "Patron Neighborhood Analytics: Disabled" + ); + }); + + it("renders a select whose options reflect the setting", () => { + const { container } = render( + + ); + const options = Array.from(getSelect(container).querySelectorAll("option")); + expect(options).toHaveLength(patronAuthSetting.options.length); + options.forEach((option, idx) => { + expect(option).toHaveValue(patronAuthSetting.options[idx].key); + expect(option).toHaveTextContent(patronAuthSetting.options[idx].label); + }); + }); + + it("renders a warning message pointing at the paired service once enabled", async () => { + const user = userEvent.setup(); + const { container, rerender } = render( + + ); + + // No warning while the feature is off. + expect(warning(container)).not.toBeInTheDocument(); + await user.selectOptions(getSelect(container), "disabled"); + expect(warning(container)).not.toBeInTheDocument(); + + // Enabling it surfaces a warning that links to the analytics config. + await user.selectOptions(getSelect(container), "choice1"); + expect(warning(container)).toBeInTheDocument(); + expect(warning(container)).toHaveTextContent( + "This feature will work only if it is also enabled in your local analytics service configuration settings." + ); + expect(warning(container).querySelector("a")).toHaveAttribute( + "href", + "/admin/web/config/analytics" + ); + + // Switching the setting flips the paired service to patron auth. + rerender(); + expect(warning(container)).toHaveTextContent( + "This feature will work only if it is also enabled in your patron authentication service configuration settings." + ); + expect(warning(container).querySelector("a")).toHaveAttribute( + "href", + "/admin/web/config/patronAuth" + ); + }); + + it("updates the header when an option is chosen", async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + expect(panelTitle(container)).toHaveTextContent( + "Patron Neighborhood Analytics: Disabled" + ); + + await user.selectOptions(getSelect(container), "choice1"); + expect(panelTitle(container)).toHaveTextContent( + "Patron Neighborhood Analytics: Enabled" + ); + }); + + it("reflects the currentValue prop as the initial selection", () => { + const { container } = render( + + ); + // A non-"disabled" currentValue means the feature starts enabled. + expect(panelTitle(container)).toHaveTextContent( + "Patron Neighborhood Analytics: Enabled" + ); + expect(getSelect(container)).toHaveValue("choice1"); + }); + + it("tells the user whether the feature is currently enabled", async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + + expect(panelTitle(container)).toHaveTextContent("Disabled"); + await user.selectOptions(getSelect(container), "choice1"); + expect(panelTitle(container)).toHaveTextContent("Enabled"); + await user.selectOptions(getSelect(container), "choice2"); + expect(panelTitle(container)).toHaveTextContent("Enabled"); + await user.selectOptions(getSelect(container), "disabled"); + expect(panelTitle(container)).toHaveTextContent("Disabled"); + }); + + it("shows the warning only for enabled selections", async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + + expect(warning(container)).not.toBeInTheDocument(); + await user.selectOptions(getSelect(container), "choice1"); + expect(warning(container)).toBeInTheDocument(); + await user.selectOptions(getSelect(container), "choice2"); + expect(warning(container)).toBeInTheDocument(); + await user.selectOptions(getSelect(container), "disabled"); + expect(warning(container)).not.toBeInTheDocument(); + }); + + it("exposes the selected value through its getValue ref API", async () => { + // ServiceEditForm reads the chosen value via this imperative ref API. + const user = userEvent.setup(); + const ref = React.createRef(); + const { container } = render( + + ); + + expect(ref.current?.getValue()).toBe(""); + await user.selectOptions(getSelect(container), "choice1"); + expect(ref.current?.getValue()).toBe("choice1"); + await user.selectOptions(getSelect(container), "choice2"); + expect(ref.current?.getValue()).toBe("choice2"); + }); +}); diff --git a/tests/jest/components/TextWithEditMode.test.tsx b/tests/jest/components/TextWithEditMode.test.tsx new file mode 100644 index 0000000000..462c3cdb62 --- /dev/null +++ b/tests/jest/components/TextWithEditMode.test.tsx @@ -0,0 +1,218 @@ +import * as React from "react"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import TextWithEditMode from "../../../src/components/TextWithEditMode"; + +describe("TextWithEditMode", () => { + it("renders text", () => { + const { rerender } = render( + + ); + expect(screen.getByText("test")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Edit editable thing/ }) + ).toBeInTheDocument(); + + rerender( + + ); + expect(screen.queryByText("test")).not.toBeInTheDocument(); + expect(screen.getByText("other text")).toBeInTheDocument(); + }); + + it("starts in edit mode if there's no text", () => { + render( + + ); + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("placeholder", "editable thing"); + expect(input).toHaveValue(""); + expect( + screen.getByRole("button", { name: /Save editable thing/ }) + ).toBeInTheDocument(); + }); + + it("switches to edit mode", async () => { + const user = userEvent.setup(); + render( + + ); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: /Edit editable thing/ }) + ); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("placeholder", "editable thing"); + expect(input).toHaveValue("test"); + expect(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Edit/ }) + ).not.toBeInTheDocument(); + }); + + it("switches out of edit mode", async () => { + const user = userEvent.setup(); + const onUpdate = jest.fn(); + render( + + ); + // Type the new value into the field; the component reads the real input + // value back through its ref on save (no prototype stubbing). + await user.type(screen.getByRole("textbox"), "new value"); + await user.click( + screen.getByRole("button", { name: /Save editable thing/ }) + ); + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledWith("new value"); + expect(screen.getByText("new value")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit/ })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Save/ }) + ).not.toBeInTheDocument(); + }); + + it("gets text via the ref API", async () => { + const user = userEvent.setup(); + // In display mode, getText() returns the current text. + const displayRef = React.createRef(); + const { unmount } = render( + + ); + expect(displayRef.current!.getText()).toBe("test"); + unmount(); + + // From edit mode, getText() returns the current input value and exits edit mode. + const editRef = React.createRef(); + render( + + ); + await user.type(screen.getByRole("textbox"), "new value"); + let value = ""; + act(() => { + value = editRef.current!.getText(); + }); + expect(value).toBe("new value"); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); + + it("resets via the ref API", async () => { + const user = userEvent.setup(); + const onUpdate = jest.fn(); + + // With no text prop, reset() returns to a blank edit mode. + const blankRef = React.createRef(); + const { unmount } = render( + + ); + await user.type(screen.getByRole("textbox"), "new value"); + await user.click( + screen.getByRole("button", { name: /Save editable thing/ }) + ); + expect(screen.getByText("new value")).toBeInTheDocument(); + expect(onUpdate).toHaveBeenCalledTimes(1); + + act(() => { + blankRef.current!.reset(); + }); + expect(screen.queryByText("new value")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(onUpdate.mock.calls[1][0]).toBeUndefined(); + unmount(); + + // With a text prop, reset() returns to displaying that text. + const textRef = React.createRef(); + render( + + ); + await user.click( + screen.getByRole("button", { name: /Edit editable thing/ }) + ); + const input = screen.getByRole("textbox"); + await user.clear(input); + await user.type(input, "new value"); + await user.click( + screen.getByRole("button", { name: /Save editable thing/ }) + ); + expect(screen.queryByText("test")).not.toBeInTheDocument(); + expect(screen.getByText("new value")).toBeInTheDocument(); + expect(onUpdate).toHaveBeenCalledTimes(3); + + act(() => { + textRef.current!.reset(); + }); + expect(screen.queryByText("new value")).not.toBeInTheDocument(); + expect(screen.getByText("test")).toBeInTheDocument(); + expect(onUpdate).toHaveBeenCalledTimes(4); + expect(onUpdate.mock.calls[3][0]).toBe("test"); + }); + + it("optionally disables the save button if the text is blank", async () => { + const user = userEvent.setup(); + render( + + ); + const saveButton = screen.getByRole("button", { + name: /Save editable thing/, + }); + expect(saveButton).toBeDisabled(); + + await user.type(screen.getByRole("textbox"), "test"); + expect(saveButton).toBeEnabled(); + }); + + it("updates the displayed value as the user types", async () => { + const user = userEvent.setup(); + render( + + ); + const input = screen.getByRole("textbox"); + expect(input).toHaveValue(""); + await user.type(input, "test"); + expect(input).toHaveValue("test"); + }); +}); diff --git a/tests/jest/components/ToolTip.test.tsx b/tests/jest/components/ToolTip.test.tsx new file mode 100644 index 0000000000..4b1ace34be --- /dev/null +++ b/tests/jest/components/ToolTip.test.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ToolTip from "../../../src/components/ToolTip"; + +describe("ToolTip", () => { + const renderToolTip = () => + render(Hover} text="ToolTip Content" />); + + it("renders the trigger element", () => { + renderToolTip(); + expect(screen.getByRole("heading", { name: "Hover" })).toBeInTheDocument(); + }); + + it("renders the tooltip", () => { + const { container } = renderToolTip(); + const tooltip = container.querySelector(".tool-tip")!; + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent("ToolTip Content"); + expect(tooltip.innerHTML).toContain("ToolTip Content"); + }); + + it("initially hides the tooltip", () => { + const { container } = renderToolTip(); + expect(container.querySelector(".tool-tip")).toHaveClass("hide"); + }); + + it("shows the tooltip on mouseEnter", async () => { + const user = userEvent.setup(); + const { container } = renderToolTip(); + await user.hover(screen.getByRole("tooltip")); + expect(container.querySelector(".tool-tip")).not.toHaveClass("hide"); + }); + + it("hides the tooltip on mouseLeave", async () => { + const user = userEvent.setup(); + const { container } = renderToolTip(); + const toolTipContainer = screen.getByRole("tooltip"); + + await user.hover(toolTipContainer); + expect(container.querySelector(".tool-tip")).not.toHaveClass("hide"); + + await user.unhover(toolTipContainer); + expect(container.querySelector(".tool-tip")).toHaveClass("hide"); + }); +}); From d2c9828d665b4a63637be554f946ed1d9eedbff6 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Thu, 16 Jul 2026 10:40:58 -0300 Subject: [PATCH 2/5] Restore the IndividualAdmins access-control tests dropped in migration The RTL rewrite dropped the role-based tests that covered canCreate, canEdit and canDelete, and a code comment wrongly claimed the coverage had moved to roleBasedAccess.test.ts, which covers unrelated features. Restore them as DOM-driven tests over the create link, the Edit/View link and the Delete button, adding the sitewide manager and librarian cases the originals never exercised. Also restore the non-setup header test; the setup-branch headers stay dropped, since SetupPage.test.tsx covers them through the real prop wiring. --- .../jest/components/IndividualAdmins.test.tsx | 94 ++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/tests/jest/components/IndividualAdmins.test.tsx b/tests/jest/components/IndividualAdmins.test.tsx index 72af85d58c..daa15ea597 100644 --- a/tests/jest/components/IndividualAdmins.test.tsx +++ b/tests/jest/components/IndividualAdmins.test.tsx @@ -6,6 +6,7 @@ import ConnectedIndividualAdmins, { import renderWithContext from "../testUtils/renderWithContext"; import { renderWithProviders } from "../testUtils/withProviders"; import { + AdminRoleData, ConfigurationSettings, IndividualAdminsData, } from "../../../src/interfaces"; @@ -386,9 +387,98 @@ describe("IndividualAdmins - role association disclosure", () => { }); }); +// The settingUp headers ("Welcome!" / "Set up your system admin account") are +// covered by SetupPage.test.tsx, which exercises the real prop wiring. Only the +// non-setup branch needs covering here. +describe("IndividualAdmins - headers", () => { + it("shows the configuration messages when not setting up", () => { + renderWithContext( + , + { csrfToken: "", featureFlags: defaultFeatureFlags, roles: [] } + ); + + expect( + screen.getByRole("heading", { + level: 2, + name: "Individual admin configuration", + }) + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { + level: 3, + name: "Create a new individual admin", + }) + ).toBeInTheDocument(); + }); +}); + +describe("IndividualAdmins - access control", () => { + const renderAs = (roles: AdminRoleData[]) => + renderWithContext( + , + { csrfToken: "", featureFlags: defaultFeatureFlags, roles } + ); + + const createLink = () => screen.queryByText("Create new individual admin"); + const deleteButton = () => screen.queryByRole("button", { name: /Delete/ }); + + it("lets library managers create, edit and view, but not delete", () => { + renderAs([{ role: "manager", library: "alpha" }]); + expect(createLink()).toBeInTheDocument(); + expect(screen.getByText("Edit")).toBeInTheDocument(); + expect(deleteButton()).toBeNull(); + }); + + it("lets sitewide library managers create and edit", () => { + renderAs([{ role: "manager-all" }]); + expect(createLink()).toBeInTheDocument(); + expect(screen.getByText("Edit")).toBeInTheDocument(); + }); + + it("lets system admins create, edit and delete", () => { + renderAs([{ role: "system" }]); + expect(createLink()).toBeInTheDocument(); + expect(screen.getByText("Edit")).toBeInTheDocument(); + expect(deleteButton()).toBeInTheDocument(); + }); + + it("does not let librarians create, edit or delete", () => { + renderAs([{ role: "librarian", library: "alpha" }]); + expect(createLink()).toBeNull(); + expect(screen.queryByText("Edit")).toBeNull(); + // Non-editors get a read-only View link in place of Edit. + expect(screen.getByText("View")).toBeInTheDocument(); + expect(deleteButton()).toBeNull(); + }); + + it("does not let sitewide librarians create, edit or delete", () => { + renderAs([{ role: "librarian-all" }]); + expect(createLink()).toBeNull(); + expect(screen.getByText("View")).toBeInTheDocument(); + expect(deleteButton()).toBeNull(); + }); +}); + // Exercises the connected default export so mapStateToProps/mapDispatchToProps -// are covered. The RBAC gating is already covered by the disclosure tests above -// and by tests/jest/businessRules/roleBasedAccess.test.ts. +// are covered. describe("IndividualAdmins - connect wiring", () => { afterEach(() => jest.restoreAllMocks()); From 2b3d4caab7d5ca16f79f1f0fe2a8717a2d0a4a86 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Thu, 16 Jul 2026 10:58:55 -0300 Subject: [PATCH 3/5] Restore the remaining test coverage dropped in the RTL migration Auditing every legacy suite deleted in 900ed9f12 against its replacement turned up eleven more dropped behaviors beyond the IndividualAdmins ones. EditableInput lost the most: the .field-error branch was left with no coverage at all, as were the disabled and children pass-throughs, the radio label and its arm of handleChange, extraContent, clear()'s reset of checked, and the zero-value guard that defaultValueIfMissing exists to provide. Its description assertion had also weakened to a plain-text check while the component still renders HTML. IndividualAdminEditForm lost the sitewide-librarian target from its password matrix -- the only case exercising canChangePassword with a role carrying no library -- and the assertion that an existing password is never pre-filled. AnnouncementForm only compared its date fields against whatever it had rendered with, so blank defaults would have passed; pin the clock and assert the values instead. LibraryRegistration lost the status -> checked derivation for the terms of service box, restored here for both the registered and failed cases. Each restored assertion was checked by mutating the component to reintroduce the regression it guards and confirming it fails. --- .../jest/components/AnnouncementForm.test.tsx | 28 ++- tests/jest/components/EditableInput.test.tsx | 167 ++++++++++++++++++ .../IndividualAdminEditForm.test.tsx | 25 +++ .../components/LibraryRegistration.test.tsx | 41 ++++- 4 files changed, 255 insertions(+), 6 deletions(-) diff --git a/tests/jest/components/AnnouncementForm.test.tsx b/tests/jest/components/AnnouncementForm.test.tsx index 1128d3b23a..28ba06d72d 100644 --- a/tests/jest/components/AnnouncementForm.test.tsx +++ b/tests/jest/components/AnnouncementForm.test.tsx @@ -48,14 +48,38 @@ describe("AnnouncementForm", () => { // Two date fields, each with its label and description. const dates = getDateInputs(container); expect(dates).toHaveLength(2); + // EditableInput prepends "(Optional) " to each description, so match the + // full sentence as a substring rather than the whole text content. expect(screen.getByText("Start Date")).toBeInTheDocument(); - expect(screen.getByText(/If no start date is chosen/)).toBeInTheDocument(); + expect( + screen.getByText( + /If no start date is chosen, the default start date is today's date\./ + ) + ).toBeInTheDocument(); expect(screen.getByText("End Date")).toBeInTheDocument(); expect( - screen.getByText(/If no expiration date is chosen/) + screen.getByText( + /If no expiration date is chosen, the default expiration date is 2 months from the start date\./ + ) ).toBeInTheDocument(); }); + // The other tests only compare the date fields against whatever they rendered + // with, so they would all still pass if the defaults came out blank. Pin the + // clock and assert the actual values. + it("prefills the date fields with today and two months from today", () => { + jest.useFakeTimers().setSystemTime(new Date(2026, 2, 15)); + try { + const { container } = render(); + + const [start, finish] = getDateInputs(container); + expect(start.value).toBe("2026-03-15"); + expect(finish.value).toBe("2026-05-15"); + } finally { + jest.useRealTimers(); + } + }); + it("renders the buttons", () => { render(); expect(screen.getAllByRole("button")).toHaveLength(2); diff --git a/tests/jest/components/EditableInput.test.tsx b/tests/jest/components/EditableInput.test.tsx index 2b0128b634..1b17ac770b 100644 --- a/tests/jest/components/EditableInput.test.tsx +++ b/tests/jest/components/EditableInput.test.tsx @@ -143,4 +143,171 @@ describe("EditableInput - value, change, and imperative API", () => { act(() => ref.current?.clear()); expect(input).toHaveValue(""); }); + + it("clear also resets a checked box", () => { + const ref = React.createRef(); + render( + + ); + const box = screen.getByRole("checkbox", { name: "C" }); + expect(box).toBeChecked(); + + act(() => ref.current?.clear()); + expect(box).not.toBeChecked(); + }); + + // A falsy-but-valid value must survive: this is what defaultValueIfMissing + // guards, and what a naive `props.value || ""` would silently break. + it("keeps a zero value rather than falling back to the empty default", () => { + const { rerender } = render(); + const input = screen.getByRole("textbox", { name: "F" }); + expect(input).toHaveValue("0"); + + rerender(); + expect(input).toHaveValue("a"); + + rerender(); + expect(input).toHaveValue("0"); + }); +}); + +describe("EditableInput - labels, children, and pass-through props", () => { + it("labels a radio input and toggles it", async () => { + const user = userEvent.setup(); + const ref = React.createRef(); + render( + + ); + + const radio = screen.getByRole("radio", { name: "Radio label" }); + expect(radio).not.toBeChecked(); + + await user.click(radio); + + expect(radio).toBeChecked(); + expect(ref.current?.getChecked()).toBe(true); + }); + + it("renders children into the element", () => { + render( + + + + + ); + + const select = screen.getByRole("combobox", { name: "S" }); + expect(select).toHaveValue("b"); + expect( + Array.from(select.querySelectorAll("option")).map((o) => o.textContent) + ).toEqual(["option a", "option b"]); + }); + + it("forwards the disabled prop to the element", () => { + render(); + expect(screen.getByRole("textbox", { name: "F" })).toBeDisabled(); + }); + + // renderDescription uses dangerouslySetInnerHTML, and callers pass markup. + it("renders the description as HTML", () => { + const { container } = render( + + ); + + const description = container.querySelector(".description"); + expect(description.innerHTML).toContain("

description

"); + }); + + it("shows extra content in an add-on wrapper only when supplied", () => { + const { container, rerender } = render( + + ); + expect(container.querySelector(".with-add-on")).toBeNull(); + + rerender( + Extra content!} + /> + ); + + const extra = container.querySelector(".with-add-on"); + expect(extra).not.toBeNull(); + expect(extra).toHaveTextContent("Extra content!"); + }); +}); + +// The error styling is a compound predicate: +// clientError || (error && error.status >= 400 && !value && required) +describe("EditableInput - error styling", () => { + const formGroup = (container: HTMLElement) => + container.querySelector(".form-group"); + + it("applies no error class by default", () => { + const { container } = render( + + ); + expect(formGroup(container)).not.toHaveClass("field-error"); + }); + + it("applies the error class on a client error", () => { + const { container } = render( + + ); + expect(formGroup(container)).toHaveClass("field-error"); + }); + + it("applies the error class for a blank required field after a server error", () => { + const { container } = render( + + ); + expect(formGroup(container)).toHaveClass("field-error"); + }); + + it("does not apply the error class when the required field has a value", () => { + const { container } = render( + + ); + expect(formGroup(container)).not.toHaveClass("field-error"); + }); + + it("does not apply the error class for a server error on an optional field", () => { + const { container } = render( + + ); + expect(formGroup(container)).not.toHaveClass("field-error"); + }); }); diff --git a/tests/jest/components/IndividualAdminEditForm.test.tsx b/tests/jest/components/IndividualAdminEditForm.test.tsx index be52f389b5..a795720f67 100644 --- a/tests/jest/components/IndividualAdminEditForm.test.tsx +++ b/tests/jest/components/IndividualAdminEditForm.test.tsx @@ -281,6 +281,31 @@ describe("IndividualAdminEditForm - rendered inputs and role changes", () => { // Target manages a single library → editable by a manager of that library. expect(passwordShownFor(nyplManager, nyplManager)).toBe(true); expect(passwordShownFor(nyplManager, bplManager)).toBe(false); + // Target is a sitewide librarian → its role carries no library, so the + // per-role check falls to isLibraryManager(undefined): only a sitewide + // manager qualifies, never a manager of one particular library. + expect(passwordShownFor(librarianAll, systemAdmin)).toBe(true); + expect(passwordShownFor(librarianAll, managerAll)).toBe(true); + expect(passwordShownFor(librarianAll, librarianAll)).toBe(false); + expect(passwordShownFor(librarianAll, nyplManager)).toBe(false); + expect(passwordShownFor(librarianAll, nyplLibrarian)).toBe(false); + expect(passwordShownFor(librarianAll, nyplManagerLibrarianAll)).toBe(false); + // Target holds roles at two libraries → a manager of either one qualifies. + expect( + passwordShownFor([...nyplManager, ...bplLibrarian], bplManager) + ).toBe(true); + }); + + it("never pre-fills the existing password", () => { + const { container } = renderForm({ + item: { ...adminData, roles: nyplManager }, + }); + + const password = container.querySelector( + 'input[name="password"]' + ); + expect(password).not.toBeNull(); + expect(password.value).toBe(""); }); it("has a save button only when a save function is supplied", () => { diff --git a/tests/jest/components/LibraryRegistration.test.tsx b/tests/jest/components/LibraryRegistration.test.tsx index a5a786d320..559f8bb057 100644 --- a/tests/jest/components/LibraryRegistration.test.tsx +++ b/tests/jest/components/LibraryRegistration.test.tsx @@ -236,10 +236,7 @@ describe("LibraryRegistration", () => { }); it("should render a form", () => { - // The status of each library determines its button text. (The - // `checked`/`library` props of each form are only DOM-observable through - // the button text and status here, since there is no terms-of-service - // checkbox in this data.) + // The status of each library determines its button text. const { container } = renderReg({ item: serviceData }); const libs = libraries(container); @@ -254,6 +251,42 @@ describe("LibraryRegistration", () => { expect(libs[2]).toHaveTextContent("Queens Public Library"); expect(libs[2].querySelector("button")).toHaveTextContent("Register"); }); + + // Each form's `checked` prop is derived from the library's status. It only + // reaches the DOM when the registration carries terms of service, since + // that is what makes LibraryRegistrationForm render the checkbox at all. + // + // Each case renders a single library, because libraryRegistrationItem + // resolves the registration data as `libraryRegistrations[idx]` — indexing + // the per-service array by the library's position — so only a library at + // index 0 finds the terms. + const renderWithTerms = (library, status: string) => + renderReg({ + item: serviceData, + data: makeData({ + allLibraries: [library], + libraryRegistrations: [ + { + id: 1, + terms_of_service_html: "

Terms

", + libraries: [{ short_name: library.short_name, status }], + }, + ], + }), + }); + + const termsCheckbox = (container: HTMLElement) => + container.querySelector('input[type="checkbox"]'); + + it("pre-checks the terms-of-service box for an already-registered library", () => { + const { container } = renderWithTerms(allLibraries[0], "success"); + expect(termsCheckbox(container)).toBeChecked(); + }); + + it("leaves the terms-of-service box unchecked when registration failed", () => { + const { container } = renderWithTerms(allLibraries[1], "failure"); + expect(termsCheckbox(container)).not.toBeChecked(); + }); }); describe("behavior", () => { From 6ae12dbab6f30e524fd93f48f0954187b3bbe415 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Thu, 16 Jul 2026 11:19:31 -0300 Subject: [PATCH 4/5] Thread the caller's userEvent instance through fillOutForm The helper called userEvent.setup() itself, so tests that had already set one up drove the form through two independent instances. Take the user as a parameter instead, keeping one instance per test as the surrounding suites do. --- tests/jest/components/AnnouncementForm.test.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/jest/components/AnnouncementForm.test.tsx b/tests/jest/components/AnnouncementForm.test.tsx index 28ba06d72d..302d45857a 100644 --- a/tests/jest/components/AnnouncementForm.test.tsx +++ b/tests/jest/components/AnnouncementForm.test.tsx @@ -16,8 +16,10 @@ const getDateInputs = (container: HTMLElement) => const getCounter = () => screen.getByText(/Current length:/); // Enter valid content and a start/finish date. -const fillOutForm = async (container: HTMLElement) => { - const user = userEvent.setup(); +const fillOutForm = async ( + user: ReturnType, + container: HTMLElement +) => { await user.type(getTextarea(), CONTENT); const [start, finish] = getDateInputs(container); // Date inputs are read via a ref, so `fireEvent.change` (set value + dispatch) @@ -88,6 +90,7 @@ describe("AnnouncementForm", () => { }); it("keeps track of whether the content is too short or too long", async () => { + const user = userEvent.setup(); const { container } = render(); // Empty content: 0/350, flagged as the wrong length, Add disabled. @@ -96,7 +99,7 @@ describe("AnnouncementForm", () => { expect(screen.getByRole("button", { name: "Add" })).toBeDisabled(); // Valid content: length flag cleared and Add enabled. - await fillOutForm(container); + await fillOutForm(user, container); expect(getCounter()).toHaveTextContent("(Current length: 66/350)"); expect(getCounter().parentElement).not.toHaveClass("wrong-length"); expect(screen.getByRole("button", { name: "Add" })).not.toBeDisabled(); @@ -112,7 +115,7 @@ describe("AnnouncementForm", () => { it("adds a new announcement", async () => { const user = userEvent.setup(); const { container } = render(); - await fillOutForm(container); + await fillOutForm(user, container); await user.click(screen.getByRole("button", { name: "Add" })); @@ -129,7 +132,7 @@ describe("AnnouncementForm", () => { const defaultStart = start0.value; const defaultFinish = finish0.value; - await fillOutForm(container); + await fillOutForm(user, container); expect(getTextarea()).toHaveValue(CONTENT); await user.click(screen.getByRole("button", { name: "Cancel" })); From 5f0b263dd3cdd79b018166ba739a36ab52244ded Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Thu, 16 Jul 2026 11:42:14 -0300 Subject: [PATCH 5/5] Tighten the announcement edit and blank-password assertions Editing an announcement loads its start and finish into the form's date inputs, which is what lets it be re-saved with the right dates; assert those values rather than only the content. Split the blank and partially blank password cases into separate tests. Both shared one render, so the partial-blank branch re-asserted an error message that the preceding blank submit had already put on screen -- it passed whether or not the partial submit produced an error of its own. Each case now renders fresh. --- .../components/AnnouncementsSection.test.tsx | 6 ++++++ .../components/ChangePasswordForm.test.tsx | 21 +++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/jest/components/AnnouncementsSection.test.tsx b/tests/jest/components/AnnouncementsSection.test.tsx index 1bd6b2af78..1d31debb0c 100644 --- a/tests/jest/components/AnnouncementsSection.test.tsx +++ b/tests/jest/components/AnnouncementsSection.test.tsx @@ -104,6 +104,12 @@ describe("AnnouncementsSection", () => { within(items[0]).getByText("Second Announcement") ).toBeInTheDocument(); expect(screen.getByRole("textbox")).toHaveValue("First Announcement"); + + // Its dates are loaded too, which is what lets it be re-saved unchanged. + const dates = + container.querySelectorAll('input[type="date"]'); + expect(dates[0]).toHaveValue("2020-05-12"); + expect(dates[1]).toHaveValue("2020-06-12"); }); it("deletes an announcement", async () => { diff --git a/tests/jest/components/ChangePasswordForm.test.tsx b/tests/jest/components/ChangePasswordForm.test.tsx index ef6a85cc09..368ae0e5e8 100644 --- a/tests/jest/components/ChangePasswordForm.test.tsx +++ b/tests/jest/components/ChangePasswordForm.test.tsx @@ -79,20 +79,29 @@ describe("ChangePasswordForm", () => { expect(labels[1]).toHaveTextContent("Confirm New Password"); }); - it("does not submit a blank or partially blank form", async () => { + it("does not submit a blank form", async () => { const user = userEvent.setup(); const changePassword = jest.fn().mockResolvedValue(undefined); - const { container } = renderUnconnected({ changePassword }); - const submit = screen.getByRole("button", { name: "Submit" }); + renderUnconnected({ changePassword }); + + await user.click(screen.getByRole("button", { name: "Submit" })); - // Both fields blank. - await user.click(submit); expect(changePassword).not.toHaveBeenCalled(); expect(screen.getByText("Fields cannot be blank.")).toBeInTheDocument(); + }); + + // Rendered fresh, so the error message asserted below can only have come from + // this partial submit rather than lingering from an earlier blank one. + it("does not submit a partially blank form", async () => { + const user = userEvent.setup(); + const changePassword = jest.fn().mockResolvedValue(undefined); + const { container } = renderUnconnected({ changePassword }); + expect(screen.queryByText("Fields cannot be blank.")).toBeNull(); // Only the first field filled. await user.type(passwordInput(container), "newPassword"); - await user.click(submit); + await user.click(screen.getByRole("button", { name: "Submit" })); + expect(changePassword).not.toHaveBeenCalled(); expect(screen.getByText("Fields cannot be blank.")).toBeInTheDocument(); });