@thepalaceproject/circulation-admin 1.21.0-post.1 → 1.21.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
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"react-redux": "^7.2.9",
|
|
62
62
|
"react-router": "^3.2.0",
|
|
63
63
|
"recharts": "^1.8.6",
|
|
64
|
-
"redux": "^4.
|
|
65
|
-
"redux-thunk": "^2.
|
|
64
|
+
"redux": "^4.2.1",
|
|
65
|
+
"redux-thunk": "^2.4.2",
|
|
66
66
|
"request": "^2.85.0",
|
|
67
67
|
"stream-browserify": "^3.0.0",
|
|
68
68
|
"timers-browserify": "^2.0.12",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"eslint-plugin-prettier": "^3.1.3",
|
|
101
101
|
"eslint-plugin-react": "^7.19.0",
|
|
102
102
|
"eslint-plugin-react-hooks": "^4.0.0",
|
|
103
|
-
"fetch-mock": "^
|
|
103
|
+
"fetch-mock": "^10.0.7",
|
|
104
104
|
"fetch-mock-jest": "^1.5.1",
|
|
105
105
|
"fetch-ponyfill": "^7.1.0",
|
|
106
106
|
"file-loader": "^6.2.0",
|
|
@@ -149,5 +149,5 @@
|
|
|
149
149
|
"*.{js,jsx,ts,tsx,css,md}": "prettier --write",
|
|
150
150
|
"*.{js,css,md}": "prettier --write"
|
|
151
151
|
},
|
|
152
|
-
"version": "1.21.0-post.
|
|
152
|
+
"version": "1.21.0-post.3"
|
|
153
153
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { renderWithProviders } from "../testUtils/withProviders";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import {
|
|
5
|
+
bookEditorApiEndpoints,
|
|
6
|
+
PER_LIBRARY_SUPPRESS_REL,
|
|
7
|
+
PER_LIBRARY_UNSUPPRESS_REL,
|
|
8
|
+
} from "../../../src/features/book/bookEditorSlice";
|
|
9
|
+
import { BookDetailsEditor } from "../../../src/components/BookDetailsEditor";
|
|
10
|
+
import { expect } from "chai";
|
|
11
|
+
import { store } from "../../../src/store";
|
|
12
|
+
import * as fetchMock from "fetch-mock-jest";
|
|
13
|
+
|
|
14
|
+
describe("BookDetails", () => {
|
|
15
|
+
const suppressPerLibraryLink = {
|
|
16
|
+
href: "/suppress/href",
|
|
17
|
+
rel: PER_LIBRARY_SUPPRESS_REL,
|
|
18
|
+
};
|
|
19
|
+
const unsuppressPerLibraryLink = {
|
|
20
|
+
href: "/unsuppress/href",
|
|
21
|
+
rel: PER_LIBRARY_UNSUPPRESS_REL,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let fetchBookData;
|
|
25
|
+
let fetchRoles;
|
|
26
|
+
let fetchMedia;
|
|
27
|
+
let fetchLanguages;
|
|
28
|
+
let postBookData;
|
|
29
|
+
let dispatchProps;
|
|
30
|
+
const suppressBook = jest.fn().mockImplementation((url: string) =>
|
|
31
|
+
store.dispatch(
|
|
32
|
+
bookEditorApiEndpoints.endpoints.suppressBook.initiate({
|
|
33
|
+
url,
|
|
34
|
+
csrfToken: "token",
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
const unsuppressBook = jest.fn().mockImplementation((url: string) =>
|
|
39
|
+
store.dispatch(
|
|
40
|
+
bookEditorApiEndpoints.endpoints.unsuppressBook.initiate({
|
|
41
|
+
url,
|
|
42
|
+
csrfToken: "token",
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
beforeAll(() => {
|
|
48
|
+
fetchMock
|
|
49
|
+
.post("/suppress/href", {
|
|
50
|
+
status: 200,
|
|
51
|
+
body: { message: "Successfully suppressed book availability." },
|
|
52
|
+
})
|
|
53
|
+
.delete("/unsuppress/href", {
|
|
54
|
+
status: 200,
|
|
55
|
+
body: { message: "Successfully unsuppressed book availability." },
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
fetchBookData = jest.fn();
|
|
60
|
+
fetchRoles = jest.fn();
|
|
61
|
+
fetchMedia = jest.fn();
|
|
62
|
+
fetchLanguages = jest.fn();
|
|
63
|
+
postBookData = jest.fn();
|
|
64
|
+
dispatchProps = {
|
|
65
|
+
fetchBookData,
|
|
66
|
+
fetchRoles,
|
|
67
|
+
fetchMedia,
|
|
68
|
+
fetchLanguages,
|
|
69
|
+
postBookData,
|
|
70
|
+
suppressBook,
|
|
71
|
+
unsuppressBook,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
jest.clearAllMocks();
|
|
76
|
+
fetchMock.resetHistory();
|
|
77
|
+
});
|
|
78
|
+
afterAll(() => {
|
|
79
|
+
jest.restoreAllMocks();
|
|
80
|
+
fetchMock.restore();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("uses modal for suppress book confirmation", async () => {
|
|
84
|
+
// Configure standard constructors so that RTK Query works in tests with FetchMockJest
|
|
85
|
+
Object.assign(fetchMock.config, {
|
|
86
|
+
fetch,
|
|
87
|
+
Headers,
|
|
88
|
+
Request,
|
|
89
|
+
Response,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
|
|
94
|
+
const { getByRole, getByText, queryByRole } = renderWithProviders(
|
|
95
|
+
<BookDetailsEditor
|
|
96
|
+
bookData={{ id: "id", title: "title", suppressPerLibraryLink }}
|
|
97
|
+
bookUrl="url"
|
|
98
|
+
csrfToken="token"
|
|
99
|
+
{...dispatchProps}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// The `Hide` button should be present.
|
|
104
|
+
const hideButton = getByRole("button", { name: "Hide" });
|
|
105
|
+
|
|
106
|
+
// Clicking `Hide` should show the book suppression modal.
|
|
107
|
+
await user.click(hideButton);
|
|
108
|
+
getByRole("heading", { level: 4, name: "Suppressing Availability" });
|
|
109
|
+
getByText(/to hide this title from your library's catalog/);
|
|
110
|
+
let confirmButton = getByRole("button", { name: "Suppress Availability" });
|
|
111
|
+
let cancelButton = getByRole("button", { name: "Cancel" });
|
|
112
|
+
|
|
113
|
+
// Clicking `Cancel` should close the modal.
|
|
114
|
+
await user.click(cancelButton);
|
|
115
|
+
confirmButton = queryByRole("button", { name: "Suppress Availability" });
|
|
116
|
+
cancelButton = queryByRole("button", { name: "Cancel" });
|
|
117
|
+
expect(confirmButton).to.be.null;
|
|
118
|
+
expect(cancelButton).to.be.null;
|
|
119
|
+
|
|
120
|
+
// Clicking `Hide` again should show the modal again.
|
|
121
|
+
await user.click(hideButton);
|
|
122
|
+
confirmButton = getByRole("button", { name: "Suppress Availability" });
|
|
123
|
+
|
|
124
|
+
// Clicking the confirmation button should invoke the API and show a confirmation.
|
|
125
|
+
await user.click(confirmButton);
|
|
126
|
+
getByRole("heading", { level: 4, name: "Result" });
|
|
127
|
+
getByText(/Successfully suppressed book availability/);
|
|
128
|
+
getByRole("button", { name: "Dismiss" });
|
|
129
|
+
|
|
130
|
+
// Check that the API was invoked.
|
|
131
|
+
expect(suppressBook.mock.calls.length).to.equal(1);
|
|
132
|
+
expect(suppressBook.mock.calls[0][0]).to.equal("/suppress/href");
|
|
133
|
+
const fetchCalls = fetchMock.calls();
|
|
134
|
+
expect(fetchCalls.length).to.equal(1);
|
|
135
|
+
const fetchCall = fetchCalls[0];
|
|
136
|
+
const fetchOptions = fetchCalls[0][1];
|
|
137
|
+
expect(fetchCall[0]).to.equal("/suppress/href");
|
|
138
|
+
expect(fetchOptions["headers"]["X-CSRF-Token"]).to.contain("token");
|
|
139
|
+
expect(fetchOptions["method"]).to.equal("POST");
|
|
140
|
+
});
|
|
141
|
+
it("uses modal for unsuppress book confirmation", async () => {
|
|
142
|
+
// Configure standard constructors so that RTK Query works in tests with FetchMockJest
|
|
143
|
+
Object.assign(fetchMock.config, {
|
|
144
|
+
fetch,
|
|
145
|
+
Headers,
|
|
146
|
+
Request,
|
|
147
|
+
Response,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const user = userEvent.setup();
|
|
151
|
+
|
|
152
|
+
const { getByRole, getByText, queryByRole } = renderWithProviders(
|
|
153
|
+
<BookDetailsEditor
|
|
154
|
+
bookData={{ id: "id", title: "title", unsuppressPerLibraryLink }}
|
|
155
|
+
bookUrl="url"
|
|
156
|
+
csrfToken="token"
|
|
157
|
+
{...dispatchProps}
|
|
158
|
+
/>
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// The `Restore` button should be present.
|
|
162
|
+
const restoreButton = getByRole("button", { name: "Restore" });
|
|
163
|
+
|
|
164
|
+
// Clicking `Restore` should show the book un/suppression modal.
|
|
165
|
+
await user.click(restoreButton);
|
|
166
|
+
getByRole("heading", { level: 4, name: "Restoring Availability" });
|
|
167
|
+
getByText(/to make this title visible in your library's catalog/);
|
|
168
|
+
let confirmButton = getByRole("button", { name: "Restore Availability" });
|
|
169
|
+
let cancelButton = getByRole("button", { name: "Cancel" });
|
|
170
|
+
|
|
171
|
+
// Clicking `Cancel` should close the modal.
|
|
172
|
+
await user.click(cancelButton);
|
|
173
|
+
confirmButton = queryByRole("button", { name: "Restore Availability" });
|
|
174
|
+
cancelButton = queryByRole("button", { name: "Cancel" });
|
|
175
|
+
expect(confirmButton).to.be.null;
|
|
176
|
+
expect(cancelButton).to.be.null;
|
|
177
|
+
|
|
178
|
+
// Clicking `Restore` again should show the modal again.
|
|
179
|
+
await user.click(restoreButton);
|
|
180
|
+
confirmButton = getByRole("button", { name: "Restore Availability" });
|
|
181
|
+
|
|
182
|
+
// Clicking the confirmation button should invoke the API and show a confirmation.
|
|
183
|
+
await user.click(confirmButton);
|
|
184
|
+
getByRole("heading", { level: 4, name: "Result" });
|
|
185
|
+
getByText(/Successfully unsuppressed book availability/);
|
|
186
|
+
getByRole("button", { name: "Dismiss" });
|
|
187
|
+
|
|
188
|
+
// Check that the API was invoked.
|
|
189
|
+
expect(unsuppressBook.mock.calls.length).to.equal(1);
|
|
190
|
+
expect(unsuppressBook.mock.calls[0][0]).to.equal("/unsuppress/href");
|
|
191
|
+
const fetchCalls = fetchMock.calls();
|
|
192
|
+
expect(fetchCalls.length).to.equal(1);
|
|
193
|
+
const fetchCall = fetchCalls[0];
|
|
194
|
+
const fetchOptions = fetchCalls[0][1];
|
|
195
|
+
expect(fetchCall[0]).to.equal("/unsuppress/href");
|
|
196
|
+
expect(fetchOptions["headers"]["X-CSRF-Token"]).to.contain("token");
|
|
197
|
+
expect(fetchOptions["method"]).to.equal("DELETE");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -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&width=10000&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="http://www.w3.org/ns/anno.jsonld""/>
|
|
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
|
+
});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
+
import { Provider, ProviderProps } from "react-redux";
|
|
2
3
|
import ContextProvider, {
|
|
3
4
|
ContextProviderProps,
|
|
4
5
|
} from "../../../src/components/ContextProvider";
|
|
5
6
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
7
|
import { render, RenderOptions, RenderResult } from "@testing-library/react";
|
|
7
8
|
import { defaultFeatureFlags } from "../../../src/utils/featureFlags";
|
|
9
|
+
import { store } from "../../../src/store";
|
|
8
10
|
|
|
9
11
|
export type TestProviderWrapperOptions = {
|
|
12
|
+
reduxProviderProps?: ProviderProps;
|
|
10
13
|
contextProviderProps?: Partial<ContextProviderProps>;
|
|
11
14
|
queryClient?: QueryClient;
|
|
12
15
|
};
|
|
@@ -14,6 +17,10 @@ export type TestRenderWrapperOptions = TestProviderWrapperOptions & {
|
|
|
14
17
|
renderOptions?: Omit<RenderOptions, "queries">;
|
|
15
18
|
};
|
|
16
19
|
|
|
20
|
+
// The `store` argument is required for the Redux Provider and should
|
|
21
|
+
// be the same for both the Redux Provider and the ContextProvider.
|
|
22
|
+
const defaultReduxStore = store;
|
|
23
|
+
|
|
17
24
|
// The `csrfToken` context provider prop is required, so we provide
|
|
18
25
|
// a default value here, so it can be easily merged with other props.
|
|
19
26
|
const defaultContextProviderProps: ContextProviderProps = {
|
|
@@ -26,11 +33,15 @@ const defaultContextProviderProps: ContextProviderProps = {
|
|
|
26
33
|
* a React element for testing.
|
|
27
34
|
*
|
|
28
35
|
* @param {TestProviderWrapperOptions} options
|
|
36
|
+
* @param options.reduxProviderProps Props to pass to the Redux `Provider` wrapper
|
|
29
37
|
* @param {ContextProviderProps} options.contextProviderProps Props to pass to the ContextProvider wrapper
|
|
30
38
|
* @param {QueryClient} options.queryClient A `tanstack/react-query` QueryClient
|
|
31
39
|
* @returns {React.FunctionComponent} A React component that wraps children with our providers
|
|
32
40
|
*/
|
|
33
41
|
export const componentWithProviders = ({
|
|
42
|
+
reduxProviderProps = {
|
|
43
|
+
store: defaultReduxStore,
|
|
44
|
+
},
|
|
34
45
|
contextProviderProps = {
|
|
35
46
|
csrfToken: "",
|
|
36
47
|
featureFlags: defaultFeatureFlags,
|
|
@@ -40,11 +51,16 @@ export const componentWithProviders = ({
|
|
|
40
51
|
const effectiveContextProviderProps = {
|
|
41
52
|
...defaultContextProviderProps,
|
|
42
53
|
...contextProviderProps,
|
|
54
|
+
...reduxProviderProps.store, // Context and Redux Provider stores must match.
|
|
43
55
|
};
|
|
44
56
|
const wrapper = ({ children }) => (
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
57
|
+
<Provider {...reduxProviderProps}>
|
|
58
|
+
<ContextProvider {...effectiveContextProviderProps}>
|
|
59
|
+
<QueryClientProvider client={queryClient}>
|
|
60
|
+
{children}
|
|
61
|
+
</QueryClientProvider>
|
|
62
|
+
</ContextProvider>
|
|
63
|
+
</Provider>
|
|
48
64
|
);
|
|
49
65
|
wrapper.displayName = "TestWrapperComponent";
|
|
50
66
|
return wrapper;
|