@thepalaceproject/circulation-admin 1.46.0-post.1 → 1.46.0-post.3

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
@@ -139,5 +139,5 @@
139
139
  "*.{js,jsx,ts,tsx,css,md}": "prettier --write",
140
140
  "*.{js,css,md}": "prettier --write"
141
141
  },
142
- "version": "1.46.0-post.1"
142
+ "version": "1.46.0-post.3"
143
143
  }
@@ -52,6 +52,42 @@ describe("DiscoveryServices - registered library disclosure", () => {
52
52
 
53
53
  // ── Toggle visibility ─────────────────────────────────────────────────────
54
54
 
55
+ it("names registration data in the failed-library-list alert", () => {
56
+ // This tab's entries fall back to registration records, so the generic
57
+ // "shown by short name only" message would be wrong here.
58
+ const { container } = renderServices({
59
+ discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any],
60
+ allLibraries: [],
61
+ allLibrariesError: {
62
+ status: 500,
63
+ response: "nope",
64
+ url: "/admin/libraries",
65
+ },
66
+ libraryRegistrations: [
67
+ { id: 1, libraries: [{ short_name: "alpha", status: "success" }] },
68
+ ] as any,
69
+ });
70
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
71
+ "Registered libraries are shown using registration data"
72
+ );
73
+ });
74
+
75
+ it("shows no toggle while allLibraries has not settled", () => {
76
+ // Labels must render once, in final linked form, not flash bare short
77
+ // names that get rewritten when the sitewide list arrives.
78
+ const { container } = renderServices({
79
+ discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any],
80
+ allLibraries: undefined,
81
+ libraryRegistrations: [
82
+ {
83
+ id: 1,
84
+ libraries: [{ short_name: "alpha", status: "success" }],
85
+ },
86
+ ] as any,
87
+ });
88
+ expect(container.querySelector(".association-toggle")).toBeNull();
89
+ });
90
+
55
91
  it("shows no toggle when libraryRegistrations data has not yet loaded", () => {
56
92
  const { container } = renderServices({
57
93
  discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any],
@@ -23,6 +23,12 @@ describe("EditableConfigList - library association disclosure", () => {
23
23
  interface ServicesData {
24
24
  services: ServiceItem[];
25
25
  allLibraries?: Array<{ short_name: string; name?: string; uuid?: string }>;
26
+ allLibrariesError?: { status: number; response: string; url: string };
27
+ allLibrariesRefreshError?: {
28
+ status: number;
29
+ response: string;
30
+ url: string;
31
+ };
26
32
  }
27
33
 
28
34
  class TestEditForm extends React.Component<
@@ -36,6 +42,7 @@ describe("EditableConfigList - library association disclosure", () => {
36
42
  class TestServiceList extends EditableConfigList<ServicesData, ServiceItem> {
37
43
  EditForm = TestEditForm;
38
44
  listDataKey = "services";
45
+ usesLibraryList = true;
39
46
  itemTypeName = "service";
40
47
  urlBase = "/admin/services/";
41
48
  identifierKey = "id";
@@ -377,13 +384,19 @@ describe("EditableConfigList - library association disclosure", () => {
377
384
  expect(items[1].textContent).toBe("Beta Library - beta");
378
385
  });
379
386
 
380
- it("falls back to short_name when allLibraries is absent from the data", () => {
387
+ it("shows an alert and bare short names when the library list failed to load", () => {
381
388
  const { container } = renderWithContext(
382
389
  <TestServiceList
383
390
  data={{
384
391
  services: [
385
392
  { id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] },
386
393
  ],
394
+ allLibraries: [],
395
+ allLibrariesError: {
396
+ status: 500,
397
+ response: "nope",
398
+ url: "/admin/libraries",
399
+ },
387
400
  }}
388
401
  fetchData={jest.fn()}
389
402
  editItem={jest.fn().mockResolvedValue(undefined)}
@@ -393,11 +406,72 @@ describe("EditableConfigList - library association disclosure", () => {
393
406
  />,
394
407
  config
395
408
  );
409
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
410
+ "The library list failed to load"
411
+ );
412
+ // The associations still render, by short name, below the alert.
396
413
  fireEvent.click(container.querySelector(".association-toggle"));
397
414
  expect(container.querySelector(".associated-items li").textContent).toBe(
398
415
  "nypl"
399
416
  );
400
417
  });
418
+
419
+ it("shows a warning when the library list is stale", () => {
420
+ const { container } = renderWithContext(
421
+ <TestServiceList
422
+ data={{
423
+ services: [
424
+ { id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] },
425
+ ],
426
+ allLibraries: [{ short_name: "nypl", name: "NYPL" }],
427
+ allLibrariesRefreshError: {
428
+ status: 500,
429
+ response: "nope",
430
+ url: "/admin/libraries",
431
+ },
432
+ }}
433
+ fetchData={jest.fn()}
434
+ editItem={jest.fn().mockResolvedValue(undefined)}
435
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
436
+ csrfToken="token"
437
+ isFetching={false}
438
+ />,
439
+ config
440
+ );
441
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
442
+ "The library list could not be refreshed"
443
+ );
444
+ // The associations still render, from the retained list.
445
+ fireEvent.click(container.querySelector(".association-toggle"));
446
+ expect(container.querySelector(".associated-items li").textContent).toBe(
447
+ "NYPL - nypl"
448
+ );
449
+ });
450
+
451
+ it("holds the association panel until allLibraries settles", () => {
452
+ const { container } = renderWithContext(
453
+ <TestServiceList
454
+ data={{
455
+ services: [
456
+ { id: 1, name: "Service A", libraries: [{ short_name: "nypl" }] },
457
+ ],
458
+ }}
459
+ fetchData={jest.fn()}
460
+ editItem={jest.fn().mockResolvedValue(undefined)}
461
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
462
+ csrfToken="token"
463
+ isFetching={false}
464
+ />,
465
+ config
466
+ );
467
+ // With the sitewide list unsettled, no toggle renders at all, so the
468
+ // panel cannot flash bare short names that get rewritten when the
469
+ // list arrives. The status line explains and announces the gap.
470
+ expect(container.querySelector(".association-toggle")).toBeNull();
471
+ expect(container.querySelector('[role="status"]')).toHaveTextContent(
472
+ "Loading libraries..."
473
+ );
474
+ });
401
475
  });
402
476
  });
403
477
 
@@ -202,6 +202,104 @@ describe("IndividualAdminEditForm - rendered inputs and role changes", () => {
202
202
  const roleCheckbox = (role: string) =>
203
203
  screen.getByRole("checkbox", { name: roleNames[role] });
204
204
 
205
+ const failureData = {
206
+ individualAdmins: [adminData],
207
+ allLibraries: [],
208
+ allLibrariesError: {
209
+ status: 500,
210
+ response: "nope",
211
+ url: "/admin/libraries",
212
+ },
213
+ };
214
+
215
+ it("waits for the library list before showing per-library roles", () => {
216
+ const { container } = renderForm({
217
+ item: adminData,
218
+ data: { individualAdmins: [adminData] },
219
+ });
220
+ expect(container.querySelector("table.library-admin-roles")).toBeNull();
221
+ expect(container.querySelector('[role="status"]')).toHaveTextContent(
222
+ "Loading libraries..."
223
+ );
224
+ // Sitewide toggles rewrite an existing admin's hidden per-library roles
225
+ // wholesale, so role edits are disabled until the list is available.
226
+ expect(roleCheckbox("system")).toBeDisabled();
227
+ });
228
+
229
+ it("explains a failed library list load when editing an existing admin", () => {
230
+ // On failure allLibraries settles to [] and allLibrariesError is set.
231
+ const { container } = renderForm({ item: adminData, data: failureData });
232
+ expect(
233
+ screen.getByText(
234
+ /library roles cannot be shown, and no roles can be changed/
235
+ )
236
+ ).toBeInTheDocument();
237
+ // The role="alert" danger Alert announces the failure itself, so the
238
+ // status region empties rather than duplicating the announcement.
239
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
240
+ expect(roleCheckbox("system")).toBeDisabled();
241
+ // All role edits are disabled, so the empty table shell is dropped.
242
+ expect(container.querySelector("table.library-admin-roles")).toBeNull();
243
+ });
244
+
245
+ it("still allows sitewide roles for a new admin when the library list failed", () => {
246
+ // A new admin has no hidden per-library roles to clobber.
247
+ const { container } = renderForm({ data: failureData });
248
+ expect(
249
+ screen.getByText(/Sitewide roles can still be assigned/)
250
+ ).toBeInTheDocument();
251
+ expect(roleCheckbox("system")).toBeEnabled();
252
+ expect(roleCheckbox("manager-all")).toBeEnabled();
253
+ // No misleading "no libraries" row under a failure.
254
+ expect(container.querySelector("tbody")).toBeEmptyDOMElement();
255
+ });
256
+
257
+ const staleData = {
258
+ individualAdmins: [adminData],
259
+ allLibraries,
260
+ allLibrariesRefreshError: {
261
+ status: 500,
262
+ response: "nope",
263
+ url: "/admin/libraries",
264
+ },
265
+ };
266
+
267
+ it("locks only per-library roles for an existing admin while the list is stale", () => {
268
+ const { container } = renderForm({ item: adminData, data: staleData });
269
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
270
+ "Per-library roles cannot be changed until the list can be refreshed."
271
+ );
272
+ // The role="alert" warning announces the refresh failure itself, so
273
+ // the status region empties rather than duplicating the announcement.
274
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
275
+ // Only the per-library un-check branches expand sitewide roles from
276
+ // the (stale) list; sitewide toggles are list-independent and stay
277
+ // assignable, so submitting cannot be forced into a roleless admin.
278
+ expect(roleCheckbox("system")).toBeEnabled();
279
+ expect(roleCheckbox("manager-all")).toBeEnabled();
280
+ expect(roleCheckbox("manager-nypl")).toBeDisabled();
281
+ expect(roleCheckbox("librarian-nypl")).toBeDisabled();
282
+ });
283
+
284
+ it("locks only per-library roles for a new admin while the list is stale", () => {
285
+ const { container } = renderForm({ data: staleData });
286
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
287
+ "Per-library roles cannot be changed"
288
+ );
289
+ expect(roleCheckbox("system")).toBeEnabled();
290
+ expect(roleCheckbox("manager-all")).toBeEnabled();
291
+ expect(roleCheckbox("manager-nypl")).toBeDisabled();
292
+ });
293
+
294
+ it("says when no libraries are configured", () => {
295
+ renderForm({
296
+ data: { individualAdmins: [adminData], allLibraries: [] },
297
+ });
298
+ expect(
299
+ screen.getByText("No libraries are configured.")
300
+ ).toBeInTheDocument();
301
+ });
302
+
205
303
  const expectRoles = (expected: string[]) => {
206
304
  for (const role of allRoles) {
207
305
  if (expected.includes(role)) {
@@ -46,6 +46,84 @@ describe("IndividualAdmins - role association disclosure", () => {
46
46
 
47
47
  // ── Toggle visibility ─────────────────────────────────────────────────────
48
48
 
49
+ it("shows no toggle while allLibraries has not settled", () => {
50
+ // Labels must render once, in final linked form, not flash bare short
51
+ // names that get rewritten when the sitewide list arrives.
52
+ const { container } = renderWithContext(
53
+ <IndividualAdmins
54
+ data={{
55
+ individualAdmins: [
56
+ {
57
+ email: "admin@example.org",
58
+ roles: [{ role: "manager", library: "alpha" }],
59
+ },
60
+ ],
61
+ }}
62
+ fetchData={jest.fn()}
63
+ editItem={jest.fn().mockResolvedValue(undefined)}
64
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
65
+ csrfToken="token"
66
+ isFetching={false}
67
+ />,
68
+ sysAdminConfig
69
+ );
70
+ expect(container.querySelector(".association-toggle")).toBeNull();
71
+ });
72
+
73
+ it("shows the sysadmin entry before allLibraries settles", () => {
74
+ // The synthetic sysadmin entry never consults the library list and can
75
+ // never be rewritten by it, so it is not held back.
76
+ const { container } = renderWithContext(
77
+ <IndividualAdmins
78
+ data={{
79
+ individualAdmins: [
80
+ { email: "root@example.org", roles: [{ role: "system" }] },
81
+ ],
82
+ }}
83
+ fetchData={jest.fn()}
84
+ editItem={jest.fn().mockResolvedValue(undefined)}
85
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
86
+ csrfToken="token"
87
+ isFetching={false}
88
+ />,
89
+ sysAdminConfig
90
+ );
91
+ fireEvent.click(container.querySelector(".association-toggle"));
92
+ expect(container.querySelector(".associated-items li").textContent).toBe(
93
+ "sysadmin"
94
+ );
95
+ });
96
+
97
+ it("names roles, not libraries, when the library list failed to load", () => {
98
+ const { container } = renderWithContext(
99
+ <IndividualAdmins
100
+ data={{
101
+ individualAdmins: [
102
+ {
103
+ email: "admin@example.org",
104
+ roles: [{ role: "manager", library: "alpha" }],
105
+ },
106
+ ],
107
+ allLibraries: [],
108
+ allLibrariesError: {
109
+ status: 500,
110
+ response: "nope",
111
+ url: "/admin/libraries",
112
+ },
113
+ }}
114
+ fetchData={jest.fn()}
115
+ editItem={jest.fn().mockResolvedValue(undefined)}
116
+ deleteItem={jest.fn().mockResolvedValue(undefined)}
117
+ csrfToken="token"
118
+ isFetching={false}
119
+ />,
120
+ sysAdminConfig
121
+ );
122
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
123
+ "Roles are shown by library short name only."
124
+ );
125
+ });
126
+
49
127
  it("shows no toggle for an admin with no roles field", () => {
50
128
  const { container } = renderAdmins([{ email: "noroles@example.com" }]);
51
129
  expect(container.querySelector(".association-toggle")).toBeNull();
@@ -498,10 +576,13 @@ describe("IndividualAdmins - connect wiring", () => {
498
576
  afterEach(() => jest.restoreAllMocks());
499
577
 
500
578
  it("renders the connected default export, fetching on mount", async () => {
501
- jest.spyOn(globalThis, "fetch").mockResolvedValue(
502
- new Response(JSON.stringify({ individualAdmins: [] }), {
503
- headers: { "Content-Type": "application/json" },
504
- })
579
+ // A Response body can only be read once, so build one per fetch call.
580
+ const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation(() =>
581
+ Promise.resolve(
582
+ new Response(JSON.stringify({ individualAdmins: [] }), {
583
+ headers: { "Content-Type": "application/json" },
584
+ })
585
+ )
505
586
  );
506
587
 
507
588
  renderWithProviders(
@@ -517,5 +598,12 @@ describe("IndividualAdmins - connect wiring", () => {
517
598
  expect(
518
599
  await screen.findByText("Create new individual admin")
519
600
  ).toBeInTheDocument();
601
+
602
+ // Outside setup mode, fetchData also requests the libraries list (the
603
+ // settingUp half of that guard is pinned in SetupPage.test.tsx).
604
+ const urls = fetchSpy.mock.calls.map((call) => String(call[0]));
605
+ expect(urls).toEqual(
606
+ expect.arrayContaining([expect.stringContaining("/admin/libraries")])
607
+ );
520
608
  });
521
609
  });
@@ -87,6 +87,24 @@ describe("LibraryRegistration", () => {
87
87
  expect(libraries(container)).toHaveLength(0);
88
88
  });
89
89
 
90
+ it("explains instead of vanishing when the library list failed to load", () => {
91
+ const { container } = renderReg({
92
+ item: serviceData,
93
+ data: makeData({
94
+ allLibraries: [],
95
+ allLibrariesError: {
96
+ status: 500,
97
+ response: "nope",
98
+ url: "/admin/libraries",
99
+ },
100
+ }),
101
+ });
102
+ expect(libraries(container)).toHaveLength(0);
103
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
104
+ "Libraries cannot be registered: the library list is unavailable."
105
+ );
106
+ });
107
+
90
108
  it("doesn't render libraries in edit form if protocol doesn't support registration", () => {
91
109
  const { container } = renderReg({
92
110
  item: serviceData,
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { render, screen, waitFor } from "@testing-library/react";
2
+ import { render, screen, waitFor, within } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import * as fetchMock from "fetch-mock-jest";
5
5
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -217,8 +217,11 @@ describe("PatronAuthServiceEditForm – serialization", () => {
217
217
 
218
218
  await user.click(saveButton);
219
219
 
220
- // The library should now be collapsed (indicating editLibrary was called)
220
+ // Saving collapses the library. Reopening it shows the rule was stored in
221
+ // library state, not just read from the editor.
221
222
  expect(screen.queryByRole("button", { name: /Add Rule/i })).toBeNull();
223
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
224
+ expect(screen.getByLabelText(/Rule Name/i)).toHaveValue("Test Rule");
222
225
  });
223
226
 
224
227
  it("includes patron_blocking_rules in payload when adding a new library", async () => {
@@ -253,8 +256,11 @@ describe("PatronAuthServiceEditForm – serialization", () => {
253
256
 
254
257
  await user.click(addButton);
255
258
 
256
- // After adding, the library should appear in the list, editor no longer in "new library" form
259
+ // The new-library editor is gone. Opening the added library shows the rule
260
+ // was stored in library state.
257
261
  expect(screen.queryByLabelText(/Rule Name/i)).toBeNull();
262
+ await user.click(screen.getByRole("button", { name: /Edit/i }));
263
+ expect(screen.getByLabelText(/Rule Name/i)).toHaveValue("New Rule");
258
264
  });
259
265
  });
260
266
 
@@ -268,8 +274,10 @@ describe("PatronAuthServiceEditForm – per-library settings", () => {
268
274
  ...servicesData,
269
275
  protocols: [protocolWithLibrarySettings, OTHER_PROTOCOL_DATA],
270
276
  };
271
- const settingInput = (root: ParentNode) =>
272
- root.querySelector(`input[name="${LIB_SETTING_KEY}"]`) as HTMLInputElement;
277
+ const settingInput = (root: HTMLElement) =>
278
+ within(root).getByRole("textbox", {
279
+ name: /Library setting/i,
280
+ }) as HTMLInputElement;
273
281
 
274
282
  it("keeps a library setting entered when adding a new library", async () => {
275
283
  const user = userEvent.setup();
@@ -77,6 +77,21 @@ describe("PatronAuthServices", () => {
77
77
  ).not.toBeInTheDocument();
78
78
  });
79
79
 
80
+ it("fetches the libraries list along with the services on mount", async () => {
81
+ const fetchSpy = stubFetch(listData);
82
+ renderConnected(<PatronAuthServices csrfToken="token" />);
83
+
84
+ await waitFor(() => {
85
+ const urls = fetchSpy.mock.calls.map((call) => String(call[0]));
86
+ expect(urls).toEqual(
87
+ expect.arrayContaining([
88
+ expect.stringContaining("/admin/patron_auth_services"),
89
+ expect.stringContaining("/admin/libraries"),
90
+ ])
91
+ );
92
+ });
93
+ });
94
+
80
95
  it("shows the neighborhood analytics panel when creating a service whose protocol has a neighborhood_mode setting", async () => {
81
96
  const neighborhoodSetting = {
82
97
  key: "neighborhood_mode",
@@ -4,6 +4,7 @@ import { render, within } from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
 
6
6
  import ServiceEditForm, {
7
+ libraryRefKey,
7
8
  ServiceEditFormProps,
8
9
  ServiceEditFormState,
9
10
  } from "../../../src/components/ServiceEditForm";
@@ -483,6 +484,91 @@ describe("ServiceEditForm", () => {
483
484
  expect(container.querySelector(".with-edit-button a")).toBeNull();
484
485
  });
485
486
 
487
+ it("shows a loading indicator in the Libraries panel until allLibraries arrives", () => {
488
+ // Undefined allLibraries means the library list is still loading.
489
+ const dataStillLoading = Object.assign({}, servicesData, {
490
+ allLibraries: undefined,
491
+ });
492
+ const { container, rerender } = renderForm({
493
+ data: dataStillLoading,
494
+ item: serviceData,
495
+ });
496
+ expect(container.querySelector(".update-libraries")).toBeNull();
497
+ expect(container.querySelector('[role="status"]')).toHaveTextContent(
498
+ "Loading libraries..."
499
+ );
500
+
501
+ rerenderForm(rerender, { item: serviceData });
502
+ // The status line stays mounted and announces completion; the
503
+ // completion text is visually hidden.
504
+ const status = container.querySelector('[role="status"]');
505
+ expect(status).toHaveTextContent("Libraries loaded.");
506
+ expect(status).toHaveClass("visuallyHidden");
507
+ const editable = container.querySelectorAll(".with-edit-button");
508
+ expect(editable).toHaveLength(1);
509
+ expect(editable[0]).toHaveTextContent("New York Public Library - nypl");
510
+ });
511
+
512
+ it("explains a failed library list load in the Libraries panel", () => {
513
+ // On failure allLibraries settles to [] and allLibrariesError is set.
514
+ const dataWithError = Object.assign({}, servicesData, {
515
+ allLibraries: [],
516
+ allLibrariesError: {
517
+ status: 500,
518
+ response: "nope",
519
+ url: "/admin/libraries",
520
+ },
521
+ });
522
+ const { container } = renderForm({
523
+ data: dataWithError,
524
+ item: serviceData,
525
+ });
526
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
527
+ "The library list failed to load"
528
+ );
529
+ // The role="alert" danger Alert announces the failure itself, so the
530
+ // status region empties rather than duplicating the announcement.
531
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
532
+ // The associated library still renders, by short name.
533
+ const editable = container.querySelectorAll(".with-edit-button");
534
+ expect(editable).toHaveLength(1);
535
+ expect(editable[0]).toHaveTextContent("nypl");
536
+ // A removal could not be undone in-session, so it is disabled too.
537
+ expect(container.querySelector("button.remove-btn")).toBeDisabled();
538
+ });
539
+
540
+ it("warns when the library list could not be refreshed", () => {
541
+ const staleData = Object.assign({}, servicesData, {
542
+ allLibrariesRefreshError: {
543
+ status: 500,
544
+ response: "nope",
545
+ url: "/admin/libraries",
546
+ },
547
+ });
548
+ const { container } = renderForm({ data: staleData, item: serviceData });
549
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
550
+ "The library list could not be refreshed"
551
+ );
552
+ // The role="alert" warning announces the refresh failure itself, so
553
+ // the status region empties rather than duplicating the announcement.
554
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
555
+ // The stale list still renders in full, links included, and the
556
+ // panel stays editable; only the blocking error disables removal.
557
+ const editable = container.querySelectorAll(".with-edit-button");
558
+ expect(editable).toHaveLength(1);
559
+ expect(editable[0]).toHaveTextContent("New York Public Library - nypl");
560
+ expect(container.querySelector(".alert-danger")).toBeNull();
561
+ expect(container.querySelector("button.remove-btn")).toBeEnabled();
562
+ });
563
+
564
+ it("says when no libraries are configured", () => {
565
+ const emptyData = Object.assign({}, servicesData, { allLibraries: [] });
566
+ const { container } = renderForm({ data: emptyData });
567
+ expect(container.querySelector(".update-libraries")).toHaveTextContent(
568
+ "No libraries are configured."
569
+ );
570
+ });
571
+
486
572
  it("renders removable and editable libraries", () => {
487
573
  const { container, unmount } = renderForm();
488
574
  expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
@@ -999,6 +1085,15 @@ describe("ServiceEditForm", () => {
999
1085
  ).toBe("option3");
1000
1086
  });
1001
1087
 
1088
+ it("builds library ref keys for add and edit mode", () => {
1089
+ expect(libraryRefKey("key", "add")).toBe("key");
1090
+ expect(libraryRefKey("key", "edit", "nypl")).toBe("nypl_key");
1091
+ // Compile-time guard: ts-jest fails this file if the overloads stop
1092
+ // rejecting an edit-mode call with no short name.
1093
+ // @ts-expect-error edit mode must pass the library short name
1094
+ libraryRefKey("key", "edit");
1095
+ });
1096
+
1002
1097
  it("calls save when the form is submitted", async () => {
1003
1098
  // The reusable Form wires submission solely through its submit button's
1004
1099
  // onClick, so clicking it is the only way to submit; assert the resulting
@@ -6,13 +6,16 @@ import SetupPage from "../../../src/components/SetupPage";
6
6
 
7
7
  describe("SetupPage", () => {
8
8
  beforeEach(() => {
9
- // SetupPage renders the connected IndividualAdmins list, which fetches the
10
- // admin list on mount. Stub fetch so mounting does not hit the network.
11
- jest.spyOn(globalThis, "fetch").mockResolvedValue(
12
- new Response(JSON.stringify({ individualAdmins: [] }), {
13
- status: 200,
14
- headers: { "Content-Type": "application/json" },
15
- })
9
+ // SetupPage renders the connected IndividualAdmins list, which fetches on
10
+ // mount. Stub fetch so mounting does not hit the network; a Response body
11
+ // can only be read once, so build one per call.
12
+ jest.spyOn(globalThis, "fetch").mockImplementation(() =>
13
+ Promise.resolve(
14
+ new Response(JSON.stringify({ individualAdmins: [] }), {
15
+ status: 200,
16
+ headers: { "Content-Type": "application/json" },
17
+ })
18
+ )
16
19
  );
17
20
  });
18
21
 
@@ -37,5 +40,19 @@ describe("SetupPage", () => {
37
40
  name: "Set up your system admin account",
38
41
  })
39
42
  ).toBeInTheDocument();
43
+
44
+ // settingUp skips the libraries request, which cannot succeed before an
45
+ // admin exists. The positive check keeps the negative one honest.
46
+ const urls = (globalThis.fetch as jest.Mock).mock.calls.map((call) =>
47
+ String(call[0])
48
+ );
49
+ expect(urls).toEqual(
50
+ expect.arrayContaining([
51
+ expect.stringContaining("/admin/individual_admins"),
52
+ ])
53
+ );
54
+ expect(urls).not.toEqual(
55
+ expect.arrayContaining([expect.stringContaining("/admin/libraries")])
56
+ );
40
57
  });
41
58
  });