@thepalaceproject/circulation-admin 1.43.0 → 1.44.0-post.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CLAUDE.md +28 -7
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/eslint.config.mjs +9 -1
  5. package/jest.config.js +22 -8
  6. package/package.json +6 -20
  7. package/tests/jest/actions.test.ts +962 -0
  8. package/tests/jest/components/AccountPage.test.tsx +41 -0
  9. package/tests/jest/components/AdvancedSearchBooleanFilter.test.tsx +231 -0
  10. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +93 -20
  11. package/tests/jest/components/AdvancedSearchFilter.test.tsx +142 -0
  12. package/tests/jest/components/AdvancedSearchFilterInput.test.tsx +212 -0
  13. package/tests/jest/components/AdvancedSearchFilterViewer.test.tsx +52 -0
  14. package/tests/jest/components/AdvancedSearchValueFilter.test.tsx +123 -0
  15. package/tests/jest/components/Announcement.test.tsx +76 -0
  16. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  17. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  18. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  19. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  20. package/tests/jest/components/BookDetails.test.tsx +133 -0
  21. package/tests/jest/components/BookDetailsContainer.test.tsx +116 -0
  22. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  23. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  24. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  25. package/tests/jest/components/BookEditor.test.tsx +20 -21
  26. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  27. package/tests/jest/components/CatalogServices.test.tsx +100 -0
  28. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  29. package/tests/jest/components/CirculationEventsDownloadForm.test.tsx +41 -0
  30. package/tests/jest/components/Classifications.test.tsx +166 -0
  31. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  32. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  33. package/tests/jest/components/Collections.test.tsx +335 -7
  34. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  35. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  36. package/tests/jest/components/Complaints.test.tsx +259 -0
  37. package/tests/jest/components/ConfigPage.test.tsx +58 -0
  38. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  39. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  40. package/tests/jest/components/Contributors.test.tsx +157 -0
  41. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  42. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  43. package/tests/jest/components/CustomListPage.test.tsx +57 -0
  44. package/tests/jest/components/CustomListSearch.test.tsx +275 -0
  45. package/tests/jest/components/CustomLists.test.tsx +621 -4
  46. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  47. package/tests/jest/components/CustomListsSidebar.test.tsx +251 -0
  48. package/tests/jest/components/DashboardPage.test.tsx +76 -0
  49. package/tests/jest/components/DebugAuthentication.test.tsx +1 -5
  50. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  51. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  52. package/tests/jest/components/DiagnosticsTabContainer.test.tsx +147 -0
  53. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  54. package/tests/jest/components/EditableConfigList.test.tsx +606 -9
  55. package/tests/jest/components/EditableInput.test.tsx +250 -1
  56. package/tests/jest/components/EditorField.test.tsx +95 -0
  57. package/tests/jest/components/EntryPointsContainer.test.tsx +97 -0
  58. package/tests/jest/components/EntryPointsTabs.test.tsx +88 -0
  59. package/tests/jest/components/ErrorMessage.test.tsx +97 -0
  60. package/tests/jest/components/Footer.test.tsx +141 -0
  61. package/tests/jest/components/GenreForm.test.tsx +152 -0
  62. package/tests/jest/components/Header.test.tsx +421 -0
  63. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  64. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  65. package/tests/jest/components/InputList.test.tsx +493 -0
  66. package/tests/jest/components/InventoryReportRequestModal.test.tsx +16 -42
  67. package/tests/jest/components/JsonField.test.tsx +20 -0
  68. package/tests/jest/components/Lane.test.tsx +132 -10
  69. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  70. package/tests/jest/components/LaneEditor.test.tsx +377 -13
  71. package/tests/jest/components/LanePage.test.tsx +68 -0
  72. package/tests/jest/components/Lanes.test.tsx +446 -0
  73. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  74. package/tests/jest/components/LanguageField.test.tsx +40 -0
  75. package/tests/jest/components/Libraries.test.tsx +48 -0
  76. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  77. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  78. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  79. package/tests/jest/components/LoadButton.test.tsx +49 -0
  80. package/tests/jest/components/ManagePatrons.test.tsx +73 -0
  81. package/tests/jest/components/ManagePatronsForm.test.tsx +180 -0
  82. package/tests/jest/components/ManagePatronsTabContainer.test.tsx +143 -0
  83. package/tests/jest/components/MetadataServices.test.tsx +95 -0
  84. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  85. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  86. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  87. package/tests/jest/components/PatronBlockingRulesEditor.test.tsx +3 -1
  88. package/tests/jest/components/PatronInfo.test.tsx +54 -0
  89. package/tests/jest/components/ProtocolFormField.test.tsx +479 -7
  90. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  91. package/tests/jest/components/SaveButton.test.tsx +47 -0
  92. package/tests/jest/components/SelfTestResult.test.tsx +164 -0
  93. package/tests/jest/components/SelfTests.test.tsx +601 -0
  94. package/tests/jest/components/SelfTestsCategory.test.tsx +200 -0
  95. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  96. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  97. package/tests/jest/components/ServiceWithRegistrationsEditForm.test.tsx +110 -0
  98. package/tests/jest/components/SetupPage.test.tsx +41 -0
  99. package/tests/jest/components/SingleStatListItem.test.tsx +62 -0
  100. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  101. package/tests/jest/components/Stats.test.tsx +4 -15
  102. package/tests/jest/components/TabContainer.test.tsx +68 -0
  103. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  104. package/tests/jest/components/Timestamp.test.tsx +74 -0
  105. package/tests/jest/components/ToolTip.test.tsx +46 -0
  106. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  107. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  108. package/tests/jest/components/TroubleshootingTabContainer.test.tsx +68 -0
  109. package/tests/jest/components/UpdatingLoader.test.tsx +36 -0
  110. package/tests/jest/components/WelcomePage.test.tsx +43 -0
  111. package/tests/jest/components/WithEditButton.test.tsx +55 -0
  112. package/tests/jest/components/WithRemoveButton.test.tsx +95 -0
  113. package/tests/jest/editorAdapter.test.ts +245 -0
  114. package/tests/jest/features/book.test.ts +75 -80
  115. package/tests/jest/fixtures/classificationsData.ts +45 -0
  116. package/tests/jest/fixtures/genreData.ts +811 -0
  117. package/tests/jest/index.test.tsx +50 -0
  118. package/tests/jest/models/Admin.test.ts +206 -0
  119. package/tests/jest/reducers/bookCoverPreview.test.ts +81 -0
  120. package/tests/jest/reducers/classifications.test.ts +161 -0
  121. package/tests/jest/reducers/collections.test.ts +96 -0
  122. package/tests/jest/reducers/complaints.test.ts +166 -0
  123. package/tests/jest/reducers/createFetchEditReducer.test.ts +220 -0
  124. package/tests/jest/reducers/createRegisterLibraryReducer.test.ts +51 -0
  125. package/tests/jest/reducers/customListDetails.test.ts +120 -0
  126. package/tests/jest/reducers/customListEditor.test.ts +2823 -0
  127. package/tests/jest/reducers/managePatrons.test.ts +71 -0
  128. package/tests/jest/testUtils/formDataShim.test.ts +135 -0
  129. package/tests/jest/testUtils/formDataShim.ts +112 -0
  130. package/tests/jest/utils/sharedFunctions.test.ts +121 -0
  131. package/tsconfig.json +3 -4
  132. package/testReporter.js +0 -29
  133. package/tests/jest/README.md +0 -6
@@ -0,0 +1,158 @@
1
+ import * as React from "react";
2
+ import { screen } from "@testing-library/react";
3
+
4
+ import { renderWithProviders } from "../testUtils/withProviders";
5
+ import title from "../../../src/utils/title";
6
+
7
+ // CatalogPage does `require("@thepalaceproject/web-opds-client")` and mounts the OPDS
8
+ // Web Client, a heavy third-party render target that fetches on mount. Replace it
9
+ // with a marker that echoes the props CatalogPage hands it and the legacy
10
+ // `tab`/`library` child context CatalogPage supplies (read via `contextTypes`), so
11
+ // we can assert the wiring without the real catalog.
12
+ jest.mock("@thepalaceproject/web-opds-client", () => {
13
+ const React = require("react");
14
+ const PropTypes = require("prop-types");
15
+ const OPDSCatalogMock = (props, context) => (
16
+ <div
17
+ data-testid="opds-catalog"
18
+ data-collection-url={props.collectionUrl}
19
+ data-book-url={props.bookUrl}
20
+ data-tab={context.tab}
21
+ data-library={context.library && context.library()}
22
+ data-header-name={props.Header && props.Header.name}
23
+ data-book-details-container-name={
24
+ props.BookDetailsContainer && props.BookDetailsContainer.name
25
+ }
26
+ data-has-compute-breadcrumbs={String(!!props.computeBreadcrumbs)}
27
+ data-title-book={
28
+ props.pageTitleTemplate && props.pageTitleTemplate("Collection", "Book")
29
+ }
30
+ data-title-collection={
31
+ props.pageTitleTemplate && props.pageTitleTemplate("Collection", null)
32
+ }
33
+ />
34
+ );
35
+ OPDSCatalogMock.contextTypes = {
36
+ tab: PropTypes.string,
37
+ library: PropTypes.func,
38
+ };
39
+ return OPDSCatalogMock;
40
+ });
41
+
42
+ // When there is no library, CatalogPage renders WelcomePage, which mounts the real
43
+ // Header — that needs react-router v3 context and fetches on mount. Mock Header to a
44
+ // marker so the WelcomePage path stays self-contained.
45
+ jest.mock("../../../src/components/Header", () => ({
46
+ __esModule: true,
47
+ default: function Header() {
48
+ return <div data-testid="header" />;
49
+ },
50
+ }));
51
+
52
+ import CatalogPage from "../../../src/components/CatalogPage";
53
+ import Header from "../../../src/components/Header";
54
+ import BookDetailsContainer from "../../../src/components/BookDetailsContainer";
55
+
56
+ // The unit env has no `global.jsdom`, so compute the expected URLs from the real
57
+ // origin.
58
+ const origin = window.location.origin;
59
+
60
+ describe("CatalogPage", () => {
61
+ const baseParams = {
62
+ collectionUrl: "library/collectionurl",
63
+ bookUrl: "library/bookurl",
64
+ tab: "tab",
65
+ };
66
+
67
+ it("renders the OPDS catalog with expanded collection and book URLs", () => {
68
+ renderWithProviders(<CatalogPage params={baseParams} />);
69
+
70
+ const catalog = screen.getByTestId("opds-catalog");
71
+ expect(catalog).toHaveAttribute(
72
+ "data-collection-url",
73
+ `${origin}/library/collectionurl`
74
+ );
75
+ expect(catalog).toHaveAttribute(
76
+ "data-book-url",
77
+ `${origin}/library/works/bookurl`
78
+ );
79
+ });
80
+
81
+ it("forwards the admin header, book details container, breadcrumbs, and page-title template", () => {
82
+ renderWithProviders(<CatalogPage params={baseParams} />);
83
+
84
+ const catalog = screen.getByTestId("opds-catalog");
85
+ expect(catalog).toHaveAttribute("data-header-name", Header.name);
86
+ expect(catalog).toHaveAttribute(
87
+ "data-book-details-container-name",
88
+ BookDetailsContainer.name
89
+ );
90
+ expect(catalog).toHaveAttribute("data-has-compute-breadcrumbs", "true");
91
+ // pageTitleTemplate prefers the book title over the collection title.
92
+ expect(catalog).toHaveAttribute("data-title-book", title("Book"));
93
+ expect(catalog).toHaveAttribute(
94
+ "data-title-collection",
95
+ title("Collection")
96
+ );
97
+ });
98
+
99
+ it("expands a collection URL that already contains a query string", () => {
100
+ renderWithProviders(
101
+ <CatalogPage
102
+ params={{
103
+ ...baseParams,
104
+ collectionUrl: "library/collectionurl?samplequery=test",
105
+ }}
106
+ />
107
+ );
108
+
109
+ expect(screen.getByTestId("opds-catalog")).toHaveAttribute(
110
+ "data-collection-url",
111
+ `${origin}/library/collectionurl?samplequery=test`
112
+ );
113
+ });
114
+
115
+ it("puts the tab into the child context", () => {
116
+ renderWithProviders(<CatalogPage params={baseParams} />);
117
+ expect(screen.getByTestId("opds-catalog")).toHaveAttribute(
118
+ "data-tab",
119
+ "tab"
120
+ );
121
+ });
122
+
123
+ it("derives the child-context library from the collection URL", () => {
124
+ renderWithProviders(<CatalogPage params={baseParams} />);
125
+ expect(screen.getByTestId("opds-catalog")).toHaveAttribute(
126
+ "data-library",
127
+ "library"
128
+ );
129
+ });
130
+
131
+ it("derives the child-context library from the book URL when there is no collection URL", () => {
132
+ renderWithProviders(
133
+ <CatalogPage params={{ ...baseParams, collectionUrl: null }} />
134
+ );
135
+ expect(screen.getByTestId("opds-catalog")).toHaveAttribute(
136
+ "data-library",
137
+ "library"
138
+ );
139
+ });
140
+
141
+ it("renders the welcome page when there is no library", () => {
142
+ renderWithProviders(
143
+ <CatalogPage params={{ collectionUrl: null, bookUrl: null, tab: null }} />
144
+ );
145
+
146
+ expect(screen.queryByTestId("opds-catalog")).not.toBeInTheDocument();
147
+ expect(
148
+ screen.getByText(/Welcome to the Circulation Admin Interface!/)
149
+ ).toBeInTheDocument();
150
+ });
151
+
152
+ it("renders the footer", () => {
153
+ const { container } = renderWithProviders(
154
+ <CatalogPage params={baseParams} />
155
+ );
156
+ expect(container.querySelector("footer")).toBeInTheDocument();
157
+ });
158
+ });
@@ -0,0 +1,100 @@
1
+ import * as React from "react";
2
+ import { screen, within } from "@testing-library/react";
3
+ import CatalogServicesConnected, {
4
+ CatalogServices,
5
+ } from "../../../src/components/CatalogServices";
6
+ import renderWithContext from "../testUtils/renderWithContext";
7
+ import { renderWithProviders } from "../testUtils/withProviders";
8
+ import buildStore from "../../../src/store";
9
+ import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
10
+
11
+ describe("CatalogServices", () => {
12
+ const data = {
13
+ catalog_services: [
14
+ {
15
+ id: 2,
16
+ protocol: "test protocol",
17
+ settings: {
18
+ test_setting: "test setting",
19
+ },
20
+ libraries: [
21
+ {
22
+ short_name: "nypl",
23
+ test_library_setting: "test library setting",
24
+ },
25
+ ],
26
+ name: "nypl catalog",
27
+ },
28
+ ],
29
+ protocols: [
30
+ {
31
+ name: "test protocol",
32
+ label: "test protocol label",
33
+ settings: [],
34
+ },
35
+ ],
36
+ allLibraries: [
37
+ {
38
+ short_name: "nypl",
39
+ },
40
+ ],
41
+ };
42
+
43
+ const config = { csrfToken: "token", featureFlags: defaultFeatureFlags };
44
+
45
+ it("shows catalog service list", () => {
46
+ renderWithContext(
47
+ <CatalogServices
48
+ data={data}
49
+ fetchData={jest.fn()}
50
+ editItem={jest.fn().mockResolvedValue(undefined)}
51
+ csrfToken="token"
52
+ isFetching={false}
53
+ />,
54
+ config
55
+ );
56
+
57
+ const items = screen.getAllByRole("listitem");
58
+ expect(items).toHaveLength(1);
59
+ expect(items[0]).toHaveTextContent("nypl catalog: test protocol label");
60
+
61
+ const editLink = within(items[0]).getByRole("link");
62
+ expect(editLink).toHaveAttribute(
63
+ "href",
64
+ "/admin/web/config/catalogServices/edit/2"
65
+ );
66
+ });
67
+ });
68
+
69
+ describe("CatalogServices - connected wiring", () => {
70
+ afterEach(() => jest.restoreAllMocks());
71
+
72
+ it("wires mapStateToProps/mapDispatchToProps and renders the fetched list", async () => {
73
+ jest.spyOn(globalThis, "fetch").mockImplementation(
74
+ async () =>
75
+ new Response(
76
+ JSON.stringify({
77
+ catalog_services: [
78
+ { id: 2, protocol: "test protocol", name: "Test Catalog" },
79
+ ],
80
+ protocols: [{ name: "test protocol", label: "TP", settings: [] }],
81
+ }),
82
+ { status: 200, headers: { "Content-Type": "application/json" } }
83
+ )
84
+ );
85
+ const store = buildStore();
86
+
87
+ renderWithProviders(<CatalogServicesConnected csrfToken="token" />, {
88
+ reduxProviderProps: { store },
89
+ appConfigSettings: { roles: [{ role: "system" }] },
90
+ });
91
+
92
+ // The edit link only appears once the on-mount fetch resolves and
93
+ // mapStateToProps feeds the fetched service back in as props.
94
+ const editLink = await screen.findByRole("link", { name: /edit/i });
95
+ expect(editLink).toHaveAttribute(
96
+ "href",
97
+ "/admin/web/config/catalogServices/edit/2"
98
+ );
99
+ });
100
+ });
@@ -0,0 +1,163 @@
1
+ import * as React from "react";
2
+ import { installFormDataShim } from "../testUtils/formDataShim";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+
6
+ import { renderWithProviders } from "../testUtils/withProviders";
7
+ import ConnectedChangePasswordForm, {
8
+ ChangePasswordForm,
9
+ } from "../../../src/components/ChangePasswordForm";
10
+ // The reusable-components `Form` builds `new FormData(formElement)` on
11
+ // submit, which the unit jsdom env's undici FormData rejects; install the
12
+ // shared shim that reads the form's successful controls.
13
+ installFormDataShim();
14
+ afterEach(() => {
15
+ jest.restoreAllMocks();
16
+ });
17
+
18
+ // Render the unconnected class so tests can pass a `changePassword` spy and
19
+ // drive props directly. A separate test below renders the connected default
20
+ // export to cover the connect wiring (mapStateToProps/mapDispatchToProps).
21
+ const renderUnconnected = (
22
+ props: Partial<React.ComponentProps<typeof ChangePasswordForm>> = {}
23
+ ) =>
24
+ render(
25
+ <ChangePasswordForm
26
+ isFetching={false}
27
+ csrfToken="token"
28
+ changePassword={jest.fn().mockResolvedValue(undefined)}
29
+ {...props}
30
+ />
31
+ );
32
+
33
+ const passwordInput = (container: HTMLElement) =>
34
+ container.querySelector('input[name="password"]') as HTMLInputElement;
35
+ const confirmInput = (container: HTMLElement) =>
36
+ container.querySelector('input[name="confirm_password"]') as HTMLInputElement;
37
+
38
+ describe("ChangePasswordForm", () => {
39
+ it("shows an error message when there is a fetch error", () => {
40
+ const { container, rerender } = renderUnconnected();
41
+ expect(container.querySelector(".alert-danger")).not.toBeInTheDocument();
42
+
43
+ rerender(
44
+ <ChangePasswordForm
45
+ isFetching={false}
46
+ csrfToken="token"
47
+ changePassword={jest.fn()}
48
+ fetchError={{ status: 500, response: "response", url: "" }}
49
+ />
50
+ );
51
+
52
+ expect(container.querySelector(".alert-danger")).toBeInTheDocument();
53
+ expect(screen.getByText("Error: response")).toBeInTheDocument();
54
+ });
55
+
56
+ it("shows a loading indicator while fetching", () => {
57
+ const { rerender } = renderUnconnected({ isFetching: false });
58
+ expect(screen.queryByText("Loading")).not.toBeInTheDocument();
59
+
60
+ rerender(
61
+ <ChangePasswordForm
62
+ isFetching={true}
63
+ csrfToken="token"
64
+ changePassword={jest.fn()}
65
+ />
66
+ );
67
+
68
+ expect(screen.getByText("Loading")).toBeInTheDocument();
69
+ });
70
+
71
+ it("renders the two labeled password inputs", () => {
72
+ const { container } = renderUnconnected();
73
+
74
+ expect(passwordInput(container)).toHaveAttribute("type", "password");
75
+ expect(confirmInput(container)).toHaveAttribute("type", "password");
76
+
77
+ const labels = container.querySelectorAll("label.control-label");
78
+ expect(labels[0]).toHaveTextContent("New Password");
79
+ expect(labels[1]).toHaveTextContent("Confirm New Password");
80
+ });
81
+
82
+ it("does not submit a blank form", async () => {
83
+ const user = userEvent.setup();
84
+ const changePassword = jest.fn().mockResolvedValue(undefined);
85
+ renderUnconnected({ changePassword });
86
+
87
+ await user.click(screen.getByRole("button", { name: "Submit" }));
88
+
89
+ expect(changePassword).not.toHaveBeenCalled();
90
+ expect(screen.getByText("Fields cannot be blank.")).toBeInTheDocument();
91
+ });
92
+
93
+ // Rendered fresh, so the error message asserted below can only have come from
94
+ // this partial submit rather than lingering from an earlier blank one.
95
+ it("does not submit a partially blank form", async () => {
96
+ const user = userEvent.setup();
97
+ const changePassword = jest.fn().mockResolvedValue(undefined);
98
+ const { container } = renderUnconnected({ changePassword });
99
+ expect(screen.queryByText("Fields cannot be blank.")).toBeNull();
100
+
101
+ // Only the first field filled.
102
+ await user.type(passwordInput(container), "newPassword");
103
+ await user.click(screen.getByRole("button", { name: "Submit" }));
104
+
105
+ expect(changePassword).not.toHaveBeenCalled();
106
+ expect(screen.getByText("Fields cannot be blank.")).toBeInTheDocument();
107
+ });
108
+
109
+ it("does not submit when the passwords do not match", async () => {
110
+ const user = userEvent.setup();
111
+ const changePassword = jest.fn().mockResolvedValue(undefined);
112
+ const { container } = renderUnconnected({ changePassword });
113
+
114
+ await user.type(passwordInput(container), "newPassword");
115
+ await user.type(confirmInput(container), "somethingElse");
116
+ await user.click(screen.getByRole("button", { name: "Submit" }));
117
+
118
+ expect(changePassword).not.toHaveBeenCalled();
119
+ expect(screen.getByText("Passwords do not match.")).toBeInTheDocument();
120
+ });
121
+
122
+ it("submits the new password and shows a success message", async () => {
123
+ const user = userEvent.setup();
124
+ const changePassword = jest.fn().mockResolvedValue(undefined);
125
+ const { container } = renderUnconnected({ changePassword });
126
+
127
+ await user.type(passwordInput(container), "newPassword");
128
+ await user.type(confirmInput(container), "newPassword");
129
+ await user.click(screen.getByRole("button", { name: "Submit" }));
130
+
131
+ expect(changePassword).toHaveBeenCalledTimes(1);
132
+ const submittedData = changePassword.mock.calls[0][0];
133
+ expect(submittedData.get("password")).toBe("newPassword");
134
+ expect(submittedData.get("confirm_password")).toBe("newPassword");
135
+
136
+ expect(
137
+ await screen.findByText("Password changed successfully")
138
+ ).toBeInTheDocument();
139
+ });
140
+
141
+ it("dispatches the password change through the connected store", async () => {
142
+ const user = userEvent.setup();
143
+ const fetchSpy = jest
144
+ .spyOn(globalThis, "fetch")
145
+ .mockResolvedValue(new Response("ok", { status: 200 }));
146
+
147
+ const { container } = renderWithProviders(
148
+ <ConnectedChangePasswordForm csrfToken="token" />
149
+ );
150
+
151
+ await user.type(passwordInput(container), "newPassword");
152
+ await user.type(confirmInput(container), "newPassword");
153
+ await user.click(screen.getByRole("button", { name: "Submit" }));
154
+
155
+ expect(
156
+ await screen.findByText("Password changed successfully")
157
+ ).toBeInTheDocument();
158
+ expect(fetchSpy).toHaveBeenCalledWith(
159
+ "/admin/change_password",
160
+ expect.objectContaining({ method: "POST" })
161
+ );
162
+ });
163
+ });
@@ -0,0 +1,41 @@
1
+ import * as React from "react";
2
+ import { render, screen, within } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import CirculationEventsDownloadForm from "../../../src/components/CirculationEventsDownloadForm";
6
+
7
+ describe("CirculationEventsDownloadForm", () => {
8
+ let hide: jest.Mock;
9
+
10
+ beforeEach(() => {
11
+ hide = jest.fn();
12
+ render(<CirculationEventsDownloadForm show={true} hide={hide} />);
13
+ });
14
+
15
+ it("renders start date and end date inputs", () => {
16
+ // The modal renders into a portal, so query the whole document.
17
+ const dates =
18
+ document.querySelectorAll<HTMLInputElement>("input[type='date']");
19
+ expect(dates).toHaveLength(2);
20
+ expect(dates[0].name).toBe("dateStart");
21
+ expect(dates[1].name).toBe("dateEnd");
22
+ });
23
+
24
+ it("renders download and close buttons", async () => {
25
+ const user = userEvent.setup();
26
+
27
+ expect(
28
+ screen.getByRole("button", { name: "Download" })
29
+ ).toBeInTheDocument();
30
+
31
+ // The modal header's dismiss control is also labeled "Close" (its visible
32
+ // "×" is aria-hidden), so scope to the footer for the reusable Close button.
33
+ const footer = document.querySelector<HTMLElement>(".modal-footer");
34
+ const closeButton = within(footer).getByRole("button", { name: "Close" });
35
+ expect(closeButton).toBeInTheDocument();
36
+
37
+ await user.click(closeButton);
38
+
39
+ expect(hide).toHaveBeenCalledTimes(1);
40
+ });
41
+ });
@@ -0,0 +1,166 @@
1
+ import * as React from "react";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import { renderWithProviders } from "../testUtils/withProviders";
6
+ import buildStore from "../../../src/store";
7
+ import genreData from "../fixtures/genreData";
8
+ import classificationsData from "../fixtures/classificationsData";
9
+ // Render the CONNECTED default export so that mapStateToProps / mapDispatchToProps
10
+ // are exercised. The genre tree and classifications are fetched on mount and fed
11
+ // back in through the Redux store; `book` and `refreshCatalog` are own props.
12
+ import Classifications, {
13
+ Classifications as UnconnectedClassifications,
14
+ } from "../../../src/components/Classifications";
15
+
16
+ // A `targetAgeRange` is required: ClassificationsForm reads it in bookChanged on
17
+ // re-render (the legacy enzyme test used shallow, so the child never re-rendered).
18
+ const bookData = {
19
+ title: "title",
20
+ fiction: true,
21
+ categories: ["Space Opera"],
22
+ targetAgeRange: [null, null],
23
+ };
24
+ const bookUrl = "works/1234";
25
+ const classificationsUrl = "admin/works/1234/classifications";
26
+
27
+ const jsonResponse = (body: unknown) =>
28
+ new Response(JSON.stringify(body), {
29
+ status: 200,
30
+ headers: { "Content-Type": "application/json" },
31
+ });
32
+
33
+ // Resolves each fetch with the appropriate body based on the request URL/method.
34
+ const stubFetch = () =>
35
+ jest
36
+ .spyOn(globalThis, "fetch")
37
+ .mockImplementation(async (url, opts?: RequestInit) => {
38
+ const u = String(url);
39
+ if (opts?.method === "POST") {
40
+ return new Response("success", { status: 200 });
41
+ }
42
+ if (u === "/admin/genres") {
43
+ return jsonResponse(genreData);
44
+ }
45
+ if (u.endsWith("/classifications")) {
46
+ return jsonResponse({ classifications: classificationsData });
47
+ }
48
+ // getBookData (OPDS) on refresh — non-OPDS body so it settles quietly.
49
+ return new Response("{}", { status: 200 });
50
+ });
51
+
52
+ const renderConnected = (
53
+ overrides: Partial<{ refreshCatalog: () => Promise<any> }> = {}
54
+ ) => {
55
+ const store = buildStore();
56
+ const refreshCatalog =
57
+ overrides.refreshCatalog ?? jest.fn().mockResolvedValue(undefined);
58
+ const result = renderWithProviders(
59
+ <Classifications
60
+ store={store}
61
+ csrfToken="token"
62
+ bookUrl={bookUrl}
63
+ book={bookData as any}
64
+ refreshCatalog={refreshCatalog}
65
+ />,
66
+ { reduxProviderProps: { store } }
67
+ );
68
+ return { ...result, refreshCatalog };
69
+ };
70
+
71
+ describe("Classifications", () => {
72
+ afterEach(() => {
73
+ jest.restoreAllMocks();
74
+ });
75
+
76
+ it("shows the book title", async () => {
77
+ stubFetch();
78
+ renderConnected();
79
+ expect(
80
+ await screen.findByRole("heading", { level: 2, name: "title" })
81
+ ).toBeInTheDocument();
82
+ });
83
+
84
+ it("shows the updating indicator while fetching", () => {
85
+ // A never-resolving fetch keeps the panel in its fetching state.
86
+ jest
87
+ .spyOn(globalThis, "fetch")
88
+ .mockImplementation(() => new Promise(() => {}));
89
+ const { container } = renderConnected();
90
+ expect(container.querySelector(".updating-loader")).toBeInTheDocument();
91
+ });
92
+
93
+ it("shows the classifications form and table once the data loads, and hides the updating indicator", async () => {
94
+ stubFetch();
95
+ const { container } = renderConnected();
96
+
97
+ // The form renders once the genre tree loads.
98
+ expect(
99
+ await screen.findByRole("button", { name: "Save" })
100
+ ).toBeInTheDocument();
101
+ // The table renders once the classifications load.
102
+ expect(
103
+ screen.getByRole("heading", { name: "Related Classifications" })
104
+ ).toBeInTheDocument();
105
+ // A sample classification from the fixture shows in the table.
106
+ expect(screen.getByText("FICTION / Ghost")).toBeInTheDocument();
107
+ // Fetching is done, so the updating indicator is gone.
108
+ expect(container.querySelector(".updating-loader")).not.toBeInTheDocument();
109
+ });
110
+
111
+ it("shows a fetch error", () => {
112
+ // A network failure on mount would reject the (uncaught) fetch promise and
113
+ // crash the worker, so drive the error through the prop that connect derives.
114
+ render(
115
+ <UnconnectedClassifications
116
+ store={buildStore() as any}
117
+ csrfToken="token"
118
+ bookUrl={bookUrl}
119
+ book={bookData as any}
120
+ refreshCatalog={jest.fn().mockResolvedValue(undefined)}
121
+ bookAdminUrl="book admin url"
122
+ fetchError={{ status: 500, url: "url", response: "response" } as any}
123
+ fetchGenreTree={jest.fn()}
124
+ fetchClassifications={jest.fn()}
125
+ fetchBook={jest.fn()}
126
+ editClassifications={jest.fn().mockResolvedValue(undefined)}
127
+ genreTree={null}
128
+ classifications={null}
129
+ isFetching={false}
130
+ />
131
+ );
132
+ expect(screen.getByText(/Error: response/)).toBeInTheDocument();
133
+ });
134
+
135
+ it("fetches the genre tree and classifications on mount", async () => {
136
+ const fetchSpy = stubFetch();
137
+ renderConnected();
138
+ await screen.findByRole("button", { name: "Save" });
139
+
140
+ const urls = fetchSpy.mock.calls.map(([u]) => String(u));
141
+ expect(urls).toContain("/admin/genres");
142
+ expect(urls).toContain(classificationsUrl);
143
+ });
144
+
145
+ it("refreshes the book, classifications, and catalog after editing classifications", async () => {
146
+ const user = userEvent.setup();
147
+ const fetchSpy = stubFetch();
148
+ const { container, refreshCatalog } = renderConnected();
149
+
150
+ await screen.findByRole("button", { name: "Save" });
151
+
152
+ // The form requires an audience selection before it will submit.
153
+ await user.selectOptions(
154
+ container.querySelector('select[name="audience"]') as HTMLSelectElement,
155
+ "Adult"
156
+ );
157
+ await user.click(screen.getByRole("button", { name: "Save" }));
158
+
159
+ // Editing POSTs the edit_classifications URL, then refreshes the catalog.
160
+ await waitFor(() => expect(refreshCatalog).toHaveBeenCalledTimes(1));
161
+ const postCall = fetchSpy.mock.calls.find(
162
+ ([, opts]) => (opts as RequestInit)?.method === "POST"
163
+ );
164
+ expect(String(postCall[0])).toBe("admin/works/1234/edit_classifications");
165
+ });
166
+ });