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

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.
@@ -0,0 +1,171 @@
1
+ import * as React from "react";
2
+ import { installFormDataShim } from "../testUtils/formDataShim";
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+
6
+ import ComplaintForm from "../../../src/components/ComplaintForm";
7
+ installFormDataShim();
8
+
9
+ // ComplaintForm's default export is a plain (unconnected) class component, so a
10
+ // bare `render` is sufficient — no providers/store/context are required.
11
+ const renderForm = (
12
+ props: Partial<React.ComponentProps<typeof ComplaintForm>> = {}
13
+ ) =>
14
+ render(
15
+ <ComplaintForm
16
+ disabled={false}
17
+ complaintUrl="complaint url"
18
+ postComplaint={jest.fn().mockResolvedValue(undefined)}
19
+ refreshComplaints={jest.fn()}
20
+ {...props}
21
+ />
22
+ );
23
+
24
+ const getSelect = (container: HTMLElement) =>
25
+ container.querySelector("select") as HTMLSelectElement;
26
+
27
+ describe("ComplaintForm", () => {
28
+ describe("rendering", () => {
29
+ it("shows a select field with a default value", () => {
30
+ const { container } = renderForm();
31
+ const select = getSelect(container);
32
+ expect(select).toBeInTheDocument();
33
+ expect(select).not.toBeDisabled();
34
+ expect(screen.getAllByRole("option")[0]).toHaveTextContent(
35
+ "Complaint type"
36
+ );
37
+ });
38
+
39
+ it("shows complaint type options", () => {
40
+ renderForm();
41
+ const values = screen
42
+ .getAllByRole("option")
43
+ .map((option) => (option as HTMLOptionElement).value);
44
+ expect(values).toStrictEqual([
45
+ "",
46
+ "cannot-issue-loan",
47
+ "cannot-render",
48
+ "wrong-title",
49
+ "wrong-author",
50
+ "wrong-audience",
51
+ "cannot-fulfill-loan",
52
+ "bad-description",
53
+ "cannot-return",
54
+ "bad-cover-image",
55
+ "wrong-medium",
56
+ "wrong-age-range",
57
+ "wrong-genre",
58
+ ]);
59
+ });
60
+
61
+ it("formats complaint type options", () => {
62
+ renderForm();
63
+ const texts = screen
64
+ .getAllByRole("option")
65
+ .map((option) => option.textContent);
66
+ expect(texts).toStrictEqual([
67
+ "Complaint type",
68
+ "Cannot issue loan",
69
+ "Cannot render",
70
+ "Wrong title",
71
+ "Wrong author",
72
+ "Wrong audience",
73
+ "Cannot fulfill loan",
74
+ "Bad description",
75
+ "Cannot return",
76
+ "Bad cover image",
77
+ "Wrong medium",
78
+ "Wrong age range",
79
+ "Wrong genre",
80
+ ]);
81
+ });
82
+
83
+ it("shows a submit button", () => {
84
+ renderForm();
85
+ expect(
86
+ screen.getByRole("button", { name: "Submit" })
87
+ ).toBeInTheDocument();
88
+ });
89
+
90
+ it("disables the select and submit button when disabled", () => {
91
+ const { container } = renderForm({ disabled: true });
92
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
93
+ expect(getSelect(container)).toBeDisabled();
94
+ });
95
+ });
96
+
97
+ describe("behavior", () => {
98
+ it("posts complaints", async () => {
99
+ const user = userEvent.setup();
100
+ const postComplaint = jest.fn().mockResolvedValue(undefined);
101
+ const { container } = renderForm({ postComplaint });
102
+
103
+ await user.selectOptions(getSelect(container), "bad-description");
104
+ await user.click(screen.getByRole("button", { name: "Submit" }));
105
+
106
+ expect(postComplaint).toHaveBeenCalledTimes(1);
107
+ expect(postComplaint.mock.calls[0][0]).toBe("complaint url");
108
+ expect(postComplaint.mock.calls[0][1].type).toBe(
109
+ "http://librarysimplified.org/terms/problem/bad-description"
110
+ );
111
+ });
112
+
113
+ it("refreshes complaints after a successful post", async () => {
114
+ const user = userEvent.setup();
115
+ const refreshComplaints = jest.fn();
116
+ const { container } = renderForm({
117
+ postComplaint: jest.fn().mockResolvedValue(undefined),
118
+ refreshComplaints,
119
+ });
120
+
121
+ await user.selectOptions(getSelect(container), "bad-description");
122
+ await user.click(screen.getByRole("button", { name: "Submit" }));
123
+
124
+ await waitFor(() => expect(refreshComplaints).toHaveBeenCalledTimes(1));
125
+ });
126
+
127
+ it("clears the selected complaint type after a successful post", async () => {
128
+ const user = userEvent.setup();
129
+ const { container } = renderForm({
130
+ postComplaint: jest.fn().mockResolvedValue(undefined),
131
+ });
132
+
133
+ const select = getSelect(container);
134
+ await user.selectOptions(select, "bad-description");
135
+ expect(select).toHaveValue("bad-description");
136
+
137
+ await user.click(screen.getByRole("button", { name: "Submit" }));
138
+
139
+ await waitFor(() => expect(getSelect(container)).toHaveValue(""));
140
+ });
141
+
142
+ it("displays an error if no type is selected", async () => {
143
+ const user = userEvent.setup();
144
+ const postComplaint = jest.fn().mockResolvedValue(undefined);
145
+ renderForm({ postComplaint });
146
+
147
+ await user.click(screen.getByRole("button", { name: "Submit" }));
148
+
149
+ expect(
150
+ await screen.findByText("You must select a complaint type!")
151
+ ).toBeInTheDocument();
152
+ expect(screen.getByRole("alert")).toHaveTextContent(
153
+ "You must select a complaint type!"
154
+ );
155
+ expect(postComplaint).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it("shows an error message if the post fails", async () => {
159
+ const user = userEvent.setup();
160
+ const postComplaint = jest.fn().mockRejectedValue(new Error("nope"));
161
+ const { container } = renderForm({ postComplaint });
162
+
163
+ await user.selectOptions(getSelect(container), "bad-description");
164
+ await user.click(screen.getByRole("button", { name: "Submit" }));
165
+
166
+ expect(
167
+ await screen.findByText("Couldn't post complaint.")
168
+ ).toBeInTheDocument();
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,259 @@
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 ConnectedComplaints, {
6
+ Complaints,
7
+ } from "../../../src/components/Complaints";
8
+ import { renderWithProviders } from "../testUtils/withProviders";
9
+
10
+ const bookData = {
11
+ id: "id",
12
+ title: "test title",
13
+ issuesLink: {
14
+ href: "issues url",
15
+ rel: "issues",
16
+ },
17
+ };
18
+
19
+ const complaintsData = {
20
+ "http://librarysimplified.org/terms/problem/test-type": 5,
21
+ "http://librarysimplified.org/terms/problem/other-type": 3,
22
+ "http://librarysimplified.org/terms/problem/last-type": 1,
23
+ };
24
+
25
+ // The unconnected `Complaints` takes its data + dispatch functions as props, so
26
+ // a bare `render` (no store) is enough for the rendering/behavior assertions.
27
+ // One separate test below renders the connected default export through the real
28
+ // providers to cover the connect() wiring (mapStateToProps/mapDispatchToProps).
29
+ const renderComplaints = (
30
+ props: Partial<React.ComponentProps<typeof Complaints>> = {}
31
+ ) =>
32
+ render(
33
+ <Complaints
34
+ csrfToken="token"
35
+ bookUrl="book url"
36
+ book={bookData}
37
+ complaints={complaintsData}
38
+ fetchComplaints={jest.fn()}
39
+ postComplaint={jest.fn()}
40
+ refreshCatalog={jest.fn()}
41
+ {...props}
42
+ />
43
+ );
44
+
45
+ describe("Complaints", () => {
46
+ afterEach(() => {
47
+ jest.restoreAllMocks();
48
+ });
49
+
50
+ describe("rendering", () => {
51
+ it("shows the book title", () => {
52
+ renderComplaints();
53
+ expect(
54
+ screen.getByRole("heading", { name: "test title" })
55
+ ).toBeInTheDocument();
56
+ });
57
+
58
+ it("shows complaints with their counts", () => {
59
+ const { container } = renderComplaints();
60
+ const types = Array.from(
61
+ container.querySelectorAll(".complaint-type")
62
+ ).map((el) => el.textContent);
63
+ const counts = Array.from(
64
+ container.querySelectorAll(".complaint-count")
65
+ ).map((el) => parseInt(el.textContent || "", 10));
66
+ expect(types).toStrictEqual(["Test type", "Other type", "Last type"]);
67
+ expect(counts).toStrictEqual([5, 3, 1]);
68
+ });
69
+
70
+ it("shows simplified complaint types", () => {
71
+ const { container } = renderComplaints();
72
+ const types = Array.from(
73
+ container.querySelectorAll(".complaint-type")
74
+ ).map((el) => el.textContent);
75
+ expect(types).toStrictEqual(["Test type", "Other type", "Last type"]);
76
+ });
77
+
78
+ it("shows an unrecognized complaint type unchanged", () => {
79
+ // A type that does not match the /terms/problem/ pattern is rendered as-is.
80
+ const { container } = renderComplaints({
81
+ complaints: { "custom-complaint": 2 },
82
+ });
83
+ const types = Array.from(
84
+ container.querySelectorAll(".complaint-type")
85
+ ).map((el) => el.textContent);
86
+ expect(types).toStrictEqual(["custom-complaint"]);
87
+ });
88
+
89
+ it("shows an enabled resolve button for each complaint type", () => {
90
+ renderComplaints();
91
+ const buttons = screen.getAllByRole("button", { name: "Resolve" });
92
+ expect(buttons).toHaveLength(Object.keys(complaintsData).length);
93
+ buttons.forEach((button) => expect(button).not.toBeDisabled());
94
+ });
95
+
96
+ it("shows an enabled complaint form when the book has an issues link", () => {
97
+ const { container } = renderComplaints();
98
+ // The ComplaintForm is only rendered when `book.issuesLink` exists, so its
99
+ // presence (with an enabled select) reflects that the form was wired up.
100
+ expect(screen.getByText("Add Complaint")).toBeInTheDocument();
101
+ const select = container.querySelector("select") as HTMLSelectElement;
102
+ expect(select).toBeInTheDocument();
103
+ expect(select).not.toBeDisabled();
104
+ });
105
+
106
+ it("shows a fetch error and re-fetches when trying again", async () => {
107
+ const user = userEvent.setup();
108
+ const fetchComplaints = jest.fn();
109
+ const fetchError = { status: 401, response: "test", url: "test url" };
110
+ const { container } = render(
111
+ <Complaints
112
+ csrfToken="token"
113
+ bookUrl="book url"
114
+ book={{ id: "id", title: "test book" }}
115
+ fetchError={fetchError}
116
+ fetchComplaints={fetchComplaints}
117
+ postComplaint={jest.fn()}
118
+ refreshCatalog={jest.fn()}
119
+ />
120
+ );
121
+
122
+ // The error alert is rendered from the fetchError prop.
123
+ expect(container.querySelector(".alert-danger")).toBeInTheDocument();
124
+ // fetchComplaints is called once on mount (bookUrl is set).
125
+ expect(fetchComplaints).toHaveBeenCalledTimes(1);
126
+
127
+ await user.click(screen.getByRole("button", { name: "Try again" }));
128
+
129
+ expect(fetchComplaints).toHaveBeenCalledTimes(2);
130
+ expect(fetchComplaints.mock.calls[1][0]).toBe("book url/complaints");
131
+ });
132
+ });
133
+
134
+ describe("behavior", () => {
135
+ const singleComplaint = {
136
+ "http://librarysimplified.org/terms/problem/test-type": 2,
137
+ };
138
+ const bookUrl = "http://example.com/works/fakeid";
139
+
140
+ it("fetches complaints on mount", () => {
141
+ const fetchComplaints = jest.fn();
142
+ render(
143
+ <Complaints
144
+ csrfToken="token"
145
+ book={{ id: "id", title: "test book" }}
146
+ bookUrl={bookUrl}
147
+ complaints={singleComplaint}
148
+ fetchComplaints={fetchComplaints}
149
+ refreshCatalog={jest.fn()}
150
+ resolveComplaints={jest.fn().mockResolvedValue(undefined)}
151
+ />
152
+ );
153
+
154
+ expect(fetchComplaints).toHaveBeenCalledTimes(1);
155
+ expect(fetchComplaints.mock.calls[0][0]).toBe(
156
+ "http://example.com/admin/works/fakeid/complaints"
157
+ );
158
+ });
159
+
160
+ it("resolves a complaint type when its resolve button is clicked", async () => {
161
+ const user = userEvent.setup();
162
+ jest.spyOn(window, "confirm").mockReturnValue(true);
163
+ const resolveComplaints = jest.fn().mockResolvedValue(undefined);
164
+ render(
165
+ <Complaints
166
+ csrfToken="token"
167
+ book={{ id: "id", title: "test book" }}
168
+ bookUrl={bookUrl}
169
+ complaints={singleComplaint}
170
+ fetchComplaints={jest.fn()}
171
+ refreshCatalog={jest.fn()}
172
+ resolveComplaints={resolveComplaints}
173
+ />
174
+ );
175
+
176
+ await user.click(screen.getByRole("button", { name: "Resolve" }));
177
+
178
+ expect(resolveComplaints).toHaveBeenCalledTimes(1);
179
+ expect(resolveComplaints.mock.calls[0][0]).toBe(
180
+ "http://example.com/admin/works/fakeid/resolve_complaints"
181
+ );
182
+ // The FormData sent along carries the complaint type being resolved.
183
+ const submittedData = resolveComplaints.mock.calls[0][1];
184
+ expect(submittedData.get("type")).toBe(
185
+ "http://librarysimplified.org/terms/problem/test-type"
186
+ );
187
+ });
188
+
189
+ it("re-fetches complaints and refreshes the catalog after resolving", async () => {
190
+ const user = userEvent.setup();
191
+ jest.spyOn(window, "confirm").mockReturnValue(true);
192
+ const fetchComplaints = jest.fn();
193
+ const refreshCatalog = jest.fn();
194
+ const resolveComplaints = jest.fn().mockResolvedValue(undefined);
195
+ render(
196
+ <Complaints
197
+ csrfToken="token"
198
+ book={{ id: "id", title: "test book" }}
199
+ bookUrl={bookUrl}
200
+ complaints={singleComplaint}
201
+ fetchComplaints={fetchComplaints}
202
+ refreshCatalog={refreshCatalog}
203
+ resolveComplaints={resolveComplaints}
204
+ />
205
+ );
206
+
207
+ await user.click(screen.getByRole("button", { name: "Resolve" }));
208
+
209
+ await waitFor(() => expect(refreshCatalog).toHaveBeenCalledTimes(1));
210
+ expect(resolveComplaints).toHaveBeenCalledTimes(1);
211
+ // Once on mount, and again as part of the post-resolve refresh.
212
+ expect(fetchComplaints).toHaveBeenCalledTimes(2);
213
+ });
214
+ });
215
+
216
+ describe("connect() wiring", () => {
217
+ it("renders the connected default export, mapping state and dispatch", async () => {
218
+ const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue(
219
+ new Response(JSON.stringify({ complaints: {} }), {
220
+ status: 200,
221
+ headers: { "Content-Type": "application/json" },
222
+ })
223
+ );
224
+
225
+ const { container } = renderWithProviders(
226
+ <ConnectedComplaints
227
+ bookUrl="http://example.com/works/fakeid"
228
+ book={{ id: "id", title: "connected book" }}
229
+ csrfToken="token"
230
+ refreshCatalog={jest.fn().mockResolvedValue(undefined)}
231
+ />
232
+ );
233
+
234
+ // Own prop flows through the connected component.
235
+ expect(
236
+ screen.getByRole("heading", { name: "connected book" })
237
+ ).toBeInTheDocument();
238
+
239
+ // mapDispatchToProps.fetchComplaints ran on mount and hit the network.
240
+ await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
241
+ expect(fetchSpy.mock.calls[0][0]).toBe(
242
+ "http://example.com/admin/works/fakeid/complaints"
243
+ );
244
+
245
+ // Let the request/load cycle settle (isFetching true -> false), which also
246
+ // flushes the pending fetch so it cannot reject during teardown.
247
+ await waitFor(() =>
248
+ expect(
249
+ container.querySelector(".updating-loader")
250
+ ).not.toBeInTheDocument()
251
+ );
252
+
253
+ // mapStateToProps: no complaints in the store -> "None found." is shown.
254
+ expect(screen.getByText("None found.")).toBeInTheDocument();
255
+
256
+ jest.restoreAllMocks();
257
+ });
258
+ });
259
+ });
@@ -0,0 +1,138 @@
1
+ import * as React from "react";
2
+ import * as PropTypes from "prop-types";
3
+ import { render, screen } from "@testing-library/react";
4
+
5
+ import ContextProvider from "../../../src/components/ContextProvider";
6
+ import Admin from "../../../src/models/Admin";
7
+ import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
8
+ import { ConfigurationSettings } from "../../../src/interfaces";
9
+
10
+ // Reads everything ContextProvider puts on the legacy React context — the real
11
+ // contract the app's pages consume — so we can assert on it without reaching for
12
+ // the component instance.
13
+ let captured: any;
14
+ class Capture extends React.Component {
15
+ static contextTypes = {
16
+ editorStore: PropTypes.object,
17
+ csrfToken: PropTypes.string,
18
+ settingUp: PropTypes.bool,
19
+ admin: PropTypes.object,
20
+ featureFlags: PropTypes.object,
21
+ pathFor: PropTypes.func,
22
+ };
23
+ render() {
24
+ captured = this.context;
25
+ return <div>child rendered</div>;
26
+ }
27
+ }
28
+
29
+ const renderWithConfig = (config: Partial<ConfigurationSettings>) =>
30
+ render(
31
+ <ContextProvider config={config as ConfigurationSettings}>
32
+ <Capture />
33
+ </ContextProvider>
34
+ );
35
+
36
+ describe("ContextProvider", () => {
37
+ beforeEach(() => {
38
+ captured = undefined;
39
+ });
40
+
41
+ it("provides child context", () => {
42
+ renderWithConfig({
43
+ csrfToken: "token",
44
+ featureFlags: defaultFeatureFlags,
45
+ roles: [{ role: "system" }],
46
+ email: "email",
47
+ });
48
+
49
+ expect(captured.editorStore.getState().editor).toBeTruthy();
50
+ expect(captured.editorStore.getState().catalog).toBeTruthy();
51
+ expect(captured.csrfToken).toBe("token");
52
+ expect(captured.settingUp).toBeFalsy();
53
+ expect(captured.admin instanceof Admin).toBe(true);
54
+ expect(captured.admin.isSystemAdmin()).toBe(true);
55
+ expect(captured.admin.email).toBe("email");
56
+ });
57
+
58
+ it("saves the enableAutoList feature flag to the store", () => {
59
+ renderWithConfig({
60
+ csrfToken: "token",
61
+ featureFlags: { enableAutoList: true },
62
+ });
63
+
64
+ expect(
65
+ captured.editorStore.getState().editor.customListEditor
66
+ .isAutoUpdateEnabled
67
+ ).toBe(true);
68
+ });
69
+
70
+ it("renders its child", () => {
71
+ renderWithConfig({ csrfToken: "token", featureFlags: {} });
72
+ expect(screen.getByText("child rendered")).toBeInTheDocument();
73
+ });
74
+
75
+ it("passes pathFor down through context", () => {
76
+ renderWithConfig({ csrfToken: "token", featureFlags: {} });
77
+ expect(typeof captured.pathFor).toBe("function");
78
+ });
79
+
80
+ describe("pathFor", () => {
81
+ // prepareCollectionUrl / prepareBookUrl strip only `document.location.origin`,
82
+ // so using the real jsdom origin as the host yields the same encoded output the
83
+ // legacy test asserted (it forced the origin to example.com via jsdom.reconfigure,
84
+ // which the unit test environment does not provide). These calls also exercise
85
+ // prepareCollectionUrl and prepareBookUrl transitively.
86
+ const host = window.location.origin;
87
+ const collectionUrl = `${host}/groups/eng/Adult%20Fiction`;
88
+ const bookUrl = `${host}/library/works/Axis%20360/Axis%20360%20ID/0016201449`;
89
+ const preparedCollection = "groups%2Feng%2FAdult%2520Fiction";
90
+ const preparedBook =
91
+ "library%2FAxis%2520360%2FAxis%2520360%2520ID%2F0016201449";
92
+
93
+ beforeEach(() => {
94
+ renderWithConfig({ csrfToken: "token", featureFlags: {} });
95
+ });
96
+
97
+ it("returns a path with collection, book, and tab", () => {
98
+ expect(captured.pathFor(collectionUrl, bookUrl, "tab")).toBe(
99
+ `/admin/web/collection/${preparedCollection}/book/${preparedBook}/tab/tab`
100
+ );
101
+ });
102
+
103
+ it("returns a path with collection and book", () => {
104
+ expect(captured.pathFor(collectionUrl, bookUrl, null)).toBe(
105
+ `/admin/web/collection/${preparedCollection}/book/${preparedBook}`
106
+ );
107
+ });
108
+
109
+ it("returns a path with only a collection", () => {
110
+ expect(captured.pathFor(collectionUrl, null, null)).toBe(
111
+ `/admin/web/collection/${preparedCollection}`
112
+ );
113
+ });
114
+
115
+ it("returns a path with only a book", () => {
116
+ expect(captured.pathFor(null, bookUrl, null)).toBe(
117
+ `/admin/web/book/${preparedBook}`
118
+ );
119
+ });
120
+
121
+ it("returns a path with a book and tab", () => {
122
+ expect(captured.pathFor(null, bookUrl, "tab")).toBe(
123
+ `/admin/web/book/${preparedBook}/tab/tab`
124
+ );
125
+ });
126
+
127
+ it("returns the base path with no collection, book, or tab", () => {
128
+ expect(captured.pathFor(null, null, null)).toBe("/admin/web");
129
+ });
130
+
131
+ it("leaves a book url unchanged when it does not match the works pattern", () => {
132
+ // prepareBookUrl returns the url as-is when the /works/ regexp misses.
133
+ expect(captured.pathFor(null, "not-a-works-url", null)).toBe(
134
+ "/admin/web/book/not-a-works-url"
135
+ );
136
+ });
137
+ });
138
+ });