@thepalaceproject/circulation-admin 1.36.0 → 1.37.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.
@@ -0,0 +1,833 @@
1
+ import * as React from "react";
2
+ import { render, screen, waitFor, act } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import * as fetchMock from "fetch-mock-jest";
5
+ import PatronBlockingRulesEditor, {
6
+ PatronBlockingRulesEditorHandle,
7
+ } from "../../../src/components/PatronBlockingRulesEditor";
8
+ import { PatronBlockingRule } from "../../../src/interfaces";
9
+ import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces";
10
+
11
+ const VALIDATE_URL = "/admin/patron_auth_service_validate_patron_blocking_rule";
12
+
13
+ const existingRules: PatronBlockingRule[] = [
14
+ { name: "Rule A", rule: "expr_a", message: "msg a" },
15
+ { name: "Rule B", rule: "expr_b" },
16
+ ];
17
+
18
+ /**
19
+ * Both describe blocks share a beforeEach/afterEach that provides a default
20
+ * successful validation response. This ensures that any incidental blur events
21
+ * fired by user interactions in non-blur-focused tests don't throw
22
+ * "only absolute URLs are supported" errors from the fetch polyfill.
23
+ *
24
+ * Blur-specific tests that need a non-200 response call fetchMock.mockReset()
25
+ * at the start and then set up their own route.
26
+ *
27
+ * Note: in userEvent.type, curly braces are special key sequences. Use {{
28
+ * and }} to type literal { and } characters.
29
+ */
30
+
31
+ describe("PatronBlockingRulesEditor — save-blocking (onValidationStateChange)", () => {
32
+ beforeEach(() => {
33
+ fetchMock.post(VALIDATE_URL, { status: 200 });
34
+ });
35
+
36
+ afterEach(() => {
37
+ fetchMock.mockReset();
38
+ });
39
+
40
+ it("calls onValidationStateChange(true) when a rule is added because it is incomplete", async () => {
41
+ const user = userEvent.setup();
42
+ const onChange = jest.fn();
43
+
44
+ render(
45
+ <PatronBlockingRulesEditor
46
+ value={[]}
47
+ serviceId={42}
48
+ csrfToken="tok"
49
+ onValidationStateChange={onChange}
50
+ />
51
+ );
52
+
53
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
54
+
55
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(true));
56
+ });
57
+
58
+ it("calls onValidationStateChange(false) after successful validation on blur", async () => {
59
+ const user = userEvent.setup();
60
+ const onChange = jest.fn();
61
+
62
+ render(
63
+ <PatronBlockingRulesEditor
64
+ value={[]}
65
+ serviceId={42}
66
+ csrfToken="tok"
67
+ onValidationStateChange={onChange}
68
+ />
69
+ );
70
+
71
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
72
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
73
+ await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0");
74
+ await user.tab();
75
+
76
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
77
+ });
78
+
79
+ it("calls onValidationStateChange(false) after failed validation on blur (validation does not block save)", async () => {
80
+ const user = userEvent.setup();
81
+ const onChange = jest.fn();
82
+
83
+ fetchMock.mockReset();
84
+ fetchMock.post(VALIDATE_URL, {
85
+ status: 400,
86
+ body: { detail: "Bad expression" },
87
+ });
88
+
89
+ render(
90
+ <PatronBlockingRulesEditor
91
+ value={[]}
92
+ serviceId={42}
93
+ csrfToken="tok"
94
+ onValidationStateChange={onChange}
95
+ />
96
+ );
97
+
98
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
99
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
100
+ await user.type(screen.getByLabelText(/Rule Expression/i), "bad_syntax");
101
+ await user.tab();
102
+
103
+ // Wait for the warning to appear; save is not blocked by failed validation.
104
+ await screen.findByText(/Bad expression/i);
105
+ expect(onChange).toHaveBeenLastCalledWith(false);
106
+ });
107
+
108
+ it("calls onValidationStateChange(true) when two rules have the same name", async () => {
109
+ const user = userEvent.setup();
110
+ const onChange = jest.fn();
111
+ const rules: PatronBlockingRule[] = [
112
+ { name: "Rule A", rule: "expr_a" },
113
+ { name: "Rule B", rule: "expr_b" },
114
+ ];
115
+
116
+ render(
117
+ <PatronBlockingRulesEditor
118
+ value={rules}
119
+ serviceId={42}
120
+ csrfToken="tok"
121
+ onValidationStateChange={onChange}
122
+ />
123
+ );
124
+
125
+ // Initially not blocking (no duplicates, no pending)
126
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
127
+
128
+ // Rename rule B to match rule A
129
+ const nameInputs = screen.getAllByLabelText(
130
+ /Rule Name/i
131
+ ) as HTMLInputElement[];
132
+ await user.clear(nameInputs[1]);
133
+ await user.type(nameInputs[1], "Rule A");
134
+
135
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(true));
136
+ });
137
+
138
+ it("calls onValidationStateChange(false) after a duplicate name is resolved", async () => {
139
+ const user = userEvent.setup();
140
+ const onChange = jest.fn();
141
+ const rules: PatronBlockingRule[] = [
142
+ { name: "Rule A", rule: "expr_a" },
143
+ { name: "Rule A", rule: "expr_b" }, // duplicate
144
+ ];
145
+
146
+ render(
147
+ <PatronBlockingRulesEditor
148
+ value={rules}
149
+ serviceId={42}
150
+ csrfToken="tok"
151
+ onValidationStateChange={onChange}
152
+ />
153
+ );
154
+
155
+ // Initially blocking due to duplicate name
156
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(true));
157
+
158
+ // Fix the duplicate
159
+ const nameInputs = screen.getAllByLabelText(
160
+ /Rule Name/i
161
+ ) as HTMLInputElement[];
162
+ await user.clear(nameInputs[1]);
163
+ await user.type(nameInputs[1], "Rule B");
164
+
165
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
166
+ });
167
+
168
+ it("calls onValidationStateChange(true) when a rule is added without a serviceId because it is incomplete", async () => {
169
+ const user = userEvent.setup();
170
+ const onChange = jest.fn();
171
+
172
+ render(
173
+ <PatronBlockingRulesEditor
174
+ value={[]}
175
+ csrfToken="tok"
176
+ onValidationStateChange={onChange}
177
+ // No serviceId — new service not yet saved
178
+ />
179
+ );
180
+
181
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
182
+
183
+ // Incomplete rule (empty name + expression) still blocks save even without serviceId
184
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(true));
185
+ });
186
+
187
+ it("removes blocking state when the pending rule is deleted", async () => {
188
+ const user = userEvent.setup();
189
+ const onChange = jest.fn();
190
+
191
+ render(
192
+ <PatronBlockingRulesEditor
193
+ value={[]}
194
+ serviceId={42}
195
+ csrfToken="tok"
196
+ onValidationStateChange={onChange}
197
+ />
198
+ );
199
+
200
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
201
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(true));
202
+
203
+ // Delete the only rule — blocking should clear
204
+ await user.click(screen.getByRole("button", { name: /Delete/i }));
205
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
206
+ });
207
+
208
+ it("does not block save when a rule with no serviceId has all required fields filled", async () => {
209
+ const user = userEvent.setup();
210
+ const onChange = jest.fn();
211
+
212
+ render(
213
+ <PatronBlockingRulesEditor
214
+ value={[]}
215
+ csrfToken="tok"
216
+ // No serviceId — server validation skipped; only completeness matters
217
+ onValidationStateChange={onChange}
218
+ />
219
+ );
220
+
221
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
222
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
223
+ await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0");
224
+
225
+ // Both fields filled, no serviceId → not blocking
226
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
227
+ });
228
+
229
+ it("keeps save blocked until every rule has required fields (validation does not block)", async () => {
230
+ const user = userEvent.setup();
231
+ const onChange = jest.fn();
232
+
233
+ render(
234
+ <PatronBlockingRulesEditor
235
+ value={[]}
236
+ serviceId={42}
237
+ csrfToken="tok"
238
+ onValidationStateChange={onChange}
239
+ />
240
+ );
241
+
242
+ // Add and fill the first rule
243
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
244
+ await user.type(screen.getByLabelText(/Rule Name/i), "Rule One");
245
+ await user.type(screen.getByLabelText(/Rule Expression/i), "expr_one");
246
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
247
+
248
+ // Add a second rule — save should block (incomplete)
249
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
250
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(true));
251
+
252
+ // Fill in the second rule — no validation needed to unblock
253
+ const nameInputs = screen.getAllByLabelText(
254
+ /Rule Name/i
255
+ ) as HTMLInputElement[];
256
+ const ruleInputs = screen.getAllByLabelText(
257
+ /Rule Expression/i
258
+ ) as HTMLTextAreaElement[];
259
+ await user.type(nameInputs[1], "Rule Two");
260
+ await user.type(ruleInputs[1], "expr_two");
261
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
262
+ });
263
+
264
+ it("does not block save when an existing rule's expression is edited (validation is advisory)", async () => {
265
+ const user = userEvent.setup();
266
+ const onChange = jest.fn();
267
+ const rules: PatronBlockingRule[] = [{ name: "Rule A", rule: "expr_a" }];
268
+
269
+ render(
270
+ <PatronBlockingRulesEditor
271
+ value={rules}
272
+ serviceId={42}
273
+ csrfToken="tok"
274
+ onValidationStateChange={onChange}
275
+ />
276
+ );
277
+
278
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(false));
279
+
280
+ // Edit the expression — save stays unblocked (no incomplete, no duplicate)
281
+ const ruleTextarea = screen.getByLabelText(
282
+ /Rule Expression/i
283
+ ) as HTMLTextAreaElement;
284
+ await user.clear(ruleTextarea);
285
+ await user.type(ruleTextarea, "new_expr");
286
+
287
+ expect(onChange).toHaveBeenLastCalledWith(false);
288
+ });
289
+ });
290
+
291
+ describe("PatronBlockingRulesEditor — on-blur server validation", () => {
292
+ beforeEach(() => {
293
+ fetchMock.post(VALIDATE_URL, { status: 200 });
294
+ });
295
+
296
+ afterEach(() => {
297
+ fetchMock.mockReset();
298
+ });
299
+
300
+ it("calls the validation API when the user leaves the Rule Expression field", async () => {
301
+ const user = userEvent.setup();
302
+
303
+ render(
304
+ <PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
305
+ );
306
+
307
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
308
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
309
+ await user.type(
310
+ screen.getByLabelText(/Rule Expression/i),
311
+ "{{fines}} > 10.0"
312
+ );
313
+ await user.tab();
314
+
315
+ await waitFor(() =>
316
+ expect(fetchMock).toHaveBeenCalledWith(
317
+ VALIDATE_URL,
318
+ expect.objectContaining({ method: "POST" })
319
+ )
320
+ );
321
+ });
322
+
323
+ it("shows a server error message inline after a failed validation", async () => {
324
+ const user = userEvent.setup();
325
+ fetchMock.mockReset();
326
+ fetchMock.post(VALIDATE_URL, {
327
+ status: 400,
328
+ body: { detail: "Unknown placeholder: {x}" },
329
+ });
330
+
331
+ render(
332
+ <PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
333
+ );
334
+
335
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
336
+ await user.type(screen.getByLabelText(/Rule Name/i), "Bad Rule");
337
+ await user.type(screen.getByLabelText(/Rule Expression/i), "{{x}} > 0");
338
+ await user.tab();
339
+
340
+ expect(await screen.findByText(/Unknown placeholder: \{x\}/i)).toBeTruthy();
341
+ });
342
+
343
+ it("clears the server error immediately when the user edits the rule field", async () => {
344
+ const user = userEvent.setup();
345
+ fetchMock.mockReset();
346
+ fetchMock.post(VALIDATE_URL, {
347
+ status: 400,
348
+ body: { detail: "Bad expression" },
349
+ });
350
+
351
+ render(
352
+ <PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
353
+ );
354
+
355
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
356
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
357
+ await user.type(screen.getByLabelText(/Rule Expression/i), "bad");
358
+ await user.tab();
359
+
360
+ await screen.findByText(/Bad expression/i);
361
+
362
+ // Typing in the rule field clears the server error immediately (no re-fetch needed)
363
+ await user.click(screen.getByLabelText(/Rule Expression/i));
364
+ await user.type(screen.getByLabelText(/Rule Expression/i), "x");
365
+
366
+ expect(screen.queryByText(/Bad expression/i)).toBeNull();
367
+ });
368
+
369
+ it("does not call the validation API when the rule field is empty on blur", async () => {
370
+ const user = userEvent.setup();
371
+ fetchMock.mockReset();
372
+
373
+ render(
374
+ <PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
375
+ );
376
+
377
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
378
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
379
+ // Click the Rule Expression textarea then tab away without typing
380
+ await user.click(screen.getByLabelText(/Rule Expression/i));
381
+ await user.tab();
382
+
383
+ expect(fetchMock).not.toHaveBeenCalled();
384
+ });
385
+
386
+ it("still calls the validation API when serviceId is undefined and shows the server error", async () => {
387
+ const user = userEvent.setup();
388
+ fetchMock.mockReset();
389
+ fetchMock.post(VALIDATE_URL, {
390
+ status: 400,
391
+ body: {
392
+ detail:
393
+ "Patron auth service not found. Save the service before validating rules.",
394
+ },
395
+ });
396
+
397
+ // No serviceId — simulates a new service that has not yet been saved
398
+ render(<PatronBlockingRulesEditor value={[]} csrfToken="tok" />);
399
+
400
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
401
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
402
+ await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0");
403
+ await user.tab();
404
+
405
+ await waitFor(() =>
406
+ expect(fetchMock).toHaveBeenCalledWith(
407
+ VALIDATE_URL,
408
+ expect.objectContaining({ method: "POST" })
409
+ )
410
+ );
411
+ expect(
412
+ await screen.findByText(/Save the service before validating rules/i)
413
+ ).toBeTruthy();
414
+ });
415
+
416
+ it("preserves the server error on the second rule after the first rule is deleted", async () => {
417
+ const user = userEvent.setup();
418
+ fetchMock.mockReset();
419
+ fetchMock.post(VALIDATE_URL, {
420
+ status: 400,
421
+ body: { detail: "Bad expression syntax" },
422
+ });
423
+
424
+ render(
425
+ <PatronBlockingRulesEditor
426
+ value={[{ name: "Rule A", rule: "expr_a" }]}
427
+ serviceId={42}
428
+ csrfToken="tok"
429
+ />
430
+ );
431
+
432
+ // Add a second rule and give it an invalid expression
433
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
434
+ const nameInputs = screen.getAllByLabelText(
435
+ /Rule Name/i
436
+ ) as HTMLInputElement[];
437
+ const ruleTextareas = screen.getAllByLabelText(
438
+ /Rule Expression/i
439
+ ) as HTMLTextAreaElement[];
440
+ await user.type(nameInputs[1], "Rule B");
441
+ await user.type(ruleTextareas[1], "bad_syntax");
442
+ await user.tab();
443
+
444
+ // Wait for the server error to appear on the second rule
445
+ await screen.findByText(/Bad expression syntax/i);
446
+
447
+ // Delete the first (valid) rule
448
+ const deleteButtons = screen.getAllByRole("button", { name: /Delete/i });
449
+ await user.click(deleteButtons[0]);
450
+
451
+ // Only the formerly-second rule should remain
452
+ const remainingNameInputs = screen.getAllByLabelText(
453
+ /Rule Name/i
454
+ ) as HTMLInputElement[];
455
+ expect(remainingNameInputs).toHaveLength(1);
456
+ expect(remainingNameInputs[0].value).toBe("Rule B");
457
+
458
+ // The server error for that rule must still be visible
459
+ expect(screen.getByText(/Bad expression syntax/i)).toBeTruthy();
460
+ });
461
+
462
+ it("shows no error after a successful re-validation that follows a failure", async () => {
463
+ const user = userEvent.setup();
464
+ fetchMock.mockReset();
465
+ fetchMock.post(VALIDATE_URL, {
466
+ status: 400,
467
+ body: { detail: "Syntax error in rule" },
468
+ });
469
+
470
+ render(
471
+ <PatronBlockingRulesEditor value={[]} serviceId={42} csrfToken="tok" />
472
+ );
473
+
474
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
475
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
476
+ await user.type(screen.getByLabelText(/Rule Expression/i), "bad_syntax");
477
+ await user.tab();
478
+
479
+ await screen.findByText(/Syntax error in rule/i);
480
+
481
+ // Switch mock to success, correct the rule, and blur again
482
+ fetchMock.mockReset();
483
+ fetchMock.post(VALIDATE_URL, { status: 200 });
484
+
485
+ await user.clear(screen.getByLabelText(/Rule Expression/i));
486
+ await user.type(screen.getByLabelText(/Rule Expression/i), "{{fines}} > 0");
487
+ await user.tab();
488
+
489
+ await waitFor(() =>
490
+ expect(screen.queryByText(/Syntax error in rule/i)).toBeNull()
491
+ );
492
+ });
493
+ });
494
+
495
+ describe("PatronBlockingRulesEditor", () => {
496
+ // Provide a default successful validation response so that tests which
497
+ // incidentally trigger blur on the Rule Expression field (e.g. by typing in
498
+ // the Message textarea) don't produce "only absolute URLs" fetch errors.
499
+ beforeEach(() => {
500
+ fetchMock.post(VALIDATE_URL, { status: 200 });
501
+ });
502
+
503
+ afterEach(() => {
504
+ fetchMock.mockReset();
505
+ });
506
+
507
+ it("renders with no rules when no value provided", () => {
508
+ render(<PatronBlockingRulesEditor />);
509
+ expect(screen.getByText(/No patron blocking rules defined/i)).toBeTruthy();
510
+ expect(screen.getByRole("button", { name: /Add Rule/i })).toBeTruthy();
511
+ });
512
+
513
+ it("renders existing rules from value prop", () => {
514
+ render(<PatronBlockingRulesEditor value={existingRules} />);
515
+ expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(2);
516
+ expect(screen.getAllByLabelText(/Rule Expression/i)).toHaveLength(2);
517
+
518
+ const nameInputs = screen.getAllByLabelText(
519
+ /Rule Name/i
520
+ ) as HTMLInputElement[];
521
+ expect(nameInputs[0].value).toBe("Rule A");
522
+ expect(nameInputs[1].value).toBe("Rule B");
523
+
524
+ const ruleTextareas = screen.getAllByLabelText(
525
+ /Rule Expression/i
526
+ ) as HTMLTextAreaElement[];
527
+ expect(ruleTextareas[0].value).toBe("expr_a");
528
+ expect(ruleTextareas[1].value).toBe("expr_b");
529
+ });
530
+
531
+ it("adds a new blank rule row when Add Rule is clicked", async () => {
532
+ const user = userEvent.setup();
533
+ render(<PatronBlockingRulesEditor value={[]} />);
534
+
535
+ expect(screen.queryByLabelText(/Rule Name/i)).toBeNull();
536
+
537
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
538
+
539
+ expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(1);
540
+ expect(screen.getAllByLabelText(/Rule Expression/i)).toHaveLength(1);
541
+ expect(screen.getAllByLabelText(/Message/i)).toHaveLength(1);
542
+ });
543
+
544
+ it("removes a rule row when Delete is clicked", async () => {
545
+ const user = userEvent.setup();
546
+ render(<PatronBlockingRulesEditor value={existingRules} />);
547
+
548
+ expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(2);
549
+
550
+ const deleteButtons = screen.getAllByRole("button", { name: /Delete/i });
551
+ await user.click(deleteButtons[0]);
552
+
553
+ expect(screen.getAllByLabelText(/Rule Name/i)).toHaveLength(1);
554
+ const remaining = screen.getAllByLabelText(
555
+ /Rule Name/i
556
+ ) as HTMLInputElement[];
557
+ expect(remaining[0].value).toBe("Rule B");
558
+ });
559
+
560
+ it("getValue returns current rules including edits", async () => {
561
+ const user = userEvent.setup();
562
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
563
+ render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
564
+
565
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
566
+
567
+ const nameInput = screen.getByLabelText(/Rule Name/i);
568
+ const ruleTextarea = screen.getByLabelText(/Rule Expression/i);
569
+ const messageInput = screen.getByLabelText(/Message/i);
570
+
571
+ await user.clear(nameInput);
572
+ await user.type(nameInput, "My Rule");
573
+ await user.clear(ruleTextarea);
574
+ await user.type(ruleTextarea, "blocked = true");
575
+ await user.clear(messageInput);
576
+ await user.type(messageInput, "You are blocked");
577
+
578
+ const value = ref.current.getValue();
579
+ expect(value).toHaveLength(1);
580
+ expect(value[0].name).toBe("My Rule");
581
+ expect(value[0].rule).toBe("blocked = true");
582
+ expect(value[0].message).toBe("You are blocked");
583
+ });
584
+
585
+ it("getValue returns an empty array when no rules exist", () => {
586
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
587
+ render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
588
+ expect(ref.current.getValue()).toEqual([]);
589
+ });
590
+
591
+ it("disables all inputs and buttons when disabled prop is true", () => {
592
+ render(<PatronBlockingRulesEditor value={existingRules} disabled={true} />);
593
+
594
+ const buttons = screen.getAllByRole("button");
595
+ buttons.forEach((btn) => expect(btn).toBeDisabled());
596
+
597
+ const inputs = screen.getAllByRole("textbox");
598
+ inputs.forEach((input) => expect(input).toBeDisabled());
599
+ });
600
+
601
+ it("does not show 'no rules' message when rules exist", () => {
602
+ render(<PatronBlockingRulesEditor value={existingRules} />);
603
+ expect(screen.queryByText(/No patron blocking rules defined/i)).toBeNull();
604
+ });
605
+
606
+ it("disables Add Rule button when an existing rule is missing required fields", async () => {
607
+ const user = userEvent.setup();
608
+ render(<PatronBlockingRulesEditor value={[]} />);
609
+
610
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
611
+
612
+ expect(screen.getByRole("button", { name: /Add Rule/i })).toBeDisabled();
613
+ });
614
+
615
+ it("re-enables Add Rule button once all required fields are filled", async () => {
616
+ const user = userEvent.setup();
617
+ render(<PatronBlockingRulesEditor value={[]} />);
618
+
619
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
620
+ expect(screen.getByRole("button", { name: /Add Rule/i })).toBeDisabled();
621
+
622
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
623
+ await user.type(
624
+ screen.getByLabelText(/Rule Expression/i),
625
+ "blocked = true"
626
+ );
627
+
628
+ expect(
629
+ screen.getByRole("button", { name: /Add Rule/i })
630
+ ).not.toBeDisabled();
631
+ });
632
+
633
+ it("shows a duplicate-name error inline when two rules share the same name", async () => {
634
+ const user = userEvent.setup();
635
+ const rules: PatronBlockingRule[] = [
636
+ { name: "Rule A", rule: "expr_a" },
637
+ { name: "Rule B", rule: "expr_b" },
638
+ ];
639
+
640
+ render(<PatronBlockingRulesEditor value={rules} serviceId={42} />);
641
+
642
+ // Rename rule B to match rule A
643
+ const nameInputs = screen.getAllByLabelText(
644
+ /Rule Name/i
645
+ ) as HTMLInputElement[];
646
+ await user.clear(nameInputs[1]);
647
+ await user.type(nameInputs[1], "Rule A");
648
+
649
+ await waitFor(() =>
650
+ expect(screen.getAllByText(/Rule Name must be unique/i)).toHaveLength(2)
651
+ );
652
+ });
653
+
654
+ it("clears the duplicate-name error once the name is made unique", async () => {
655
+ const user = userEvent.setup();
656
+ const rules: PatronBlockingRule[] = [
657
+ { name: "Rule A", rule: "expr_a" },
658
+ { name: "Rule A", rule: "expr_b" }, // duplicate
659
+ ];
660
+
661
+ render(<PatronBlockingRulesEditor value={rules} serviceId={42} />);
662
+
663
+ // Both rows should start with the duplicate error
664
+ expect(screen.getAllByText(/Rule Name must be unique/i)).toHaveLength(2);
665
+
666
+ // Fix the second rule's name
667
+ const nameInputs = screen.getAllByLabelText(
668
+ /Rule Name/i
669
+ ) as HTMLInputElement[];
670
+ await user.clear(nameInputs[1]);
671
+ await user.type(nameInputs[1], "Rule B");
672
+
673
+ await waitFor(() =>
674
+ expect(screen.queryByText(/Rule Name must be unique/i)).toBeNull()
675
+ );
676
+ });
677
+
678
+ it("shows server error message even when there are no rules", () => {
679
+ const error: FetchErrorData = {
680
+ status: 500,
681
+ response: JSON.stringify({ detail: "Internal server error" }),
682
+ url: "",
683
+ };
684
+ render(<PatronBlockingRulesEditor value={[]} error={error} />);
685
+ expect(screen.getByText(/Internal server error/i)).toBeTruthy();
686
+ });
687
+
688
+ it("getValue does not include internal _id field in returned rules", () => {
689
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
690
+ render(<PatronBlockingRulesEditor ref={ref} value={existingRules} />);
691
+ const value = ref.current.getValue();
692
+ value.forEach((rule) => {
693
+ expect(rule).not.toHaveProperty("_id");
694
+ });
695
+ });
696
+
697
+ it("hides the 'no rules' message once a rule is added", async () => {
698
+ const user = userEvent.setup();
699
+ render(<PatronBlockingRulesEditor value={[]} />);
700
+ expect(screen.getByText(/No patron blocking rules defined/i)).toBeTruthy();
701
+
702
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
703
+
704
+ expect(screen.queryByText(/No patron blocking rules defined/i)).toBeNull();
705
+ });
706
+ });
707
+
708
+ describe("PatronBlockingRulesEditor — validateAndGetValue", () => {
709
+ beforeEach(() => {
710
+ fetchMock.post(VALIDATE_URL, { status: 200 });
711
+ });
712
+
713
+ afterEach(() => {
714
+ fetchMock.mockReset();
715
+ });
716
+
717
+ it("returns all rules (stripped of _id) when every rule has name and expression", () => {
718
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
719
+ render(<PatronBlockingRulesEditor ref={ref} value={existingRules} />);
720
+
721
+ let result: PatronBlockingRule[] | null;
722
+ act(() => {
723
+ result = ref.current.validateAndGetValue();
724
+ });
725
+
726
+ expect(result).toHaveLength(2);
727
+ expect(result[0]).toEqual({
728
+ name: "Rule A",
729
+ rule: "expr_a",
730
+ message: "msg a",
731
+ });
732
+ expect(result[1]).toEqual({ name: "Rule B", rule: "expr_b" });
733
+ result.forEach((r) => expect(r).not.toHaveProperty("_id"));
734
+ });
735
+
736
+ it("returns null and shows a name error when a rule is missing its name", async () => {
737
+ const user = userEvent.setup();
738
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
739
+ render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
740
+
741
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
742
+ // Leave name empty, fill only the expression
743
+ await user.type(screen.getByLabelText(/Rule Expression/i), "expr");
744
+
745
+ let result: PatronBlockingRule[] | null;
746
+ act(() => {
747
+ result = ref.current.validateAndGetValue();
748
+ });
749
+
750
+ expect(result).toBeNull();
751
+ expect(screen.getByText(/Rule Name is required/i)).toBeTruthy();
752
+ expect(screen.queryByText(/Rule Expression is required/i)).toBeNull();
753
+ });
754
+
755
+ it("returns null and shows an expression error when a rule is missing its expression", async () => {
756
+ const user = userEvent.setup();
757
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
758
+ render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
759
+
760
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
761
+ // Fill only the name, leave expression empty
762
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
763
+
764
+ let result: PatronBlockingRule[] | null;
765
+ act(() => {
766
+ result = ref.current.validateAndGetValue();
767
+ });
768
+
769
+ expect(result).toBeNull();
770
+ expect(screen.queryByText(/Rule Name is required/i)).toBeNull();
771
+ expect(screen.getByText(/Rule Expression is required/i)).toBeTruthy();
772
+ });
773
+
774
+ it("returns null and shows both errors when a rule has neither name nor expression", async () => {
775
+ const user = userEvent.setup();
776
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
777
+ render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
778
+
779
+ // Add rule but leave both fields empty
780
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
781
+
782
+ let result: PatronBlockingRule[] | null;
783
+ act(() => {
784
+ result = ref.current.validateAndGetValue();
785
+ });
786
+
787
+ expect(result).toBeNull();
788
+ expect(screen.getByText(/Rule Name is required/i)).toBeTruthy();
789
+ expect(screen.getByText(/Rule Expression is required/i)).toBeTruthy();
790
+ });
791
+
792
+ it("clears prior client errors on a subsequent call that succeeds", async () => {
793
+ const user = userEvent.setup();
794
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
795
+ render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
796
+
797
+ await user.click(screen.getByRole("button", { name: /Add Rule/i }));
798
+
799
+ // First call: both fields empty → errors shown
800
+ act(() => {
801
+ ref.current.validateAndGetValue();
802
+ });
803
+ expect(screen.getByText(/Rule Name is required/i)).toBeTruthy();
804
+ expect(screen.getByText(/Rule Expression is required/i)).toBeTruthy();
805
+
806
+ // Fill in both fields
807
+ await user.type(screen.getByLabelText(/Rule Name/i), "My Rule");
808
+ await user.type(screen.getByLabelText(/Rule Expression/i), "expr");
809
+
810
+ // Second call: valid → errors gone, rules returned
811
+ let result: PatronBlockingRule[] | null;
812
+ act(() => {
813
+ result = ref.current.validateAndGetValue();
814
+ });
815
+
816
+ expect(result).toHaveLength(1);
817
+ expect(result[0].name).toBe("My Rule");
818
+ expect(screen.queryByText(/Rule Name is required/i)).toBeNull();
819
+ expect(screen.queryByText(/Rule Expression is required/i)).toBeNull();
820
+ });
821
+
822
+ it("returns an empty array (not null) when there are no rules at all", () => {
823
+ const ref = React.createRef<PatronBlockingRulesEditorHandle>();
824
+ render(<PatronBlockingRulesEditor ref={ref} value={[]} />);
825
+
826
+ let result: PatronBlockingRule[] | null;
827
+ act(() => {
828
+ result = ref.current.validateAndGetValue();
829
+ });
830
+
831
+ expect(result).toEqual([]);
832
+ });
833
+ });