@thepalaceproject/circulation-admin 1.36.0 → 1.37.0-post.2

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.
package/package.json CHANGED
@@ -152,5 +152,5 @@
152
152
  "*.{js,jsx,ts,tsx,css,md}": "prettier --write",
153
153
  "*.{js,css,md}": "prettier --write"
154
154
  },
155
- "version": "1.36.0"
155
+ "version": "1.37.0-post.2"
156
156
  }
@@ -0,0 +1,78 @@
1
+ import * as fetchMock from "fetch-mock-jest";
2
+ import { validatePatronBlockingRuleExpression } from "../../../src/api/patronBlockingRules";
3
+ import { PatronBlockingRule } from "../../../src/interfaces";
4
+
5
+ const VALIDATE_URL = "/admin/patron_auth_service_validate_patron_blocking_rule";
6
+
7
+ const sampleRule: PatronBlockingRule = {
8
+ name: "Fine Check",
9
+ rule: "{fines} > 10.0",
10
+ };
11
+
12
+ describe("validatePatronBlockingRuleExpression", () => {
13
+ afterEach(() => {
14
+ fetchMock.mockReset();
15
+ });
16
+
17
+ it("returns null on a 200 response", async () => {
18
+ fetchMock.post(VALIDATE_URL, { status: 200 });
19
+ const result = await validatePatronBlockingRuleExpression(
20
+ 42,
21
+ sampleRule,
22
+ "test-token"
23
+ );
24
+ expect(result).toBeNull();
25
+ });
26
+
27
+ it("returns the detail string from a 400 response", async () => {
28
+ fetchMock.post(VALIDATE_URL, {
29
+ status: 400,
30
+ body: { detail: "Unknown placeholder: {unknown_field}" },
31
+ });
32
+ const result = await validatePatronBlockingRuleExpression(
33
+ 42,
34
+ sampleRule,
35
+ "test-token"
36
+ );
37
+ expect(result).toBe("Unknown placeholder: {unknown_field}");
38
+ });
39
+
40
+ it("returns a fallback string when a 400 response body has no detail", async () => {
41
+ fetchMock.post(VALIDATE_URL, { status: 400, body: {} });
42
+ const result = await validatePatronBlockingRuleExpression(
43
+ 42,
44
+ sampleRule,
45
+ "test-token"
46
+ );
47
+ expect(result).not.toBeNull();
48
+ expect(typeof result).toBe("string");
49
+ });
50
+
51
+ it("sends the correct URL, method, and CSRF header", async () => {
52
+ fetchMock.post(VALIDATE_URL, { status: 200 });
53
+ await validatePatronBlockingRuleExpression(42, sampleRule, "my-csrf-token");
54
+ expect(fetchMock).toHaveBeenCalledWith(
55
+ VALIDATE_URL,
56
+ expect.objectContaining({
57
+ method: "POST",
58
+ headers: expect.objectContaining({ "X-CSRF-Token": "my-csrf-token" }),
59
+ })
60
+ );
61
+ });
62
+
63
+ it("omits service_id from the form body when serviceId is undefined", async () => {
64
+ fetchMock.post(VALIDATE_URL, { status: 200 });
65
+ // Should not throw and should still make the request (server returns "save first" error)
66
+ const result = await validatePatronBlockingRuleExpression(
67
+ undefined,
68
+ sampleRule,
69
+ "tok"
70
+ );
71
+ expect(fetchMock).toHaveBeenCalledWith(
72
+ VALIDATE_URL,
73
+ expect.objectContaining({ method: "POST" })
74
+ );
75
+ // Server would return an error detail in a real call; here it returns 200 (mocked)
76
+ expect(result).toBeNull();
77
+ });
78
+ });
@@ -0,0 +1,281 @@
1
+ import * as React from "react";
2
+ import { setupServer } from "msw/node";
3
+ import { http, HttpResponse } from "msw";
4
+ import { QueryClient } from "@tanstack/react-query";
5
+ import { renderWithProviders } from "../testUtils/withProviders";
6
+ import DebugAuthentication from "../../../src/components/DebugAuthentication";
7
+ import {
8
+ AuthMethodsResponse,
9
+ PatronDebugResponse,
10
+ } from "../../../src/api/patronDebug";
11
+ import { waitFor, fireEvent } from "@testing-library/react";
12
+
13
+ const LIBRARY = "test-library";
14
+ const SECOND_LIBRARY = "another-library";
15
+ const AUTH_METHODS_PATH = `/${LIBRARY}/admin/manage_patrons/auth_methods`;
16
+ const DEBUG_AUTH_PATH = `/${LIBRARY}/admin/manage_patrons/debug_auth`;
17
+ const SECOND_AUTH_METHODS_PATH = `/${SECOND_LIBRARY}/admin/manage_patrons/auth_methods`;
18
+
19
+ const MOCK_AUTH_METHODS: AuthMethodsResponse = {
20
+ authMethods: [
21
+ {
22
+ id: 1,
23
+ name: "Test SIP2",
24
+ protocol: "api.sip",
25
+ supportsDebug: true,
26
+ supportsPassword: true,
27
+ identifierLabel: "Barcode",
28
+ passwordLabel: "PIN",
29
+ },
30
+ {
31
+ id: 2,
32
+ name: "SAML Provider",
33
+ protocol: "api.saml.provider",
34
+ supportsDebug: false,
35
+ supportsPassword: false,
36
+ identifierLabel: "Username",
37
+ passwordLabel: "Password",
38
+ },
39
+ ],
40
+ };
41
+
42
+ const MOCK_DEBUG_RESULTS: PatronDebugResponse = {
43
+ results: [
44
+ { label: "Server-Side Validation", success: true, details: "ok" },
45
+ {
46
+ label: "SIP2 Connection",
47
+ success: true,
48
+ details: "sip.example.com:6001",
49
+ },
50
+ {
51
+ label: "Password Validation",
52
+ success: false,
53
+ details: "valid_patron_password=N",
54
+ },
55
+ ],
56
+ };
57
+
58
+ describe("DebugAuthentication", () => {
59
+ /* eslint-disable @typescript-eslint/no-empty-function */
60
+ const queryClient = new QueryClient({
61
+ defaultOptions: {
62
+ queries: {
63
+ retry: false,
64
+ },
65
+ },
66
+ logger: {
67
+ log: console.log,
68
+ warn: console.warn,
69
+ error: process.env.NODE_ENV === "test" ? () => {} : console.error,
70
+ },
71
+ });
72
+ /* eslint-enable @typescript-eslint/no-empty-function */
73
+
74
+ const server = setupServer(
75
+ http.get(AUTH_METHODS_PATH, () =>
76
+ HttpResponse.json(MOCK_AUTH_METHODS, { status: 200 })
77
+ ),
78
+ http.post(DEBUG_AUTH_PATH, () =>
79
+ HttpResponse.json(MOCK_DEBUG_RESULTS, { status: 200 })
80
+ )
81
+ );
82
+
83
+ beforeAll(() => server.listen());
84
+ afterEach(() => {
85
+ server.resetHandlers();
86
+ queryClient.clear();
87
+ });
88
+ afterAll(() => server.close());
89
+
90
+ const renderComponent = () => {
91
+ return renderWithProviders(
92
+ <DebugAuthentication library={LIBRARY} csrfToken="test-csrf-token" />,
93
+ { queryClient }
94
+ );
95
+ };
96
+
97
+ it("displays loading state initially", () => {
98
+ const { getByText } = renderComponent();
99
+ expect(getByText("Loading authentication methods...")).toBeInTheDocument();
100
+ });
101
+
102
+ it("renders auth method dropdown after loading", async () => {
103
+ const { getByLabelText, getByText } = renderComponent();
104
+ await waitFor(() => {
105
+ expect(getByLabelText("Authentication Method")).toBeInTheDocument();
106
+ });
107
+ expect(getByText("Test SIP2 (api.sip)")).toBeInTheDocument();
108
+ expect(getByText("SAML Provider (api.saml.provider)")).toBeInTheDocument();
109
+ });
110
+
111
+ it("shows 'not supported' message for non-debug methods", async () => {
112
+ const { getByLabelText, getByText } = renderComponent();
113
+ await waitFor(() => {
114
+ expect(getByLabelText("Authentication Method")).toBeInTheDocument();
115
+ });
116
+
117
+ fireEvent.change(getByLabelText("Authentication Method"), {
118
+ target: { value: "2" },
119
+ });
120
+
121
+ expect(
122
+ getByText(
123
+ "Debug authentication is not supported for this authentication method."
124
+ )
125
+ ).toBeInTheDocument();
126
+ });
127
+
128
+ it("shows form fields for debug-capable method", async () => {
129
+ const { getByLabelText, getByText } = renderComponent();
130
+ await waitFor(() => {
131
+ expect(getByLabelText("Authentication Method")).toBeInTheDocument();
132
+ });
133
+
134
+ fireEvent.change(getByLabelText("Authentication Method"), {
135
+ target: { value: "1" },
136
+ });
137
+
138
+ // Should show fields with the method's labels
139
+ expect(getByLabelText("Barcode")).toBeInTheDocument();
140
+ expect(getByLabelText("PIN")).toBeInTheDocument();
141
+ expect(getByText("Run Debug")).toBeInTheDocument();
142
+ });
143
+
144
+ it("runs debug and displays results", async () => {
145
+ const { getByLabelText, getByText } = renderComponent();
146
+ await waitFor(() => {
147
+ expect(getByLabelText("Authentication Method")).toBeInTheDocument();
148
+ });
149
+
150
+ // Select the SIP2 method
151
+ fireEvent.change(getByLabelText("Authentication Method"), {
152
+ target: { value: "1" },
153
+ });
154
+
155
+ // Fill in the form
156
+ fireEvent.change(getByLabelText("Barcode"), {
157
+ target: { value: "12345" },
158
+ });
159
+ fireEvent.change(getByLabelText("PIN"), {
160
+ target: { value: "1111" },
161
+ });
162
+
163
+ // Submit
164
+ fireEvent.click(getByText("Run Debug"));
165
+
166
+ // Wait for results
167
+ await waitFor(() => {
168
+ expect(getByText("Server-Side Validation")).toBeInTheDocument();
169
+ });
170
+ expect(getByText("SIP2 Connection")).toBeInTheDocument();
171
+ expect(getByText("Password Validation")).toBeInTheDocument();
172
+ });
173
+
174
+ it("auto-selects and hides dropdown when there is only one method", async () => {
175
+ const singleMethod: AuthMethodsResponse = {
176
+ authMethods: [MOCK_AUTH_METHODS.authMethods[0]],
177
+ };
178
+ server.use(
179
+ http.get(AUTH_METHODS_PATH, () =>
180
+ HttpResponse.json(singleMethod, { status: 200 })
181
+ )
182
+ );
183
+
184
+ const { getByLabelText, queryByLabelText } = renderComponent();
185
+
186
+ // The form fields should appear automatically without selecting a method.
187
+ await waitFor(() => {
188
+ expect(getByLabelText("Barcode")).toBeInTheDocument();
189
+ });
190
+ expect(getByLabelText("PIN")).toBeInTheDocument();
191
+
192
+ // The dropdown should not be rendered.
193
+ expect(queryByLabelText("Authentication Method")).not.toBeInTheDocument();
194
+ });
195
+
196
+ it("reconciles selected method when switching to a different library", async () => {
197
+ const singleMethodForSecondLibrary: AuthMethodsResponse = {
198
+ authMethods: [
199
+ {
200
+ id: 99,
201
+ name: "Second Library API",
202
+ protocol: "api.second",
203
+ supportsDebug: true,
204
+ supportsPassword: false,
205
+ identifierLabel: "Email",
206
+ passwordLabel: "Password",
207
+ },
208
+ ],
209
+ };
210
+
211
+ server.use(
212
+ http.get(SECOND_AUTH_METHODS_PATH, () =>
213
+ HttpResponse.json(singleMethodForSecondLibrary, { status: 200 })
214
+ )
215
+ );
216
+
217
+ const {
218
+ getByLabelText,
219
+ queryByLabelText,
220
+ rerender,
221
+ } = renderWithProviders(
222
+ <DebugAuthentication library={LIBRARY} csrfToken="test-csrf-token" />,
223
+ { queryClient }
224
+ );
225
+
226
+ await waitFor(() => {
227
+ expect(getByLabelText("Authentication Method")).toBeInTheDocument();
228
+ });
229
+
230
+ fireEvent.change(getByLabelText("Authentication Method"), {
231
+ target: { value: "1" },
232
+ });
233
+ expect(getByLabelText("Barcode")).toBeInTheDocument();
234
+
235
+ rerender(
236
+ <DebugAuthentication
237
+ library={SECOND_LIBRARY}
238
+ csrfToken="test-csrf-token"
239
+ />
240
+ );
241
+
242
+ await waitFor(() => {
243
+ expect(getByLabelText("Email")).toBeInTheDocument();
244
+ });
245
+ expect(queryByLabelText("Authentication Method")).not.toBeInTheDocument();
246
+ });
247
+
248
+ it("shows warning when library has no auth methods", async () => {
249
+ server.use(
250
+ http.get(AUTH_METHODS_PATH, () =>
251
+ HttpResponse.json({ authMethods: [] }, { status: 200 })
252
+ )
253
+ );
254
+
255
+ const { getByText, queryByLabelText } = renderComponent();
256
+ await waitFor(() => {
257
+ expect(
258
+ getByText(
259
+ "This library has no patron authentication integrations configured."
260
+ )
261
+ ).toBeInTheDocument();
262
+ });
263
+ expect(queryByLabelText("Authentication Method")).not.toBeInTheDocument();
264
+ });
265
+
266
+ it("handles API error on fetching auth methods", async () => {
267
+ server.use(
268
+ http.get(AUTH_METHODS_PATH, () => new HttpResponse(null, { status: 500 }))
269
+ );
270
+
271
+ const { getByText } = renderComponent();
272
+ await waitFor(
273
+ () => {
274
+ expect(
275
+ getByText(/Error loading authentication methods/)
276
+ ).toBeInTheDocument();
277
+ },
278
+ { timeout: 5000 }
279
+ );
280
+ });
281
+ });
@@ -0,0 +1,162 @@
1
+ import * as React from "react";
2
+ import { render } from "@testing-library/react";
3
+ import DebugResultListItem from "../../../src/components/DebugResultListItem";
4
+ import { PatronDebugResult } from "../../../src/api/patronDebug";
5
+
6
+ describe("DebugResultListItem", () => {
7
+ const renderResult = (result: PatronDebugResult, sequence = 0) => {
8
+ return render(<DebugResultListItem result={result} sequence={sequence} />);
9
+ };
10
+
11
+ it("renders success result with string details", () => {
12
+ const result: PatronDebugResult = {
13
+ label: "SIP2 Connection",
14
+ success: true,
15
+ details: "Connected to server:6001",
16
+ };
17
+ const { getByText } = renderResult(result);
18
+ expect(getByText("SIP2 Connection")).toBeInTheDocument();
19
+ expect(getByText("passed")).toBeInTheDocument();
20
+ expect(getByText("Connected to server:6001")).toBeInTheDocument();
21
+ });
22
+
23
+ it("renders failure result with string details", () => {
24
+ const result: PatronDebugResult = {
25
+ label: "Password Validation",
26
+ success: false,
27
+ details: "Password does not match",
28
+ };
29
+ const { getByText, container } = renderResult(result);
30
+ expect(getByText("Password Validation")).toBeInTheDocument();
31
+ expect(getByText("failed")).toBeInTheDocument();
32
+ const li = container.querySelector("li");
33
+ expect(li).toHaveClass("failure");
34
+ });
35
+
36
+ it("renders success result with CSS class", () => {
37
+ const result: PatronDebugResult = {
38
+ label: "Step 1",
39
+ success: true,
40
+ details: null,
41
+ };
42
+ const { container } = renderResult(result);
43
+ const li = container.querySelector("li");
44
+ expect(li).toHaveClass("success");
45
+ });
46
+
47
+ it("renders null details without detail section", () => {
48
+ const result: PatronDebugResult = {
49
+ label: "Step 1",
50
+ success: true,
51
+ details: null,
52
+ };
53
+ const { container } = renderResult(result);
54
+ expect(container.querySelector(".debug-result-detail")).toBeNull();
55
+ expect(container.querySelector(".debug-result-table")).toBeNull();
56
+ expect(container.querySelector(".debug-result-list")).toBeNull();
57
+ });
58
+
59
+ it("renders list details as ordered list in panel", () => {
60
+ const result: PatronDebugResult = {
61
+ label: "Multi Result",
62
+ success: true,
63
+ details: ["item one", "item two", "item three"],
64
+ };
65
+ const { getByText, container } = renderResult(result);
66
+ expect(getByText("item one")).toBeInTheDocument();
67
+ expect(getByText("item two")).toBeInTheDocument();
68
+ expect(getByText("item three")).toBeInTheDocument();
69
+ const ol = container.querySelector(".debug-result-list");
70
+ expect(ol).toBeInTheDocument();
71
+ });
72
+
73
+ it("renders dict details as key-value table in panel", () => {
74
+ const result: PatronDebugResult = {
75
+ label: "Patron Data",
76
+ success: true,
77
+ details: {
78
+ username: "jdoe",
79
+ barcode: "12345",
80
+ fines: "0.00",
81
+ },
82
+ };
83
+ const { getByText, container } = renderResult(result);
84
+ expect(getByText("username")).toBeInTheDocument();
85
+ expect(getByText("jdoe")).toBeInTheDocument();
86
+ expect(getByText("barcode")).toBeInTheDocument();
87
+ expect(getByText("12345")).toBeInTheDocument();
88
+ const table = container.querySelector(".debug-result-table");
89
+ expect(table).toBeInTheDocument();
90
+ });
91
+
92
+ it("renders dict details with nested object values as JSON", () => {
93
+ const result: PatronDebugResult = {
94
+ label: "SirsiDynix Data",
95
+ success: true,
96
+ details: {
97
+ patronType: { key: "testtype", label: "Test Type" },
98
+ name: "Jane Doe",
99
+ },
100
+ };
101
+ const { getByText, container } = renderResult(result);
102
+ expect(getByText("patronType")).toBeInTheDocument();
103
+ // Nested object should render as pretty-printed JSON inside a <code> tag
104
+ const codeEl = container.querySelector(
105
+ "td.debug-result-value code"
106
+ ) as HTMLElement;
107
+ expect(codeEl).toBeInTheDocument();
108
+ expect(codeEl.textContent).toBe(
109
+ JSON.stringify({ key: "testtype", label: "Test Type" }, null, 2)
110
+ );
111
+ expect(getByText("name")).toBeInTheDocument();
112
+ expect(getByText("Jane Doe")).toBeInTheDocument();
113
+ });
114
+
115
+ it("renders top-level number details as text content", () => {
116
+ const result: PatronDebugResult = {
117
+ label: "Retry Count",
118
+ success: true,
119
+ details: 3,
120
+ };
121
+ const { getByText, container } = renderResult(result);
122
+ expect(getByText("Retry Count")).toBeInTheDocument();
123
+ expect(getByText("3")).toBeInTheDocument();
124
+ expect(container.querySelector(".debug-result-table")).toBeNull();
125
+ });
126
+
127
+ it("renders top-level boolean details as text content", () => {
128
+ const result: PatronDebugResult = {
129
+ label: "Eligibility",
130
+ success: false,
131
+ details: false,
132
+ };
133
+ const { getByText, container } = renderResult(result);
134
+ expect(getByText("Eligibility")).toBeInTheDocument();
135
+ expect(getByText("false")).toBeInTheDocument();
136
+ expect(container.querySelector(".debug-result-table")).toBeNull();
137
+ });
138
+
139
+ it("renders dict details with mixed value types (number, boolean, null)", () => {
140
+ const result: PatronDebugResult = {
141
+ label: "Mixed Data",
142
+ success: true,
143
+ details: {
144
+ username: "jdoe",
145
+ fines: 2.5,
146
+ active: true,
147
+ block_reason: null,
148
+ },
149
+ };
150
+ const { getByText, container } = renderResult(result);
151
+ expect(getByText("username")).toBeInTheDocument();
152
+ expect(getByText("jdoe")).toBeInTheDocument();
153
+ expect(getByText("fines")).toBeInTheDocument();
154
+ expect(getByText("2.5")).toBeInTheDocument();
155
+ expect(getByText("active")).toBeInTheDocument();
156
+ expect(getByText("true")).toBeInTheDocument();
157
+ expect(getByText("block_reason")).toBeInTheDocument();
158
+ expect(getByText("null")).toBeInTheDocument();
159
+ const table = container.querySelector(".debug-result-table");
160
+ expect(table).toBeInTheDocument();
161
+ });
162
+ });
@@ -0,0 +1,81 @@
1
+ import * as React from "react";
2
+ import { render, fireEvent } from "@testing-library/react";
3
+ import PasswordInput from "../../../src/components/PasswordInput";
4
+
5
+ describe("PasswordInput", () => {
6
+ it("defaults to type='password'", () => {
7
+ const { getByLabelText } = render(
8
+ <label>
9
+ PIN
10
+ <PasswordInput />
11
+ </label>
12
+ );
13
+ expect(getByLabelText("PIN")).toHaveAttribute("type", "password");
14
+ });
15
+
16
+ it("toggles to type='text' when show button is clicked", () => {
17
+ const { getByLabelText } = render(
18
+ <label>
19
+ PIN
20
+ <PasswordInput />
21
+ </label>
22
+ );
23
+ const input = getByLabelText("PIN");
24
+ const toggleBtn = getByLabelText("Show password");
25
+
26
+ fireEvent.click(toggleBtn);
27
+ expect(input).toHaveAttribute("type", "text");
28
+
29
+ fireEvent.click(getByLabelText("Hide password"));
30
+ expect(input).toHaveAttribute("type", "password");
31
+ });
32
+
33
+ it("has correct aria-label on toggle button", () => {
34
+ const { getByLabelText, queryByLabelText } = render(
35
+ <label>
36
+ PIN
37
+ <PasswordInput />
38
+ </label>
39
+ );
40
+
41
+ expect(getByLabelText("Show password")).toBeInTheDocument();
42
+ expect(queryByLabelText("Hide password")).not.toBeInTheDocument();
43
+
44
+ fireEvent.click(getByLabelText("Show password"));
45
+
46
+ expect(getByLabelText("Hide password")).toBeInTheDocument();
47
+ expect(queryByLabelText("Show password")).not.toBeInTheDocument();
48
+ });
49
+
50
+ it("passes through standard input props", () => {
51
+ const handleChange = jest.fn();
52
+ const { getByLabelText } = render(
53
+ <label>
54
+ PIN
55
+ <PasswordInput
56
+ id="test-pw"
57
+ className="form-control"
58
+ value="secret"
59
+ onChange={handleChange}
60
+ autoComplete="off"
61
+ />
62
+ </label>
63
+ );
64
+
65
+ const input = getByLabelText("PIN");
66
+ expect(input).toHaveAttribute("id", "test-pw");
67
+ expect(input).toHaveClass("form-control");
68
+ expect(input).toHaveAttribute("value", "secret");
69
+ expect(input).toHaveAttribute("autoComplete", "off");
70
+
71
+ fireEvent.change(input, { target: { value: "new-value" } });
72
+ expect(handleChange).toHaveBeenCalled();
73
+ });
74
+
75
+ it("renders the wrapper div with password-input-wrapper class", () => {
76
+ const { container } = render(<PasswordInput />);
77
+ expect(
78
+ container.querySelector(".password-input-wrapper")
79
+ ).toBeInTheDocument();
80
+ });
81
+ });