@thepalaceproject/circulation-admin 0.0.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.
Files changed (72) hide show
  1. package/.eslintrc +59 -0
  2. package/.node-version +1 -0
  3. package/.nvmrc +1 -0
  4. package/.prettierrc.json +10 -0
  5. package/.sass-lint.yml +37 -0
  6. package/CHANGELOG.md +946 -0
  7. package/README.md +155 -0
  8. package/dist/060b2710bdbbe3dfe48b58d59bd5f1fb.svg +288 -0
  9. package/dist/0db1520f47986b6c755a.svg +1 -0
  10. package/dist/1e59d2330b4c6deb84b3.ttf +0 -0
  11. package/dist/20fd1704ea223900efa9.woff2 +0 -0
  12. package/dist/4692b9ec53fd5972caa2.ttf +0 -0
  13. package/dist/5be1347c682810f199c7.eot +0 -0
  14. package/dist/6563aa3790be8329e4f2.svg +1 -0
  15. package/dist/82b1212e45a2bc35dd73.woff +0 -0
  16. package/dist/8b43027f47b20503057d.eot +0 -0
  17. package/dist/PalaceCollectionManagerLogo.svg +122 -0
  18. package/dist/be810be3a3e14c682a25.woff2 +0 -0
  19. package/dist/c1e38fd9e0e74ba58f7a2b77ef29fdd3.svg +2671 -0
  20. package/dist/circulation-admin.css +6841 -0
  21. package/dist/circulation-admin.js +2 -0
  22. package/dist/circulation-admin.js.LICENSE.txt +153 -0
  23. package/dist/f691f37e57f04c152e23.woff +0 -0
  24. package/jest.config.js +15 -0
  25. package/jest.polyfills.js +12 -0
  26. package/nightwatch.json +58 -0
  27. package/package.json +155 -0
  28. package/pull_request_template.md +22 -0
  29. package/requirements-ci.txt +1 -0
  30. package/testReporter.js +31 -0
  31. package/tests/__data__/statisticsApiResponseData.ts +327 -0
  32. package/tests/__mocks__/fileMock.js +1 -0
  33. package/tests/__mocks__/styleMock.js +1 -0
  34. package/tests/browser/README.md +19 -0
  35. package/tests/browser/assertions/noError.js +38 -0
  36. package/tests/browser/commands/goHome.js +13 -0
  37. package/tests/browser/commands/signIn.js +18 -0
  38. package/tests/browser/globals.js.sample +5 -0
  39. package/tests/browser/navigate.js +294 -0
  40. package/tests/browser/pages/book.js +21 -0
  41. package/tests/browser/pages/catalog.js +24 -0
  42. package/tests/browser/pages/login.js +11 -0
  43. package/tests/browser/redirect.js +104 -0
  44. package/tests/browser/signInFailure.js +22 -0
  45. package/tests/jest/README.md +6 -0
  46. package/tests/jest/api/admin.test.ts +60 -0
  47. package/tests/jest/businessRules/roleBasedAccess.test.ts +250 -0
  48. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +38 -0
  49. package/tests/jest/components/BookEditor.test.tsx +240 -0
  50. package/tests/jest/components/CirculationEventsDownload.test.tsx +65 -0
  51. package/tests/jest/components/CustomLists.test.tsx +203 -0
  52. package/tests/jest/components/EditableInput.test.tsx +64 -0
  53. package/tests/jest/components/IndividualAdminEditForm.test.tsx +128 -0
  54. package/tests/jest/components/InventoryReportRequestModal.test.tsx +652 -0
  55. package/tests/jest/components/Lane.test.tsx +78 -0
  56. package/tests/jest/components/LaneEditor.test.tsx +148 -0
  57. package/tests/jest/components/ProtocolFormField.test.tsx +37 -0
  58. package/tests/jest/components/QuicksightDashboard.test.tsx +67 -0
  59. package/tests/jest/components/Stats.test.tsx +699 -0
  60. package/tests/jest/context/AppContext.test.tsx +113 -0
  61. package/tests/jest/features/book.test.ts +396 -0
  62. package/tests/jest/jest-setup.ts +1 -0
  63. package/tests/jest/sample/sample.test.js +3 -0
  64. package/tests/jest/testUtils/renderWithContext.tsx +38 -0
  65. package/tests/jest/testUtils/withProviders.tsx +92 -0
  66. package/tests/jest/utils/NoCacheDataFetcher.test.ts +75 -0
  67. package/tsconfig.json +25 -0
  68. package/tslint.json +56 -0
  69. package/webpack.common.js +72 -0
  70. package/webpack.dev-server.config.js +215 -0
  71. package/webpack.dev.config.js +9 -0
  72. package/webpack.prod.config.js +8 -0
@@ -0,0 +1,113 @@
1
+ import { renderHook } from "@testing-library/react-hooks";
2
+ import {
3
+ useAppAdmin,
4
+ useAppContext,
5
+ useAppEmail,
6
+ useAppFeatureFlags,
7
+ useCsrfToken,
8
+ useTermsOfService,
9
+ useSupportContact,
10
+ SupportContactLink,
11
+ } from "../../../src/context/appContext";
12
+ import { componentWithProviders } from "../testUtils/withProviders";
13
+ import {
14
+ AdminRoleData,
15
+ ConfigurationSettings,
16
+ FeatureFlags,
17
+ } from "../../../src/interfaces";
18
+
19
+ // TODO: These tests may need to be adjusted in the future.
20
+ // Currently, an AppContext.Provider is injected into the component tree
21
+ // by the ContextProvider, which itself uses a legacy context API. (See
22
+ // https://legacy.reactjs.org/docs/legacy-context.html)
23
+ // but that will change once uses of that API have been removed.
24
+
25
+ describe("AppContext", () => {
26
+ const expectedCsrfToken = "token";
27
+ const expectedEmail = "email";
28
+ const expectedFeatureFlags: FeatureFlags = {
29
+ // @ts-expect-error - "testTrue" & "testFalse" aren't valid feature flags
30
+ testTrue: true,
31
+ testFalse: false,
32
+ };
33
+ const expectedRoles: AdminRoleData[] = [{ role: "system" }];
34
+ const expectedTermsOfService = {
35
+ text: "Terms of Service",
36
+ href: "/terms-of-service",
37
+ };
38
+ const emailAddress = "helpdesk@example.com";
39
+ const expectedSupportContact: SupportContactLink = {
40
+ href: `mailto:${emailAddress}?subject=Support+request`,
41
+ text: `Email ${emailAddress}.`,
42
+ };
43
+
44
+ const appConfigSettings: Partial<ConfigurationSettings> = {
45
+ csrfToken: expectedCsrfToken,
46
+ featureFlags: expectedFeatureFlags,
47
+ roles: expectedRoles,
48
+ email: expectedEmail,
49
+ tos_link_text: expectedTermsOfService.text,
50
+ tos_link_href: expectedTermsOfService.href,
51
+ support_contact_url: "deprecated and should be overridden",
52
+ supportContactUrl: expectedSupportContact.href,
53
+ };
54
+ const wrapper = componentWithProviders({ appConfigSettings });
55
+
56
+ it("provides useAppContext context hook", () => {
57
+ const { result } = renderHook(() => useAppContext(), { wrapper });
58
+ const value = result.current;
59
+ expect(value.csrfToken).toEqual(expectedCsrfToken);
60
+ expect(value.admin.email).toEqual(expectedEmail);
61
+ expect(value.admin.roles).toEqual(expectedRoles);
62
+ expect(value.featureFlags).toEqual(expectedFeatureFlags);
63
+ expect(value.supportContact).toEqual(expectedSupportContact);
64
+ });
65
+
66
+ it("provides useAppAdmin context hook", () => {
67
+ const { result } = renderHook(() => useAppAdmin(), { wrapper });
68
+ const admin = result.current;
69
+ expect(admin.email).toEqual(expectedEmail);
70
+ expect(admin.roles).toEqual(expectedRoles);
71
+ });
72
+
73
+ it("provides useAppEmail context hook", () => {
74
+ const { result } = renderHook(() => useAppEmail(), { wrapper });
75
+ const email = result.current;
76
+ expect(email).toEqual(expectedEmail);
77
+ });
78
+
79
+ it("provides useCsrfToken context hook", () => {
80
+ const { result } = renderHook(() => useCsrfToken(), { wrapper });
81
+ const token = result.current;
82
+ expect(token).toEqual(expectedCsrfToken);
83
+ });
84
+
85
+ it("provides useAppFeatureFlags context hook", () => {
86
+ const { result } = renderHook(() => useAppFeatureFlags(), {
87
+ wrapper,
88
+ });
89
+ const flags = result.current;
90
+ expect(flags).toEqual(expectedFeatureFlags);
91
+ });
92
+
93
+ it("provides useTermsOfService context hook", () => {
94
+ const { result } = renderHook(() => useTermsOfService(), {
95
+ wrapper,
96
+ });
97
+ const tosLink = result.current;
98
+ const { text, href } = tosLink;
99
+ expect(tosLink).toEqual(expectedTermsOfService);
100
+ expect(text).toEqual(expectedTermsOfService.text);
101
+ expect(href).toEqual(expectedTermsOfService.href);
102
+ });
103
+ it("provides useSupportContact context hook", () => {
104
+ const { result } = renderHook(() => useSupportContact(), {
105
+ wrapper,
106
+ });
107
+ const supportContact = result.current;
108
+ expect(supportContact).toBeDefined();
109
+ const { href, text } = supportContact;
110
+ expect(href).toEqual(expectedSupportContact.href);
111
+ expect(text).toEqual(expectedSupportContact.text);
112
+ });
113
+ });
@@ -0,0 +1,396 @@
1
+ import reducer, {
2
+ initialState,
3
+ getBookData,
4
+ GetBookDataArgs,
5
+ submitBookData,
6
+ } from "../../../src/features/book/bookEditorSlice";
7
+ import { expect } from "chai";
8
+ import * as fetchMock from "fetch-mock-jest";
9
+ import { store } from "../../../src/store";
10
+ import { BookData } from "@thepalaceproject/web-opds-client/lib/interfaces";
11
+ import { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher";
12
+ import { AsyncThunkAction, Dispatch } from "@reduxjs/toolkit";
13
+ import ActionCreator from "@thepalaceproject/web-opds-client/lib/actions";
14
+
15
+ const SAMPLE_BOOK_ADMIN_DETAIL = `
16
+ <entry xmlns="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:opds="http://opds-spec.org/2010/catalog" xmlns:opf="http://www.idpf.org/2007/opf" xmlns:drm="http://librarysimplified.org/terms/drm" xmlns:schema="http://schema.org/" xmlns:simplified="http://librarysimplified.org/terms/" xmlns:bibframe="http://bibframe.org/vocab/" xmlns:bib="http://bib.schema.org/" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/" xmlns:lcp="http://readium.org/lcp-specs/ns" schema:additionalType="http://schema.org/EBook">
17
+ <title>Tea-Cup Reading and Fortune-Telling by Tea Leaves</title>
18
+ <schema:alternativeHeadline>by a Highland Seer</schema:alternativeHeadline>
19
+ <summary>First written in the year 1881 by A Highland Seer, it contains a list of omens, both good and bad, many of which are very familiar. Folklore, mystic paths, and enlightenment of the divine are laid out in simple terms to help the everyday person interpret patterns found in tea leaves. (Google Books)</summary>
20
+ <simplified:pwid>60c23c76-f789-a51d-cd0b-31d2decf1271</simplified:pwid>
21
+ <dcterms:language>en</dcterms:language>
22
+ <dcterms:publisher>GEORGE SULLY AND COMPANY</dcterms:publisher>
23
+ <dcterms:issued>1921-01-01</dcterms:issued>
24
+ <id>urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1</id>
25
+ <bibframe:distribution bibframe:ProviderName="Palace Bookshelf"/>
26
+ <published>2022-12-17T00:00:00Z</published>
27
+ <updated>2023-05-03T16:31:10+00:00</updated>
28
+ <category scheme="http://schema.org/audience" term="Adult" label="Adult"/>
29
+ <author>
30
+ <name>Homer</name>
31
+ <link href="http://localhost:8080/minotaur-test-library/works/contributor/Homer/eng/Adult,Adults+Only,All+Ages,Children,Young+Adult" rel="contributor" type="application/atom+xml;profile=opds-catalog;kind=acquisition" title="Homer"/>
32
+ </author>
33
+ <link href="https://palace-bookshelf-downloads.dp.la/assets/0d970df7-275e-45ee-a90e-884f1a93beef?fit=inside&amp;width=10000&amp;height=560" rel="http://opds-spec.org/image" type="image/png"/>
34
+ <link href="https://palace-bookshelf-downloads.dp.la/0d970df7-275e-45ee-a90e-884f1a93beef.jpeg" rel="http://opds-spec.org/image/thumbnail" type="image/jpeg"/>
35
+ <link href="http://localhost:8080/minotaur-test-library/works/URI/urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1/borrow" rel="http://opds-spec.org/acquisition/borrow" type="application/atom+xml;type=entry;profile=opds-catalog">
36
+ <opds:indirectAcquisition type="application/epub+zip"/>
37
+ <opds:availability status="available"/>
38
+ </link>
39
+ <link href="http://localhost:8080/minotaur-test-library/works/URI/urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1" rel="alternate" type="application/atom+xml;type=entry;profile=opds-catalog"/>
40
+ <link href="http://localhost:8080/minotaur-test-library/works/URI/urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1/related_books" rel="related" type="application/atom+xml;profile=opds-catalog;kind=acquisition" title="Recommended Works"/>
41
+ <link href="http://localhost:8080/minotaur-test-library/annotations/URI/urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1" rel="http://www.w3.org/ns/oa#annotationService" type="application/ld+json; profile=&quot;http://www.w3.org/ns/anno.jsonld&quot;"/>
42
+ <link href="http://localhost:8080/minotaur-test-library/analytics/URI/urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1/open_book" rel="http://librarysimplified.org/terms/rel/analytics/open-book"/>
43
+ <link href="http://localhost:8080/minotaur-test-library/admin/works/URI/urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1/suppression" rel="http://palaceproject.io/terms/rel/suppress-for-library"/>
44
+ <link href="http://localhost:8080/admin/works/URI/urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1/edit" rel="edit"/>
45
+ </entry>
46
+ `;
47
+ const SAMPLE_BOOK_DATA_BROKEN_XML = `BROKEN ${SAMPLE_BOOK_ADMIN_DETAIL}`;
48
+ const FETCH_OPDS_PARSE_ERROR_MESSAGE = "Failed to parse OPDS data";
49
+
50
+ describe("Redux bookEditorSlice...", () => {
51
+ const bookData = { id: "urn:something:something", title: "test title" };
52
+
53
+ const fetchedState = {
54
+ url: "test url",
55
+ data: { ...bookData },
56
+ isFetching: false,
57
+ fetchError: null,
58
+ editError: null,
59
+ };
60
+
61
+ describe("reducers...", () => {
62
+ it("should return the initial state from undefined, if no action is passed", () => {
63
+ expect(reducer(undefined, { type: "unknown" })).to.deep.equal(
64
+ initialState
65
+ );
66
+ });
67
+ it("should return the initial state from initialState, if no action is passed", () => {
68
+ expect(reducer(initialState, { type: "unknown" })).to.deep.equal(
69
+ initialState
70
+ );
71
+ });
72
+ it("should handle BOOK_CLEAR", () => {
73
+ // This is dispatched by `web-opds`client`, but we need to handle it, too.
74
+ const action = { type: ActionCreator.BOOK_CLEAR };
75
+
76
+ expect(reducer(fetchedState, action)).to.deep.equal(initialState);
77
+ });
78
+
79
+ it("should handle getBookData.pending", () => {
80
+ const action = {
81
+ type: getBookData.pending.type,
82
+ meta: { arg: { url: "https://example.com/book" } },
83
+ };
84
+ const previousState = { ...initialState, url: null, isFetching: false };
85
+ const state = reducer(previousState, action);
86
+
87
+ expect(state.url).to.equal("https://example.com/book");
88
+ expect(state.data).to.be.null;
89
+ expect(state.isFetching).to.equal(true);
90
+ expect(state.fetchError).to.be.null;
91
+ expect(state.editError).to.be.null;
92
+ });
93
+ it("should handle getBookData.fulfilled", () => {
94
+ const action = {
95
+ type: getBookData.fulfilled.type,
96
+ meta: { arg: { url: "https://example.com/book" } },
97
+ payload: bookData,
98
+ };
99
+ const previousState = {
100
+ ...initialState,
101
+ url: null,
102
+ data: null,
103
+ isFetching: true,
104
+ };
105
+ const state = reducer(previousState, action);
106
+
107
+ expect(state.url).to.equal("https://example.com/book");
108
+ expect(state.data).to.deep.equal(bookData);
109
+ expect(state.isFetching).to.equal(false);
110
+ expect(state.fetchError).to.be.null;
111
+ expect(state.editError).to.be.null;
112
+ });
113
+ it("should handle getBookData.rejected", () => {
114
+ const errorObject = { error: "some error object" };
115
+ const action = {
116
+ type: getBookData.rejected.type,
117
+ meta: { arg: { url: "https://example.com/book" } },
118
+ payload: errorObject,
119
+ };
120
+ const previousState = {
121
+ ...initialState,
122
+ url: null,
123
+ data: null,
124
+ isFetching: true,
125
+ };
126
+ const state = reducer(previousState, action);
127
+
128
+ expect(state.url).to.equal("https://example.com/book");
129
+ expect(state.data).to.be.null;
130
+ expect(state.isFetching).to.equal(false);
131
+ expect(state.fetchError).to.deep.equal(errorObject);
132
+ expect(state.editError).to.be.null;
133
+ });
134
+
135
+ it("should handle submitBookData.pending", () => {
136
+ const action = { type: submitBookData.pending.type };
137
+ const previousState = { ...fetchedState, isFetching: false };
138
+ const state = reducer(previousState, action);
139
+
140
+ expect(state).to.deep.equal({ ...fetchedState, isFetching: true });
141
+ });
142
+ it("should handle submitBookData.fulfilled", () => {
143
+ const action = {
144
+ type: submitBookData.fulfilled.type,
145
+ payload: "some value",
146
+ };
147
+ const previousState = { ...fetchedState, isFetching: true };
148
+ const state = reducer(previousState, action);
149
+
150
+ expect(state).to.deep.equal({
151
+ ...fetchedState,
152
+ isFetching: false,
153
+ editError: null,
154
+ });
155
+ });
156
+ it("should handle submitBookData.rejected", () => {
157
+ const action = {
158
+ type: submitBookData.rejected.type,
159
+ payload: "some value",
160
+ };
161
+ const previousState = { ...fetchedState, isFetching: true };
162
+ const state = reducer(previousState, action);
163
+
164
+ expect(state).to.deep.equal({
165
+ ...fetchedState,
166
+ isFetching: false,
167
+ editError: "some value",
168
+ });
169
+ });
170
+ });
171
+
172
+ describe("thunks...", () => {
173
+ describe("getBookData...", () => {
174
+ const goodBookUrl = "https://example.com/book";
175
+ const brokenBookUrl = "https://example.com/broken-book";
176
+ const errorBookUrl = "https://example.com/error-book";
177
+
178
+ const dispatch = jest.fn();
179
+ const getState = jest.fn().mockReturnValue({
180
+ bookEditor: initialState,
181
+ });
182
+
183
+ beforeAll(() => {
184
+ fetchMock
185
+ .get(goodBookUrl, { body: SAMPLE_BOOK_ADMIN_DETAIL, status: 200 })
186
+ .get(brokenBookUrl, {
187
+ body: SAMPLE_BOOK_DATA_BROKEN_XML,
188
+ status: 200,
189
+ })
190
+ .get(errorBookUrl, { body: "Internal server error", status: 400 });
191
+ });
192
+
193
+ afterEach(() => {
194
+ fetchMock.resetHistory();
195
+ dispatch.mockClear();
196
+ });
197
+ afterAll(() => fetchMock.restore());
198
+
199
+ it("should return the book data on the happy path", async () => {
200
+ const action = getBookData({ url: goodBookUrl });
201
+
202
+ const result = await action(dispatch, getState, undefined);
203
+ const dispatchCalls = dispatch.mock.calls;
204
+
205
+ const payload = result.payload as BookData;
206
+ expect(payload.id).to.equal(
207
+ "urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1"
208
+ );
209
+ expect(payload.title).to.equal(
210
+ "Tea-Cup Reading and Fortune-Telling by Tea Leaves"
211
+ );
212
+
213
+ expect(dispatchCalls.length).to.equal(2);
214
+ expect(dispatchCalls[0][0].type).to.equal(getBookData.pending.type);
215
+ expect(dispatchCalls[0][0].payload).to.equal(undefined);
216
+ expect(dispatchCalls[0][0].meta.arg).to.deep.equal({
217
+ url: goodBookUrl,
218
+ });
219
+ expect(dispatchCalls[1][0].type).to.equal(getBookData.fulfilled.type);
220
+ expect(dispatchCalls[1][0].payload).to.deep.equal(payload);
221
+ expect(dispatchCalls[1][0].meta.arg).to.deep.equal({
222
+ url: goodBookUrl,
223
+ });
224
+ });
225
+ it("should return an error, if the data is malformed", async () => {
226
+ const action = getBookData({ url: brokenBookUrl });
227
+
228
+ const result = await action(dispatch, getState, undefined);
229
+ const dispatchCalls = dispatch.mock.calls;
230
+
231
+ const payload = result.payload as RequestError;
232
+ expect(payload.response).to.equal(FETCH_OPDS_PARSE_ERROR_MESSAGE);
233
+ expect(payload.url).to.equal(brokenBookUrl);
234
+
235
+ expect(dispatchCalls.length).to.equal(2);
236
+ expect(dispatchCalls[0][0].type).to.equal(getBookData.pending.type);
237
+ expect(dispatchCalls[0][0].payload).to.equal(undefined);
238
+ expect(dispatchCalls[0][0].meta.arg).to.deep.equal({
239
+ url: brokenBookUrl,
240
+ });
241
+ expect(dispatchCalls[1][0].type).to.equal(getBookData.rejected.type);
242
+ expect(dispatchCalls[1][0].payload).to.deep.equal(payload);
243
+ expect(dispatchCalls[1][0].meta.arg).to.deep.equal({
244
+ url: brokenBookUrl,
245
+ });
246
+ });
247
+ it("should return an error, if the HTTP request fails", async () => {
248
+ const action = getBookData({ url: errorBookUrl });
249
+
250
+ const result = await action(dispatch, getState, undefined);
251
+ const dispatchCalls = dispatch.mock.calls;
252
+
253
+ const payload = result.payload as RequestError;
254
+
255
+ expect(result.type).to.equal(getBookData.rejected.type);
256
+ expect(result.meta.arg).to.deep.equal({ url: errorBookUrl });
257
+ expect(payload.response).to.equal("Internal server error");
258
+ expect(payload.url).to.equal(errorBookUrl);
259
+
260
+ expect(dispatchCalls.length).to.equal(2);
261
+ expect(dispatchCalls[0][0].type).to.equal(getBookData.pending.type);
262
+ expect(dispatchCalls[0][0].payload).to.equal(undefined);
263
+ expect(dispatchCalls[0][0].meta.arg).to.deep.equal({
264
+ url: errorBookUrl,
265
+ });
266
+ expect(dispatchCalls[1][0].type).to.equal(getBookData.rejected.type);
267
+ expect(dispatchCalls[1][0].payload).to.deep.equal(payload);
268
+ expect(dispatchCalls[1][0].meta.arg).to.deep.equal({
269
+ url: errorBookUrl,
270
+ });
271
+ });
272
+ });
273
+
274
+ describe("submitBookData...", () => {
275
+ const goodBookUrl = "https://example.com/book";
276
+ const editBookUrl = `${goodBookUrl}/edit`;
277
+ const brokenBookUrl = "https://example.com/broken-book";
278
+ const errorBookUrl = "https://example.com/error-book";
279
+ const csrfTokenHeader = "X-CSRF-Token";
280
+ const validCsrfToken = "valid-csrf-token";
281
+
282
+ const badCsrfTokenResponseBody = {
283
+ type: "http://librarysimplified.org/terms/problem/invalid-csrf-token",
284
+ title: "Invalid CSRF token",
285
+ status: 400,
286
+ detail: "There was an error saving your changes.",
287
+ };
288
+
289
+ const dispatch = jest.fn();
290
+ const getState = jest.fn().mockReturnValue({
291
+ bookEditor: initialState,
292
+ });
293
+
294
+ beforeAll(() => {
295
+ fetchMock
296
+ .post(
297
+ {
298
+ name: "valid-csrf-token-post",
299
+ url: editBookUrl,
300
+ headers: { [csrfTokenHeader]: validCsrfToken },
301
+ },
302
+ { body: "Success!", status: 201 }
303
+ )
304
+ .post(
305
+ { name: "invalid-csrf-token-post", url: editBookUrl },
306
+ { body: badCsrfTokenResponseBody, status: 400 }
307
+ )
308
+ .get(goodBookUrl, { body: SAMPLE_BOOK_ADMIN_DETAIL, status: 200 });
309
+ });
310
+
311
+ afterEach(() => {
312
+ fetchMock.resetHistory();
313
+ dispatch.mockClear();
314
+ });
315
+
316
+ afterEach(fetchMock.resetHistory);
317
+ afterAll(() => fetchMock.restore());
318
+
319
+ it("should post the book data on the happy path", async () => {
320
+ const csrfToken = validCsrfToken;
321
+ const formData = new FormData();
322
+ formData.append("id", "urn:something:something");
323
+ formData.append("title", "title");
324
+
325
+ const action = submitBookData({
326
+ url: editBookUrl,
327
+ data: formData,
328
+ csrfToken,
329
+ });
330
+
331
+ const result = await action(dispatch, getState, undefined);
332
+ const dispatchCalls = dispatch.mock.calls;
333
+ const fetchCalls = fetchMock.calls();
334
+
335
+ expect(fetchCalls.length).to.equal(1);
336
+ expect(fetchCalls[0].identifier).to.equal("valid-csrf-token-post");
337
+ expect(
338
+ (fetchCalls[0][1].headers as Headers).get(csrfTokenHeader)
339
+ ).to.equal(validCsrfToken);
340
+
341
+ expect(fetchCalls[0][0]).to.equal(editBookUrl);
342
+ expect(fetchCalls[0][1].method).to.equal("POST");
343
+ expect(fetchCalls[0][1].body).to.equal(formData);
344
+
345
+ expect(dispatchCalls.length).to.equal(3);
346
+ expect(dispatchCalls[0][0].type).to.equal(submitBookData.pending.type);
347
+ expect(dispatchCalls[0][0].payload).to.equal(undefined);
348
+ // On a successful update, the second dispatch is to re-fetch the updated book data.
349
+ // The third dispatch is for the fulfilled action.
350
+ expect(dispatchCalls[2][0].type).to.equal(
351
+ submitBookData.fulfilled.type
352
+ );
353
+ expect(dispatchCalls[2][0].payload.body.toString()).to.equal(
354
+ "Success!"
355
+ );
356
+ });
357
+ it("should fail, if the user is unauthorized", async () => {
358
+ const csrfToken = "invalid-token";
359
+ const formData = new FormData();
360
+ formData.append("id", "urn:something:something");
361
+ formData.append("title", "title");
362
+
363
+ const action = submitBookData({
364
+ url: editBookUrl,
365
+ data: formData,
366
+ csrfToken,
367
+ });
368
+
369
+ const result = await action(dispatch, getState, undefined);
370
+ const dispatchCalls = dispatch.mock.calls;
371
+ const fetchCalls = fetchMock.calls();
372
+
373
+ expect(fetchCalls.length).to.equal(1);
374
+ expect(fetchCalls[0].identifier).to.equal("invalid-csrf-token-post");
375
+ expect(
376
+ (fetchCalls[0][1].headers as Headers).get(csrfTokenHeader)
377
+ ).not.to.equal(validCsrfToken);
378
+
379
+ expect(fetchCalls[0][0]).to.equal(editBookUrl);
380
+ expect(fetchCalls[0][1].method).to.equal("POST");
381
+ expect(fetchCalls[0][1].body).to.equal(formData);
382
+
383
+ expect(dispatchCalls.length).to.equal(2);
384
+ expect(dispatchCalls[0][0].type).to.equal(submitBookData.pending.type);
385
+ expect(dispatchCalls[0][0].payload).to.equal(undefined);
386
+ // There is no re-fetch on a failed request, ...
387
+ // ...so the second dispatch is for the rejected action.
388
+ expect(dispatchCalls[1][0].type).to.equal(submitBookData.rejected.type);
389
+ expect(dispatchCalls[1][0].payload.status).to.equal(400);
390
+ expect(dispatchCalls[1][0].payload.response).to.equal(
391
+ "There was an error saving your changes."
392
+ );
393
+ });
394
+ });
395
+ });
396
+ });
@@ -0,0 +1 @@
1
+ import "@testing-library/jest-dom";
@@ -0,0 +1,3 @@
1
+ test("adds 1 + 2 to equal 3", () => {
2
+ expect(1 + 2).toBe(3);
3
+ });
@@ -0,0 +1,38 @@
1
+ import * as React from "react";
2
+ import { render, RenderOptions, RenderResult } from "@testing-library/react";
3
+ import ContextProvider, {
4
+ ContextProviderProps,
5
+ } from "../../../src/components/ContextProvider";
6
+ import { ConfigurationSettings } from "../../../src/interfaces";
7
+
8
+ /**
9
+ * Renders a given React element, wrapped in a ContextProvider. The resulting rerender function is
10
+ * also wrapped, so that rerenders will have the identical context.
11
+ *
12
+ * @param ui The element to render
13
+ * @param config Props to pass to the ContextProvider wrapper
14
+ * @param renderOptions Options to pass through to the RTL render function
15
+ * @returns
16
+ */
17
+ export default function renderWithContext(
18
+ ui: React.ReactElement,
19
+ config: Partial<ConfigurationSettings>,
20
+ renderOptions?: Omit<RenderOptions, "queries">
21
+ ): RenderResult {
22
+ const contextProviderProps = { config } as ContextProviderProps;
23
+ const renderResult = render(
24
+ <ContextProvider {...contextProviderProps}>{ui}</ContextProvider>,
25
+ renderOptions
26
+ );
27
+
28
+ const rerenderWithContext = (ui) => {
29
+ return renderResult.rerender(
30
+ <ContextProvider {...contextProviderProps}>{ui}</ContextProvider>
31
+ );
32
+ };
33
+
34
+ return {
35
+ ...renderResult,
36
+ rerender: rerenderWithContext,
37
+ };
38
+ }
@@ -0,0 +1,92 @@
1
+ import * as React from "react";
2
+ import { Provider, ProviderProps } from "react-redux";
3
+ import ContextProvider, {
4
+ ContextProviderProps,
5
+ } from "../../../src/components/ContextProvider";
6
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
7
+ import { render, RenderOptions, RenderResult } from "@testing-library/react";
8
+ import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
9
+ import { store } from "../../../src/store";
10
+ import { ConfigurationSettings } from "../../../src/interfaces";
11
+
12
+ export type TestProviderWrapperOptions = {
13
+ reduxProviderProps?: ProviderProps;
14
+ appConfigSettings?: Partial<ConfigurationSettings>;
15
+ queryClient?: QueryClient;
16
+ };
17
+ export type TestRenderWrapperOptions = TestProviderWrapperOptions & {
18
+ renderOptions?: Omit<RenderOptions, "queries">;
19
+ };
20
+
21
+ // The `store` argument is required for the Redux Provider and should
22
+ // be the same for both the Redux Provider and the ContextProvider.
23
+ const defaultReduxStore = store;
24
+
25
+ // Some config settings from the server are required, so we provide
26
+ // default values here, so they can be easily merged with other props.
27
+ const requiredAppConfigSettings: Partial<ConfigurationSettings> = {
28
+ csrfToken: "",
29
+ featureFlags: defaultFeatureFlags,
30
+ };
31
+
32
+ /**
33
+ * Returns a component, composed with our providers, that can be used to wrap
34
+ * a React element for testing.
35
+ *
36
+ * @param {TestProviderWrapperOptions} options
37
+ * @param options.reduxProviderProps Props to pass to the Redux `Provider` wrapper
38
+ * @param {ConfigurationSettings} options.appConfigSettings
39
+ * @param {QueryClient} options.queryClient A `tanstack/react-query` QueryClient
40
+ * @returns {React.FunctionComponent} A React component that wraps children with our providers
41
+ */
42
+ export const componentWithProviders = ({
43
+ reduxProviderProps = {
44
+ store: defaultReduxStore,
45
+ },
46
+ appConfigSettings = {},
47
+ queryClient = new QueryClient(),
48
+ }: TestProviderWrapperOptions = {}): React.FunctionComponent => {
49
+ const config = { ...requiredAppConfigSettings, ...appConfigSettings };
50
+ const effectiveContextProviderProps = {
51
+ config: config as ConfigurationSettings,
52
+ ...reduxProviderProps.store, // Context and Redux Provider stores must match.
53
+ } as ContextProviderProps;
54
+ const wrapper = ({ children }) => (
55
+ <Provider {...reduxProviderProps}>
56
+ <ContextProvider {...effectiveContextProviderProps}>
57
+ <QueryClientProvider client={queryClient}>
58
+ {children}
59
+ </QueryClientProvider>
60
+ </ContextProvider>
61
+ </Provider>
62
+ );
63
+ wrapper.displayName = "TestWrapperComponent";
64
+ return wrapper;
65
+ };
66
+
67
+ /**
68
+ * Renders a React element with specified providers and provides a function for re-rendering with the same context.
69
+ *
70
+ * @param {React.ReactElement} renderChildren The element to render
71
+ * @param {TestRenderWrapperOptions} testRenderOptions Options for rendering with providers
72
+ * @returns {RenderResult} The result of rendering, including re-rendering functionality
73
+ */
74
+ export const renderWithProviders = (
75
+ renderChildren: React.ReactElement,
76
+ testRenderOptions: TestRenderWrapperOptions = {}
77
+ ): RenderResult => {
78
+ const wrapper = componentWithProviders(testRenderOptions);
79
+ const renderResult = render(
80
+ wrapper({ children: renderChildren }),
81
+ testRenderOptions.renderOptions
82
+ );
83
+
84
+ const rerenderWithProviders = (reRenderChildren: React.ReactElement) => {
85
+ return renderResult.rerender(wrapper({ children: reRenderChildren }));
86
+ };
87
+
88
+ return {
89
+ ...renderResult,
90
+ rerender: rerenderWithProviders,
91
+ };
92
+ };