@thepalaceproject/circulation-admin 1.37.0 → 1.38.0-post.1

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
@@ -125,7 +125,7 @@
125
125
  "sass": "^1.64.2",
126
126
  "sass-lint": "^1.13.1",
127
127
  "sass-loader": "^13.2.0",
128
- "selenium-standalone": "^6.16.0",
128
+ "selenium-standalone": "^10.0.2",
129
129
  "sinon": "7.5.0",
130
130
  "style-loader": "^0.13.1",
131
131
  "terser-webpack-plugin": "^5.3.6",
@@ -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.37.0"
155
+ "version": "1.38.0-post.1"
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,458 @@
1
+ import * as React from "react";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import * as fetchMock from "fetch-mock-jest";
5
+ import PatronAuthServiceEditForm from "../../../src/components/PatronAuthServiceEditForm";
6
+ import {
7
+ PatronAuthServicesData,
8
+ PatronBlockingRule,
9
+ } from "../../../src/interfaces";
10
+ import { SIP2_PROTOCOL } from "../../../src/utils/patronBlockingRules";
11
+
12
+ const VALIDATE_URL = "/admin/patron_auth_service_validate_patron_blocking_rule";
13
+
14
+ async function expandLibrariesPanel(user: ReturnType<typeof userEvent.setup>) {
15
+ const toggle = screen
16
+ .getAllByRole("button")
17
+ .find((btn) => btn.getAttribute("aria-controls")?.includes("libraries"));
18
+ if (toggle && toggle.getAttribute("aria-expanded") === "false") {
19
+ await user.click(toggle);
20
+ }
21
+ }
22
+
23
+ const SIP2_PROTOCOL_DATA = {
24
+ name: SIP2_PROTOCOL,
25
+ label: "SIP2",
26
+ description: "SIP2 authentication",
27
+ sitewide: false,
28
+ settings: [{ key: "server", label: "Server", required: true }],
29
+ library_settings: [],
30
+ };
31
+
32
+ const OTHER_PROTOCOL_DATA = {
33
+ name: "api.saml.provider",
34
+ label: "SAML",
35
+ description: "SAML authentication",
36
+ sitewide: false,
37
+ settings: [{ key: "idp_url", label: "IdP URL", required: true }],
38
+ library_settings: [],
39
+ };
40
+
41
+ const LIBRARY_SHORT_NAME = "test-lib";
42
+
43
+ const servicesData: PatronAuthServicesData = {
44
+ protocols: [SIP2_PROTOCOL_DATA, OTHER_PROTOCOL_DATA],
45
+ allLibraries: [{ short_name: LIBRARY_SHORT_NAME, name: "Test Library" }],
46
+ patron_auth_services: [],
47
+ };
48
+
49
+ const baseProps = {
50
+ data: servicesData,
51
+ disabled: false,
52
+ save: jest.fn(),
53
+ urlBase: "/admin/web/config/patronAuth/",
54
+ listDataKey: "patron_auth_services",
55
+ adminLevel: 10,
56
+ };
57
+
58
+ function buildSIP2Item(rules: PatronBlockingRule[] = []) {
59
+ return {
60
+ id: 1,
61
+ protocol: SIP2_PROTOCOL,
62
+ settings: { server: "sip.example.com" },
63
+ libraries: [
64
+ { short_name: LIBRARY_SHORT_NAME, patron_blocking_rules: rules },
65
+ ],
66
+ };
67
+ }
68
+
69
+ // Guard: any blur on the Rule Expression textarea calls validatePatronBlockingRuleExpression.
70
+ // All describe blocks get a default 200 mock so tests that incidentally trigger blur
71
+ // don't fail with "only absolute URLs are supported" from the fetch polyfill.
72
+ beforeEach(() => {
73
+ fetchMock.post(VALIDATE_URL, { status: 200 });
74
+ });
75
+
76
+ afterEach(() => {
77
+ fetchMock.mockReset();
78
+ });
79
+
80
+ describe("PatronAuthServiceEditForm – capability gating", () => {
81
+ it("shows PatronBlockingRulesEditor in expanded library settings for SIP2", async () => {
82
+ const user = userEvent.setup();
83
+ render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
84
+
85
+ await expandLibrariesPanel(user);
86
+ const editButton = screen.getByRole("button", { name: /Edit/i });
87
+ await user.click(editButton);
88
+
89
+ // The PatronBlockingRulesEditor label and Add Rule button should be visible
90
+ expect(screen.getByText("Patron Blocking Rules")).toBeTruthy();
91
+ expect(screen.getByRole("button", { name: /Add Rule/i })).toBeTruthy();
92
+ });
93
+
94
+ it("does not show PatronBlockingRulesEditor for non-SIP2 protocol", async () => {
95
+ const user = userEvent.setup();
96
+ const item = {
97
+ id: 2,
98
+ protocol: "api.saml.provider",
99
+ settings: { idp_url: "https://idp.example.com" },
100
+ libraries: [{ short_name: LIBRARY_SHORT_NAME }],
101
+ };
102
+ render(<PatronAuthServiceEditForm {...baseProps} item={item} />);
103
+ await expandLibrariesPanel(user);
104
+
105
+ // For non-SIP2 with no library_settings, the Edit button should not render
106
+ expect(screen.queryByRole("button", { name: /Edit/i })).toBeNull();
107
+ expect(screen.queryByText(/Patron Blocking Rules/i)).toBeNull();
108
+ expect(screen.queryByRole("button", { name: /Add Rule/i })).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe("PatronAuthServiceEditForm – load / initial value", () => {
113
+ it("populates the editor with existing patron_blocking_rules from saved library settings", async () => {
114
+ const user = userEvent.setup();
115
+ const existingRules: PatronBlockingRule[] = [
116
+ {
117
+ name: "Block expired",
118
+ rule: "status == expired",
119
+ message: "Card expired",
120
+ },
121
+ ];
122
+ render(
123
+ <PatronAuthServiceEditForm
124
+ {...baseProps}
125
+ item={buildSIP2Item(existingRules)}
126
+ />
127
+ );
128
+
129
+ await expandLibrariesPanel(user);
130
+ const editButton = screen.getByRole("button", { name: /Edit/i });
131
+ await user.click(editButton);
132
+
133
+ const nameInput = screen.getByLabelText(/Rule Name/i) as HTMLInputElement;
134
+ expect(nameInput.value).toBe("Block expired");
135
+
136
+ const ruleTextarea = screen.getByLabelText(
137
+ /Rule Expression/i
138
+ ) as HTMLTextAreaElement;
139
+ expect(ruleTextarea.value).toBe("status == expired");
140
+
141
+ const messageInput = screen.getByLabelText(/Message/i) as HTMLInputElement;
142
+ expect(messageInput.value).toBe("Card expired");
143
+ });
144
+
145
+ it("shows empty editor when library has no existing patron_blocking_rules", async () => {
146
+ const user = userEvent.setup();
147
+ render(
148
+ <PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item([])} />
149
+ );
150
+
151
+ await expandLibrariesPanel(user);
152
+ const editButton = screen.getByRole("button", { name: /Edit/i });
153
+ await user.click(editButton);
154
+
155
+ expect(screen.getByText(/No patron blocking rules defined/i)).toBeTruthy();
156
+ });
157
+ });
158
+
159
+ describe("PatronAuthServiceEditForm – error display", () => {
160
+ it("passes error prop down to PatronBlockingRulesEditor", async () => {
161
+ const user = userEvent.setup();
162
+ const error = { status: 400, response: "Validation failed", url: "" };
163
+ render(
164
+ <PatronAuthServiceEditForm
165
+ {...baseProps}
166
+ item={buildSIP2Item([{ name: "", rule: "expr", message: null }])}
167
+ error={error}
168
+ />
169
+ );
170
+
171
+ await expandLibrariesPanel(user);
172
+ const editButton = screen.getByRole("button", { name: /Edit/i });
173
+ await user.click(editButton);
174
+
175
+ // The PatronBlockingRulesEditor should be present (error prop is wired in)
176
+ expect(screen.getByText(/Patron Blocking Rules/i)).toBeTruthy();
177
+ });
178
+ });
179
+
180
+ describe("PatronAuthServiceEditForm – serialization", () => {
181
+ it("includes patron_blocking_rules in the library state when editLibrary is called", async () => {
182
+ const user = userEvent.setup();
183
+ render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
184
+
185
+ await expandLibrariesPanel(user);
186
+ const editButton = screen.getByRole("button", { name: /Edit/i });
187
+ await user.click(editButton);
188
+
189
+ // Add a rule via the editor UI
190
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
191
+ await user.type(screen.getByLabelText(/Rule Name/i), "Test Rule");
192
+ await user.type(
193
+ screen.getByLabelText(/Rule Expression/i),
194
+ "field == value"
195
+ );
196
+
197
+ // Tab out of the expression field to trigger blur → validation (mocked 200).
198
+ // The Save button is disabled until validation succeeds.
199
+ await user.tab();
200
+ const saveButton = screen.getByRole("button", { name: /^Save$/i });
201
+ await waitFor(() => expect(saveButton).not.toBeDisabled());
202
+
203
+ await user.click(saveButton);
204
+
205
+ // The library should now be collapsed (indicating editLibrary was called)
206
+ expect(screen.queryByRole("button", { name: /Add Rule/i })).toBeNull();
207
+ });
208
+
209
+ it("includes patron_blocking_rules in payload when adding a new library", async () => {
210
+ const user = userEvent.setup();
211
+ // No existing libraries associated with service
212
+ const item = {
213
+ id: 3,
214
+ protocol: SIP2_PROTOCOL,
215
+ settings: {},
216
+ libraries: [],
217
+ };
218
+ render(<PatronAuthServiceEditForm {...baseProps} item={item} />);
219
+
220
+ await expandLibrariesPanel(user);
221
+ // Select the library from the dropdown
222
+ const select = screen.getByRole("combobox", { name: /Add Library/i });
223
+ await user.selectOptions(select, LIBRARY_SHORT_NAME);
224
+
225
+ // Add a patron blocking rule
226
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
227
+ await user.type(screen.getByLabelText(/Rule Name/i), "New Rule");
228
+ await user.type(
229
+ screen.getByLabelText(/Rule Expression/i),
230
+ "some expression"
231
+ );
232
+
233
+ // Tab out to trigger blur → validation (mocked 200).
234
+ // The Add Library button is disabled until validation succeeds.
235
+ await user.tab();
236
+ const addButton = screen.getByRole("button", { name: /Add Library/i });
237
+ await waitFor(() => expect(addButton).not.toBeDisabled());
238
+
239
+ await user.click(addButton);
240
+
241
+ // After adding, the library should appear in the list, editor no longer in "new library" form
242
+ expect(screen.queryByLabelText(/Rule Name/i)).toBeNull();
243
+ });
244
+ });
245
+
246
+ describe("PatronAuthServiceEditForm – save button gating", () => {
247
+ it("disables the per-library Save button immediately when a new rule is added", async () => {
248
+ const user = userEvent.setup();
249
+ render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
250
+
251
+ await expandLibrariesPanel(user);
252
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
253
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
254
+
255
+ const saveButton = screen.getByRole("button", { name: /^Save$/i });
256
+ await waitFor(() => expect(saveButton).toBeDisabled());
257
+ });
258
+
259
+ it("re-enables the per-library Save button once validation succeeds", async () => {
260
+ const user = userEvent.setup();
261
+ render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
262
+
263
+ await expandLibrariesPanel(user);
264
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
265
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
266
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
267
+ await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0");
268
+ // Tab out triggers blur → server validation → 200 OK (from beforeEach mock)
269
+ await user.tab();
270
+
271
+ const saveButton = screen.getByRole("button", { name: /^Save$/i });
272
+ await waitFor(() => expect(saveButton).not.toBeDisabled());
273
+ });
274
+
275
+ it("keeps the per-library Save button disabled when validation fails", async () => {
276
+ const user = userEvent.setup();
277
+ fetchMock.mockReset();
278
+ fetchMock.post(VALIDATE_URL, {
279
+ status: 400,
280
+ body: { detail: "Unknown placeholder" },
281
+ });
282
+
283
+ render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
284
+
285
+ await expandLibrariesPanel(user);
286
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
287
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
288
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
289
+ await user.type(screen.getByLabelText(/Rule Expression/i), "bad_syntax");
290
+ await user.tab();
291
+
292
+ await screen.findByText(/Unknown placeholder/i);
293
+ const saveButton = screen.getByRole("button", { name: /^Save$/i });
294
+ expect(saveButton).toBeDisabled();
295
+ });
296
+
297
+ it("disables the Add Library button immediately when an incomplete rule is added to the new library section", async () => {
298
+ const user = userEvent.setup();
299
+ const item = {
300
+ id: 3,
301
+ protocol: SIP2_PROTOCOL,
302
+ settings: {},
303
+ libraries: [],
304
+ };
305
+ render(<PatronAuthServiceEditForm {...baseProps} item={item} />);
306
+
307
+ await expandLibrariesPanel(user);
308
+ const select = screen.getByRole("combobox", { name: /Add Library/i });
309
+ await user.selectOptions(select, LIBRARY_SHORT_NAME);
310
+
311
+ const addButton = screen.getByRole("button", { name: /Add Library/i });
312
+ // Before adding a rule the button is enabled
313
+ expect(addButton).not.toBeDisabled();
314
+
315
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
316
+
317
+ // Incomplete rule (empty name + expression) must block the Add Library button
318
+ await waitFor(() => expect(addButton).toBeDisabled());
319
+ });
320
+
321
+ it("disables the per-library Save button when two rules share the same name", async () => {
322
+ const user = userEvent.setup();
323
+ const existingRules = [
324
+ { name: "Rule A", rule: "expr_a" },
325
+ { name: "Rule B", rule: "expr_b" },
326
+ ];
327
+ render(
328
+ <PatronAuthServiceEditForm
329
+ {...baseProps}
330
+ item={buildSIP2Item(existingRules)}
331
+ />
332
+ );
333
+
334
+ await expandLibrariesPanel(user);
335
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
336
+
337
+ const saveButton = screen.getByRole("button", { name: /^Save$/i });
338
+ // Both rules are complete and unique — Save should be enabled
339
+ await waitFor(() => expect(saveButton).not.toBeDisabled());
340
+
341
+ // Rename rule B to match rule A → duplicate → Save must disable
342
+ const nameInputs = screen.getAllByLabelText(
343
+ /Rule Name/i
344
+ ) as HTMLInputElement[];
345
+ await user.clear(nameInputs[1]);
346
+ await user.type(nameInputs[1], "Rule A");
347
+
348
+ await waitFor(() => expect(saveButton).toBeDisabled());
349
+ });
350
+
351
+ it("re-enables the per-library Save button after a duplicate name is resolved", async () => {
352
+ const user = userEvent.setup();
353
+ const existingRules = [
354
+ { name: "Rule A", rule: "expr_a" },
355
+ { name: "Rule A", rule: "expr_b" }, // starts as duplicate
356
+ ];
357
+ render(
358
+ <PatronAuthServiceEditForm
359
+ {...baseProps}
360
+ item={buildSIP2Item(existingRules)}
361
+ />
362
+ );
363
+
364
+ await expandLibrariesPanel(user);
365
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
366
+
367
+ const saveButton = screen.getByRole("button", { name: /^Save$/i });
368
+ await waitFor(() => expect(saveButton).toBeDisabled());
369
+
370
+ // Fix the duplicate
371
+ const nameInputs = screen.getAllByLabelText(
372
+ /Rule Name/i
373
+ ) as HTMLInputElement[];
374
+ await user.clear(nameInputs[1]);
375
+ await user.type(nameInputs[1], "Rule B");
376
+
377
+ await waitFor(() => expect(saveButton).not.toBeDisabled());
378
+ });
379
+
380
+ it("re-blocks and then re-enables the Save button when an existing rule's expression is edited and re-validated", async () => {
381
+ const user = userEvent.setup();
382
+ const existingRules = [{ name: "Rule A", rule: "expr_a" }];
383
+ render(
384
+ <PatronAuthServiceEditForm
385
+ {...baseProps}
386
+ item={buildSIP2Item(existingRules)}
387
+ />
388
+ );
389
+
390
+ await expandLibrariesPanel(user);
391
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
392
+
393
+ const saveButton = screen.getByRole("button", { name: /^Save$/i });
394
+ // Existing rule is not pending — Save should be enabled
395
+ await waitFor(() => expect(saveButton).not.toBeDisabled());
396
+
397
+ // Edit the expression — Save must re-block
398
+ const ruleTextarea = screen.getByLabelText(
399
+ /Rule Expression/i
400
+ ) as HTMLTextAreaElement;
401
+ await user.clear(ruleTextarea);
402
+ await user.type(ruleTextarea, "new_expr");
403
+ await waitFor(() => expect(saveButton).toBeDisabled());
404
+
405
+ // Blur → 200 OK from beforeEach mock → Save re-enabled
406
+ await user.tab();
407
+ await waitFor(() => expect(saveButton).not.toBeDisabled());
408
+ });
409
+ });
410
+
411
+ describe("PatronAuthServiceEditForm – csrfToken / serviceId wiring", () => {
412
+ it("passes the csrfToken from additionalData to the validation API request", async () => {
413
+ const user = userEvent.setup();
414
+ render(
415
+ <PatronAuthServiceEditForm
416
+ {...baseProps}
417
+ item={buildSIP2Item()}
418
+ additionalData={{ csrfToken: "test-csrf-token" }}
419
+ />
420
+ );
421
+
422
+ await expandLibrariesPanel(user);
423
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
424
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
425
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
426
+ await user.type(screen.getByLabelText(/Rule Expression/i), "expr");
427
+ await user.tab();
428
+
429
+ await waitFor(() =>
430
+ expect(fetchMock).toHaveBeenCalledWith(
431
+ VALIDATE_URL,
432
+ expect.objectContaining({
433
+ headers: expect.objectContaining({
434
+ "X-CSRF-Token": "test-csrf-token",
435
+ }),
436
+ })
437
+ )
438
+ );
439
+ });
440
+
441
+ it("passes the item id as service_id in the validation API request body", async () => {
442
+ const user = userEvent.setup();
443
+ // item.id = 1 (from buildSIP2Item)
444
+ render(<PatronAuthServiceEditForm {...baseProps} item={buildSIP2Item()} />);
445
+
446
+ await expandLibrariesPanel(user);
447
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
448
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
449
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
450
+ await user.type(screen.getByLabelText(/Rule Expression/i), "expr");
451
+ await user.tab();
452
+
453
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
454
+ const [, options] = fetchMock.calls(VALIDATE_URL)[0];
455
+ const body = (options as RequestInit).body as FormData;
456
+ expect(body.get("service_id")).toBe("1");
457
+ });
458
+ });