@internetarchive/ia-book-actions 1.4.5 → 1.4.6-alpha2
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 +1 -1
- package/src/core/services/actions-handler/actions-handler-service.js +111 -10
- package/src/core/services/actions-handler/actions-handler.js +16 -5
- package/src/core/services/loan-token-poller.js +28 -10
- package/src/core/services/sentry-report.js +40 -0
- package/src/ia-book-actions.js +44 -8
- package/test/core/services/actions-handler-service.test.js +165 -0
- package/test/core/services/loan-token-poller.test.js +71 -0
- package/test/core/services/sentry-report.test.js +72 -0
- package/test/ia-book-actions.test.js +132 -0
package/package.json
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
2
|
import { sentryLogs } from '../../config/sentry-events.js';
|
|
3
|
+
import {
|
|
4
|
+
sentryCaptureMessage,
|
|
5
|
+
sentryCaptureException,
|
|
6
|
+
} from '../sentry-report.js';
|
|
3
7
|
import log from '../log.js';
|
|
4
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Patron-facing messages for failures that never reached the lending
|
|
11
|
+
* service's own JSON error handling.
|
|
12
|
+
*/
|
|
13
|
+
export const unexpectedResponseError = status =>
|
|
14
|
+
`Unexpected response from the lending service (HTTP ${status}). Please try again.`;
|
|
15
|
+
export const networkError =
|
|
16
|
+
'Could not reach the lending service. Please check your connection and try again.';
|
|
17
|
+
export const unexpectedError =
|
|
18
|
+
'Something went wrong handling the lending service response. Please try again.';
|
|
19
|
+
|
|
5
20
|
/**
|
|
6
21
|
* Helper to call loan service
|
|
7
22
|
* @param {Object} options
|
|
@@ -52,12 +67,19 @@ export default async function ActionsHandlerService(options) {
|
|
|
52
67
|
formData.append('action', option.action);
|
|
53
68
|
formData.append('identifier', option.identifier);
|
|
54
69
|
|
|
70
|
+
// Set once a response arrives, so the catch below can tell a failed fetch
|
|
71
|
+
// (a real network problem) from anything thrown afterwards — including by
|
|
72
|
+
// the caller's own success/error callback, which run inside this try.
|
|
73
|
+
let reachedResponse = false;
|
|
74
|
+
|
|
55
75
|
try {
|
|
56
76
|
await fetch(baseHost, {
|
|
57
77
|
method: 'POST',
|
|
58
78
|
body: formData,
|
|
59
79
|
})
|
|
60
80
|
.then(async response => {
|
|
81
|
+
reachedResponse = true;
|
|
82
|
+
|
|
61
83
|
// intentional error on localhost
|
|
62
84
|
if (shouldReturnError) {
|
|
63
85
|
return {
|
|
@@ -85,9 +107,7 @@ export default async function ActionsHandlerService(options) {
|
|
|
85
107
|
};
|
|
86
108
|
}
|
|
87
109
|
|
|
88
|
-
|
|
89
|
-
// You parse the data into a useable format using `.json()`
|
|
90
|
-
return response.json();
|
|
110
|
+
return parseServiceResponse(response, option);
|
|
91
111
|
})
|
|
92
112
|
.then(data => {
|
|
93
113
|
// `data` is the parsed version of the JSON returned from the above endpoint.
|
|
@@ -100,15 +120,23 @@ export default async function ActionsHandlerService(options) {
|
|
|
100
120
|
}
|
|
101
121
|
});
|
|
102
122
|
} catch (error) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
123
|
+
sentryCaptureException(error, {
|
|
124
|
+
identifier: option.identifier,
|
|
125
|
+
action: option.action,
|
|
126
|
+
extra: {
|
|
127
|
+
message: `${sentryLogs.actionsHandlerService} - ${option.action} ${
|
|
128
|
+
reachedResponse ? 'threw' : 'fetch failed'
|
|
129
|
+
}`,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
106
132
|
|
|
107
133
|
/**
|
|
108
134
|
* Report it, don't just swallow it. A rejected fetch (offline, dropped
|
|
109
|
-
* connection)
|
|
110
|
-
*
|
|
111
|
-
*
|
|
135
|
+
* connection) lands here — a non-JSON body no longer does, see
|
|
136
|
+
* parseServiceResponse() — as does anything thrown after the response
|
|
137
|
+
* arrived; calling neither `success` nor `error` leaves every caller
|
|
138
|
+
* waiting on a callback that never comes. Only the former is a network
|
|
139
|
+
* problem, so only that one says so.
|
|
112
140
|
*
|
|
113
141
|
* For renew_loan that's not merely a missing modal: IABookActions
|
|
114
142
|
* clears its loanRenewInProgress guard from these callbacks, so a
|
|
@@ -117,6 +145,79 @@ export default async function ActionsHandlerService(options) {
|
|
|
117
145
|
* connection is exactly when this fires.
|
|
118
146
|
*/
|
|
119
147
|
log(`[IABookActions] ✗ ${option.action} threw`, error);
|
|
120
|
-
|
|
148
|
+
// networkFailure marks "the service never answered" (like
|
|
149
|
+
// nonJsonResponse in parseServiceResponse) — see
|
|
150
|
+
// IABookActions.handleLendingActionError.
|
|
151
|
+
option?.error?.({
|
|
152
|
+
error: reachedResponse ? unexpectedError : networkError,
|
|
153
|
+
detail: String(error),
|
|
154
|
+
...(reachedResponse ? {} : { networkFailure: true }),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Parse a lending service response without ever surfacing a raw
|
|
161
|
+
* `SyntaxError` to the patron. Anything in front of PHP (the WAF, or nginx
|
|
162
|
+
* swapping in /about/400.html or a 5xx page) can answer with HTML instead
|
|
163
|
+
* of the service's JSON; that becomes a readable error carrying the HTTP
|
|
164
|
+
* status, and is reported to Sentry with the identifier/action.
|
|
165
|
+
*
|
|
166
|
+
* @param {Response} response
|
|
167
|
+
* @param {Object} option - the ActionsHandlerService options
|
|
168
|
+
* @returns {Promise<Object>}
|
|
169
|
+
*/
|
|
170
|
+
export async function parseServiceResponse(response, option) {
|
|
171
|
+
const { status } = response;
|
|
172
|
+
const contentType = response.headers?.get('content-type') || '';
|
|
173
|
+
|
|
174
|
+
// The service always answers with JSON (application/json, or
|
|
175
|
+
// application/problem+json for OPDS) — anything else never came from it.
|
|
176
|
+
const body = await response.text();
|
|
177
|
+
let data;
|
|
178
|
+
if (contentType.includes('json')) {
|
|
179
|
+
try {
|
|
180
|
+
data = JSON.parse(body);
|
|
181
|
+
} catch (parseError) {
|
|
182
|
+
data = undefined;
|
|
183
|
+
}
|
|
121
184
|
}
|
|
185
|
+
|
|
186
|
+
if (data === undefined) {
|
|
187
|
+
sentryCaptureMessage(
|
|
188
|
+
`${sentryLogs.actionsHandlerService} - ${option.action}: non-JSON response (HTTP ${status})`,
|
|
189
|
+
{
|
|
190
|
+
identifier: option.identifier,
|
|
191
|
+
action: option.action,
|
|
192
|
+
extra: {
|
|
193
|
+
status,
|
|
194
|
+
contentType,
|
|
195
|
+
bodySnippet: body.slice(0, 300),
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
// nonJsonResponse lets callers tell "something in front of PHP answered"
|
|
200
|
+
// apart from the service's own error — see
|
|
201
|
+
// IABookActions.handleLendingActionError.
|
|
202
|
+
return {
|
|
203
|
+
success: false,
|
|
204
|
+
error: unexpectedResponseError(status),
|
|
205
|
+
status,
|
|
206
|
+
nonJsonResponse: true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Carry the HTTP status on the service's own JSON errors too, so it
|
|
211
|
+
// reaches Sentry. A JSON error status without the service's usual `error`
|
|
212
|
+
// field would otherwise be treated as a success.
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
return {
|
|
215
|
+
...(data && typeof data === 'object' ? data : {}),
|
|
216
|
+
success: false,
|
|
217
|
+
error: data?.error || unexpectedResponseError(status),
|
|
218
|
+
status,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return data;
|
|
122
223
|
}
|
|
@@ -2,6 +2,10 @@ import { LitElement } from 'lit';
|
|
|
2
2
|
|
|
3
3
|
import { URLHelper } from '../../config/url-helper.js';
|
|
4
4
|
import { sentryLogs } from '../../config/sentry-events.js';
|
|
5
|
+
import {
|
|
6
|
+
sentryCaptureMessage,
|
|
7
|
+
sentryCaptureException,
|
|
8
|
+
} from '../sentry-report.js';
|
|
5
9
|
import log from '../log.js';
|
|
6
10
|
|
|
7
11
|
import ActionsHandlerService from './actions-handler-service.js';
|
|
@@ -228,8 +232,13 @@ export default class ActionsHandler extends LitElement {
|
|
|
228
232
|
data,
|
|
229
233
|
id: this.identifier,
|
|
230
234
|
});
|
|
231
|
-
|
|
232
|
-
`${sentryLogs.bookRenewFailed} -
|
|
235
|
+
sentryCaptureMessage(
|
|
236
|
+
`${sentryLogs.bookRenewFailed} - no loan active`,
|
|
237
|
+
{
|
|
238
|
+
identifier: this.identifier,
|
|
239
|
+
action,
|
|
240
|
+
extra: { response: JSON.stringify(data) },
|
|
241
|
+
}
|
|
233
242
|
);
|
|
234
243
|
this.dispatchActionError(action, {
|
|
235
244
|
data,
|
|
@@ -238,9 +247,11 @@ export default class ActionsHandler extends LitElement {
|
|
|
238
247
|
});
|
|
239
248
|
} catch (error) {
|
|
240
249
|
log('RENEW_LOAN THREW --- ', error);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
250
|
+
sentryCaptureException(error, {
|
|
251
|
+
identifier: this.identifier,
|
|
252
|
+
action,
|
|
253
|
+
extra: { message: `${sentryLogs.bookRenewFailed} - threw` },
|
|
254
|
+
});
|
|
244
255
|
this.dispatchActionError(action, {
|
|
245
256
|
data,
|
|
246
257
|
error: true,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import ActionsHandlerService from './actions-handler/actions-handler-service.js';
|
|
3
3
|
import LoanAnanlytics from './loan-analytics.js';
|
|
4
4
|
import { sentryLogs } from '../config/sentry-events.js';
|
|
5
|
+
import { sentryCaptureMessage } from './sentry-report.js';
|
|
5
6
|
import log from './log.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -62,9 +63,12 @@ export class LoanTokenPoller {
|
|
|
62
63
|
async bookAccessed() {
|
|
63
64
|
if (this.borrowType) {
|
|
64
65
|
if (this.skipInitialCall) {
|
|
65
|
-
log(
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
log(
|
|
67
|
+
'[LoanTokenPoller] skipping initial create_token — already minted by the renewal response',
|
|
68
|
+
{
|
|
69
|
+
identifier: this.identifier,
|
|
70
|
+
}
|
|
71
|
+
);
|
|
68
72
|
} else {
|
|
69
73
|
// Do an initial token, then set an interval
|
|
70
74
|
this.handleLoanTokenPoller(true);
|
|
@@ -83,9 +87,10 @@ export class LoanTokenPoller {
|
|
|
83
87
|
}, this.pollerDelay * 1000);
|
|
84
88
|
}
|
|
85
89
|
} else {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
sentryCaptureMessage(`${sentryLogs.bookAccessed} - not borrowed`, {
|
|
91
|
+
identifier: this.identifier,
|
|
92
|
+
action: 'create_token',
|
|
93
|
+
});
|
|
89
94
|
|
|
90
95
|
// if book is not browsed, just clear token polling interval
|
|
91
96
|
this.disconnectedCallback();
|
|
@@ -117,8 +122,9 @@ export class LoanTokenPoller {
|
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
/**
|
|
120
|
-
* Reports
|
|
121
|
-
*
|
|
125
|
+
* Reports every create_token failure via errorCallback — the poller never
|
|
126
|
+
* retries, and the consumer decides whether a failure is terminal (see
|
|
127
|
+
* IABookActions.handleLendingActionError). Split out from
|
|
122
128
|
* handleLoanTokenPoller so it's directly testable without needing to
|
|
123
129
|
* mock the network call.
|
|
124
130
|
*
|
|
@@ -140,8 +146,20 @@ export class LoanTokenPoller {
|
|
|
140
146
|
this.errorCallback({ detail: { action, data, isInitial } });
|
|
141
147
|
|
|
142
148
|
// send error to Sentry
|
|
143
|
-
|
|
144
|
-
`${sentryLogs.handleLoanTokenPoller} -
|
|
149
|
+
sentryCaptureMessage(
|
|
150
|
+
`${sentryLogs.handleLoanTokenPoller} - create_token failed`,
|
|
151
|
+
{
|
|
152
|
+
identifier: this.identifier,
|
|
153
|
+
action,
|
|
154
|
+
extra: {
|
|
155
|
+
borrowType: this.borrowType,
|
|
156
|
+
isInitial,
|
|
157
|
+
error: data?.error,
|
|
158
|
+
status: data?.status,
|
|
159
|
+
nonJsonResponse: data?.nonJsonResponse === true,
|
|
160
|
+
response: JSON.stringify(data),
|
|
161
|
+
},
|
|
162
|
+
}
|
|
145
163
|
);
|
|
146
164
|
|
|
147
165
|
// send LendingServiceError to GA
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* global: window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds Sentry's captureContext with the book identifier and lending action
|
|
5
|
+
* attached as tags, so events can be filtered per book or per action (e.g.
|
|
6
|
+
* `action:create_token identifier:foo`) without the identifier going into
|
|
7
|
+
* the message text — which would split one issue into one per book.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} [context]
|
|
10
|
+
* @param {string} [context.identifier]
|
|
11
|
+
* @param {string} [context.action] - e.g. create_token, renew_loan
|
|
12
|
+
* @param {Object} [context.extra] - any other details worth keeping
|
|
13
|
+
* @returns {Object}
|
|
14
|
+
*/
|
|
15
|
+
export function buildSentryContext({ identifier, action, extra = {} } = {}) {
|
|
16
|
+
const tags = {};
|
|
17
|
+
if (identifier) tags.identifier = identifier;
|
|
18
|
+
if (action) tags.action = action;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
tags,
|
|
22
|
+
extra: { identifier, action, ...extra },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} message
|
|
28
|
+
* @param {Object} [context] - see buildSentryContext
|
|
29
|
+
*/
|
|
30
|
+
export function sentryCaptureMessage(message, context) {
|
|
31
|
+
window?.Sentry?.captureMessage?.(message, buildSentryContext(context));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Error|string} error
|
|
36
|
+
* @param {Object} [context] - see buildSentryContext
|
|
37
|
+
*/
|
|
38
|
+
export function sentryCaptureException(error, context) {
|
|
39
|
+
window?.Sentry?.captureException?.(error, buildSentryContext(context));
|
|
40
|
+
}
|
package/src/ia-book-actions.js
CHANGED
|
@@ -16,6 +16,7 @@ import './core/config/ia-lending-intervals.js';
|
|
|
16
16
|
import { GetLendingActions } from './core/services/get-lending-actions.js';
|
|
17
17
|
import { mobileContainerWidth } from './core/config/constants.js';
|
|
18
18
|
import { sentryLogs } from './core/config/sentry-events.js';
|
|
19
|
+
import { sentryCaptureMessage } from './core/services/sentry-report.js';
|
|
19
20
|
import { LoanTokenPoller } from './core/services/loan-token-poller.js';
|
|
20
21
|
import { LoanRenewHelper } from './core/services/loan-renew-helper.js';
|
|
21
22
|
import log from './core/services/log.js';
|
|
@@ -163,11 +164,13 @@ export default class IABookActions extends LitElement {
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
/**
|
|
166
|
-
* send log messages to sentry
|
|
167
|
+
* send log messages to sentry, tagged with this book's identifier
|
|
167
168
|
* @param {string} msg
|
|
169
|
+
* @param {string} [action] - the lending action involved, if any
|
|
170
|
+
* @param {Object} [extra]
|
|
168
171
|
*/
|
|
169
|
-
sentryCaptureMsg(msg) {
|
|
170
|
-
|
|
172
|
+
sentryCaptureMsg(msg, action, extra) {
|
|
173
|
+
sentryCaptureMessage(msg, { identifier: this.identifier, action, extra });
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
firstUpdated() {
|
|
@@ -435,7 +438,9 @@ export default class IABookActions extends LitElement {
|
|
|
435
438
|
} catch (error) {
|
|
436
439
|
// Surface localCache failures instead of an unhandled rejection.
|
|
437
440
|
log('[IABookActions] visibilitychange handler failed', error);
|
|
438
|
-
this.sentryCaptureMsg(
|
|
441
|
+
this.sentryCaptureMsg('visibilitychange handler failed', undefined, {
|
|
442
|
+
error: String(error),
|
|
443
|
+
});
|
|
439
444
|
}
|
|
440
445
|
});
|
|
441
446
|
}
|
|
@@ -796,7 +801,7 @@ export default class IABookActions extends LitElement {
|
|
|
796
801
|
this.modal?.closeModal();
|
|
797
802
|
this.modal.removeAttribute('id');
|
|
798
803
|
this.modal.customModalContent = nothing;
|
|
799
|
-
this.sentryCaptureMsg(sentryLogs.bookHasRenewed);
|
|
804
|
+
this.sentryCaptureMsg(sentryLogs.bookHasRenewed, 'renew_loan');
|
|
800
805
|
this.warningModalDismissed = false;
|
|
801
806
|
}
|
|
802
807
|
|
|
@@ -990,10 +995,12 @@ export default class IABookActions extends LitElement {
|
|
|
990
995
|
this.disableActionGroup = false;
|
|
991
996
|
|
|
992
997
|
const action = event?.detail?.action;
|
|
998
|
+
const eventData = event?.detail?.data;
|
|
999
|
+
|
|
993
1000
|
// handleLoanRenewNow's failure path sets `.error` to a boolean flag and
|
|
994
1001
|
// puts the real text in `.message` — only take `.error` when it's
|
|
995
1002
|
// actually a string, or the modal renders the literal word "true".
|
|
996
|
-
const rawErrorMsg =
|
|
1003
|
+
const rawErrorMsg = eventData?.error;
|
|
997
1004
|
const errorMsg =
|
|
998
1005
|
typeof rawErrorMsg === 'string' ? rawErrorMsg : undefined;
|
|
999
1006
|
const isInitial = event?.detail?.isInitial === true;
|
|
@@ -1007,9 +1014,38 @@ export default class IABookActions extends LitElement {
|
|
|
1007
1014
|
recoveringFromLoanExpiry: this.recoveringFromLoanExpiry,
|
|
1008
1015
|
});
|
|
1009
1016
|
|
|
1017
|
+
if (
|
|
1018
|
+
action === 'create_token' &&
|
|
1019
|
+
!isInitial &&
|
|
1020
|
+
(eventData?.nonJsonResponse === true ||
|
|
1021
|
+
eventData?.networkFailure === true)
|
|
1022
|
+
) {
|
|
1023
|
+
// The service never answered this routine refresh: something in
|
|
1024
|
+
// front of it did (the WAF's /about/400.html, an outage page), or the
|
|
1025
|
+
// request never got through. That says nothing about the loan, so
|
|
1026
|
+
// no modal and no session teardown — it's already reported to Sentry
|
|
1027
|
+
// (ActionsHandlerService/LoanTokenPoller), the current access token
|
|
1028
|
+
// keeps the book readable, and the poll interval is left running, so
|
|
1029
|
+
// the next tick tries again. The service's own answer (e.g. "You do
|
|
1030
|
+
// not currently have this book borrowed.") still falls through below.
|
|
1031
|
+
//
|
|
1032
|
+
// Not for the initial call: its success is what runs
|
|
1033
|
+
// lendingBarPostInit() and opens the book (see startLoanTokenPoller),
|
|
1034
|
+
// so suppressing it would leave a reader that never loads, silently.
|
|
1035
|
+
log(
|
|
1036
|
+
'[IABookActions] create_token routine refresh got no answer from the service — no modal',
|
|
1037
|
+
{
|
|
1038
|
+
identifier: this.identifier,
|
|
1039
|
+
status: eventData?.status,
|
|
1040
|
+
errorMsg,
|
|
1041
|
+
}
|
|
1042
|
+
);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1010
1046
|
if (action === 'create_token') {
|
|
1011
|
-
// Any create_token failure — initial or a routine
|
|
1012
|
-
// means access can no longer be confirmed (e.g. a stale loan-record
|
|
1047
|
+
// Any other create_token failure — initial, or a routine refresh the
|
|
1048
|
+
// service itself answered — means access can no longer be confirmed (e.g. a stale loan-record
|
|
1013
1049
|
// cache on some other node can make this request fail even though
|
|
1014
1050
|
// the loan is fine, or the loan may genuinely have just been
|
|
1015
1051
|
// returned via BookReaderImages.php's revokeAccess()). Don't retry
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { expect } from '@open-wc/testing';
|
|
2
|
+
import Sinon from 'sinon';
|
|
3
|
+
|
|
4
|
+
import ActionsHandlerService, {
|
|
5
|
+
parseServiceResponse,
|
|
6
|
+
networkError,
|
|
7
|
+
unexpectedError,
|
|
8
|
+
} from '../../../src/core/services/actions-handler/actions-handler-service.js';
|
|
9
|
+
|
|
10
|
+
const option = { identifier: 'foo', action: 'create_token' };
|
|
11
|
+
const jsonHeaders = { 'Content-Type': 'application/json' };
|
|
12
|
+
|
|
13
|
+
describe('ActionsHandlerService#parseServiceResponse', () => {
|
|
14
|
+
let originalSentry;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
originalSentry = window.Sentry;
|
|
18
|
+
window.Sentry = { captureMessage: Sinon.spy() };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
window.Sentry = originalSentry;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('turns an HTML error page into a readable error instead of a SyntaxError', async () => {
|
|
26
|
+
// e.g. the WAF's /about/400.html in place of the service's JSON
|
|
27
|
+
const response = new Response('<!DOCTYPE html><html>Bad Request</html>', {
|
|
28
|
+
status: 400,
|
|
29
|
+
headers: { 'Content-Type': 'text/html' },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const data = await parseServiceResponse(response, option);
|
|
33
|
+
|
|
34
|
+
expect(data.success).to.be.false;
|
|
35
|
+
expect(data.status).to.equal(400);
|
|
36
|
+
expect(data.nonJsonResponse).to.be.true;
|
|
37
|
+
expect(data.error).to.contain('HTTP 400');
|
|
38
|
+
expect(data.error).to.not.contain('SyntaxError');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports the non-JSON response to Sentry with the identifier, action and status', async () => {
|
|
42
|
+
const response = new Response('<!DOCTYPE html>', {
|
|
43
|
+
status: 400,
|
|
44
|
+
headers: { 'Content-Type': 'text/html' },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await parseServiceResponse(response, option);
|
|
48
|
+
|
|
49
|
+
expect(window.Sentry.captureMessage.calledOnce).to.be.true;
|
|
50
|
+
const [message, context] = window.Sentry.captureMessage.firstCall.args;
|
|
51
|
+
expect(message).to.contain('create_token');
|
|
52
|
+
expect(message).to.contain('HTTP 400');
|
|
53
|
+
expect(context.tags).to.deep.equal({
|
|
54
|
+
identifier: 'foo',
|
|
55
|
+
action: 'create_token',
|
|
56
|
+
});
|
|
57
|
+
expect(context.extra.status).to.equal(400);
|
|
58
|
+
expect(context.extra.contentType).to.equal('text/html');
|
|
59
|
+
expect(context.extra.bodySnippet).to.contain('<!DOCTYPE');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('treats a JSON content type with an unparseable body as non-JSON', async () => {
|
|
63
|
+
const response = new Response('<!DOCTYPE html>', {
|
|
64
|
+
status: 502,
|
|
65
|
+
headers: jsonHeaders,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const data = await parseServiceResponse(response, option);
|
|
69
|
+
|
|
70
|
+
expect(data.error).to.contain('HTTP 502');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps the service's own JSON error, adding the HTTP status", async () => {
|
|
74
|
+
const body = {
|
|
75
|
+
success: false,
|
|
76
|
+
error: 'You do not currently have this book borrowed.',
|
|
77
|
+
};
|
|
78
|
+
const response = new Response(JSON.stringify(body), {
|
|
79
|
+
status: 400,
|
|
80
|
+
headers: jsonHeaders,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const data = await parseServiceResponse(response, option);
|
|
84
|
+
|
|
85
|
+
expect(data).to.deep.equal({ ...body, status: 400 });
|
|
86
|
+
expect(data.nonJsonResponse).to.be.undefined;
|
|
87
|
+
expect(window.Sentry.captureMessage.called).to.be.false;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('passes a JSON success through untouched', async () => {
|
|
91
|
+
const body = { success: true, token: 'abc' };
|
|
92
|
+
const response = new Response(JSON.stringify(body), {
|
|
93
|
+
status: 200,
|
|
94
|
+
headers: jsonHeaders,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(await parseServiceResponse(response, option)).to.deep.equal(body);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('never treats a JSON error status without an error field as a success', async () => {
|
|
101
|
+
const response = new Response('{}', { status: 503, headers: jsonHeaders });
|
|
102
|
+
|
|
103
|
+
const data = await parseServiceResponse(response, option);
|
|
104
|
+
|
|
105
|
+
expect(data.success).to.be.false;
|
|
106
|
+
expect(data.error).to.contain('HTTP 503');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('ActionsHandlerService - thrown errors', () => {
|
|
111
|
+
let originalSentry;
|
|
112
|
+
let fetchStub;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
originalSentry = window.Sentry;
|
|
116
|
+
window.Sentry = { captureException: Sinon.spy() };
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
window.Sentry = originalSentry;
|
|
121
|
+
fetchStub?.restore();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('says the service could not be reached only when the fetch itself failed', async () => {
|
|
125
|
+
fetchStub = Sinon.stub(window, 'fetch').rejects(
|
|
126
|
+
new TypeError('Failed to fetch')
|
|
127
|
+
);
|
|
128
|
+
const error = Sinon.spy();
|
|
129
|
+
|
|
130
|
+
await ActionsHandlerService({
|
|
131
|
+
identifier: 'foo',
|
|
132
|
+
action: 'create_token',
|
|
133
|
+
error,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(error.calledOnce).to.be.true;
|
|
137
|
+
expect(error.firstCall.args[0].error).to.equal(networkError);
|
|
138
|
+
expect(error.firstCall.args[0].networkFailure).to.be.true;
|
|
139
|
+
const [, context] = window.Sentry.captureException.firstCall.args;
|
|
140
|
+
expect(context.tags).to.deep.equal({
|
|
141
|
+
identifier: 'foo',
|
|
142
|
+
action: 'create_token',
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("doesn't blame the network for a throw after the response arrived", async () => {
|
|
147
|
+
// e.g. the caller's success callback crashing (BookReader init)
|
|
148
|
+
const error = Sinon.spy();
|
|
149
|
+
|
|
150
|
+
await ActionsHandlerService({
|
|
151
|
+
identifier: 'foo',
|
|
152
|
+
action: 'create_token',
|
|
153
|
+
success: () => {
|
|
154
|
+
throw new Error('boom');
|
|
155
|
+
},
|
|
156
|
+
error,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(error.calledOnce).to.be.true;
|
|
160
|
+
expect(error.firstCall.args[0].error).to.equal(unexpectedError);
|
|
161
|
+
expect(error.firstCall.args[0].detail).to.contain('boom');
|
|
162
|
+
// the service did answer, so this isn't treated as "no answer"
|
|
163
|
+
expect(error.firstCall.args[0].networkFailure).to.be.undefined;
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -2,6 +2,7 @@ import { expect } from '@open-wc/testing';
|
|
|
2
2
|
import Sinon from 'sinon';
|
|
3
3
|
|
|
4
4
|
import { LoanTokenPoller } from '../../../src/core/services/loan-token-poller.js';
|
|
5
|
+
import { unexpectedResponseError } from '../../../src/core/services/actions-handler/actions-handler-service.js';
|
|
5
6
|
import '@internetarchive/modal-manager';
|
|
6
7
|
|
|
7
8
|
beforeEach(async () => {
|
|
@@ -152,3 +153,73 @@ describe('skipInitialCall option', () => {
|
|
|
152
153
|
poller.disconnectedCallback();
|
|
153
154
|
});
|
|
154
155
|
});
|
|
156
|
+
|
|
157
|
+
describe('handleTokenError - Sentry report (WEBDEV-9595)', () => {
|
|
158
|
+
let originalSentry;
|
|
159
|
+
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
originalSentry = window.Sentry;
|
|
162
|
+
window.Sentry = { captureMessage: Sinon.spy() };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
window.Sentry = originalSentry;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('sends the identifier, action, borrowType and status to Sentry', () => {
|
|
170
|
+
const poller = new LoanTokenPoller({
|
|
171
|
+
identifier: 'identifier1',
|
|
172
|
+
borrowType: 'borrowed',
|
|
173
|
+
successCallback: () => {},
|
|
174
|
+
errorCallback: () => {},
|
|
175
|
+
pollerDelay: 120,
|
|
176
|
+
skipInitialCall: true,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
poller.handleTokenError(
|
|
180
|
+
{
|
|
181
|
+
success: false,
|
|
182
|
+
error: unexpectedResponseError(400),
|
|
183
|
+
status: 400,
|
|
184
|
+
},
|
|
185
|
+
false
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const [, context] = window.Sentry.captureMessage.lastCall.args;
|
|
189
|
+
expect(context.tags).to.deep.equal({
|
|
190
|
+
identifier: 'identifier1',
|
|
191
|
+
action: 'create_token',
|
|
192
|
+
});
|
|
193
|
+
expect(context.extra.borrowType).to.equal('borrowed');
|
|
194
|
+
expect(context.extra.status).to.equal(400);
|
|
195
|
+
expect(context.extra.isInitial).to.be.false;
|
|
196
|
+
poller.disconnectedCallback();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('handleTokenError - HTML 400 from the WAF (WEBDEV-9595)', () => {
|
|
201
|
+
it('still calls errorCallback, passing the response through for the consumer to decide', () => {
|
|
202
|
+
const errorCallback = Sinon.spy();
|
|
203
|
+
const poller = new LoanTokenPoller({
|
|
204
|
+
identifier: 'identifier1',
|
|
205
|
+
borrowType: 'borrowed',
|
|
206
|
+
successCallback: () => {},
|
|
207
|
+
errorCallback,
|
|
208
|
+
pollerDelay: 120,
|
|
209
|
+
skipInitialCall: true,
|
|
210
|
+
});
|
|
211
|
+
const data = {
|
|
212
|
+
success: false,
|
|
213
|
+
error: unexpectedResponseError(400),
|
|
214
|
+
status: 400,
|
|
215
|
+
nonJsonResponse: true,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
poller.handleTokenError(data, false);
|
|
219
|
+
|
|
220
|
+
expect(errorCallback.calledOnce).to.be.true;
|
|
221
|
+
expect(errorCallback.firstCall.args[0].detail.data).to.deep.equal(data);
|
|
222
|
+
expect(errorCallback.firstCall.args[0].detail.isInitial).to.be.false;
|
|
223
|
+
poller.disconnectedCallback();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { expect } from '@open-wc/testing';
|
|
2
|
+
import Sinon from 'sinon';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildSentryContext,
|
|
6
|
+
sentryCaptureMessage,
|
|
7
|
+
sentryCaptureException,
|
|
8
|
+
} from '../../../src/core/services/sentry-report.js';
|
|
9
|
+
|
|
10
|
+
describe('sentry-report', () => {
|
|
11
|
+
let originalSentry;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
originalSentry = window.Sentry;
|
|
15
|
+
window.Sentry = {
|
|
16
|
+
captureMessage: Sinon.spy(),
|
|
17
|
+
captureException: Sinon.spy(),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
window.Sentry = originalSentry;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('tags events with the identifier and action', () => {
|
|
26
|
+
const context = buildSentryContext({
|
|
27
|
+
identifier: 'foo',
|
|
28
|
+
action: 'create_token',
|
|
29
|
+
extra: { status: 400 },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(context.tags).to.deep.equal({
|
|
33
|
+
identifier: 'foo',
|
|
34
|
+
action: 'create_token',
|
|
35
|
+
});
|
|
36
|
+
expect(context.extra).to.deep.equal({
|
|
37
|
+
identifier: 'foo',
|
|
38
|
+
action: 'create_token',
|
|
39
|
+
status: 400,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('leaves out tags that are not known', () => {
|
|
44
|
+
expect(buildSentryContext({ identifier: 'foo' }).tags).to.deep.equal({
|
|
45
|
+
identifier: 'foo',
|
|
46
|
+
});
|
|
47
|
+
expect(buildSentryContext().tags).to.deep.equal({});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('passes the context through to Sentry', () => {
|
|
51
|
+
sentryCaptureMessage('msg', { identifier: 'foo', action: 'renew_loan' });
|
|
52
|
+
const error = new Error('boom');
|
|
53
|
+
sentryCaptureException(error, { identifier: 'foo', action: 'return_loan' });
|
|
54
|
+
|
|
55
|
+
expect(window.Sentry.captureMessage.firstCall.args[0]).to.equal('msg');
|
|
56
|
+
expect(window.Sentry.captureMessage.firstCall.args[1].tags).to.deep.equal({
|
|
57
|
+
identifier: 'foo',
|
|
58
|
+
action: 'renew_loan',
|
|
59
|
+
});
|
|
60
|
+
expect(window.Sentry.captureException.firstCall.args[0]).to.equal(error);
|
|
61
|
+
expect(
|
|
62
|
+
window.Sentry.captureException.firstCall.args[1].tags.action
|
|
63
|
+
).to.equal('return_loan');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does nothing when Sentry is not loaded', () => {
|
|
67
|
+
window.Sentry = undefined;
|
|
68
|
+
expect(() =>
|
|
69
|
+
sentryCaptureMessage('msg', { identifier: 'foo' })
|
|
70
|
+
).to.not.throw();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -5,6 +5,10 @@ import Sinon from 'sinon';
|
|
|
5
5
|
|
|
6
6
|
import { SharedResizeObserver } from '@internetarchive/shared-resize-observer';
|
|
7
7
|
import { LocalCache } from '@internetarchive/local-cache';
|
|
8
|
+
import {
|
|
9
|
+
unexpectedResponseError,
|
|
10
|
+
networkError,
|
|
11
|
+
} from '../src/core/services/actions-handler/actions-handler-service.js';
|
|
8
12
|
|
|
9
13
|
beforeEach(() => {
|
|
10
14
|
const modalManager = document.createElement('modal-manager');
|
|
@@ -1699,3 +1703,131 @@ describe('WEBDEV-8322 review fixes', () => {
|
|
|
1699
1703
|
expect(startSpy.called).to.be.false;
|
|
1700
1704
|
});
|
|
1701
1705
|
});
|
|
1706
|
+
|
|
1707
|
+
describe('Sentry reports (WEBDEV-9595)', () => {
|
|
1708
|
+
it('tags its Sentry reports with the identifier and action', async () => {
|
|
1709
|
+
const originalSentry = window.Sentry;
|
|
1710
|
+
window.Sentry = { captureMessage: Sinon.spy() };
|
|
1711
|
+
try {
|
|
1712
|
+
const el = await fixture(
|
|
1713
|
+
container({
|
|
1714
|
+
userid: '@user1',
|
|
1715
|
+
identifier: 'sentry-tagged',
|
|
1716
|
+
lendingStatus: {
|
|
1717
|
+
is_lendable: true,
|
|
1718
|
+
available_to_browse: true,
|
|
1719
|
+
user_has_browsed: true,
|
|
1720
|
+
browsingExpired: false,
|
|
1721
|
+
secondsLeftOnLoan: 300,
|
|
1722
|
+
},
|
|
1723
|
+
})
|
|
1724
|
+
);
|
|
1725
|
+
await el.updateComplete;
|
|
1726
|
+
|
|
1727
|
+
el.sentryCaptureMsg('some message', 'renew_loan');
|
|
1728
|
+
|
|
1729
|
+
const [, context] = window.Sentry.captureMessage.lastCall.args;
|
|
1730
|
+
expect(context.tags).to.deep.equal({
|
|
1731
|
+
identifier: 'sentry-tagged',
|
|
1732
|
+
action: 'renew_loan',
|
|
1733
|
+
});
|
|
1734
|
+
} finally {
|
|
1735
|
+
window.Sentry = originalSentry;
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
describe('handleLendingActionError - routine create_token refresh failures (WEBDEV-9595)', () => {
|
|
1741
|
+
const htmlBadRequest = {
|
|
1742
|
+
success: false,
|
|
1743
|
+
error: unexpectedResponseError(400),
|
|
1744
|
+
status: 400,
|
|
1745
|
+
nonJsonResponse: true,
|
|
1746
|
+
};
|
|
1747
|
+
// the service never answered: no modal on a routine refresh
|
|
1748
|
+
const noAnswer = {
|
|
1749
|
+
'an HTML 400 (WAF block)': htmlBadRequest,
|
|
1750
|
+
'an HTML 502': {
|
|
1751
|
+
...htmlBadRequest,
|
|
1752
|
+
error: unexpectedResponseError(502),
|
|
1753
|
+
status: 502,
|
|
1754
|
+
},
|
|
1755
|
+
'a blocked or failed request': {
|
|
1756
|
+
error: networkError,
|
|
1757
|
+
detail: 'TypeError: Failed to fetch',
|
|
1758
|
+
networkFailure: true,
|
|
1759
|
+
},
|
|
1760
|
+
};
|
|
1761
|
+
// the service answered "no": the loan may really be gone
|
|
1762
|
+
const serviceAnswer = {
|
|
1763
|
+
success: false,
|
|
1764
|
+
error: 'You do not currently have this book borrowed.',
|
|
1765
|
+
status: 400,
|
|
1766
|
+
};
|
|
1767
|
+
const liveLoan = {
|
|
1768
|
+
is_lendable: true,
|
|
1769
|
+
user_has_borrowed: true,
|
|
1770
|
+
browsingExpired: false,
|
|
1771
|
+
secondsLeftOnLoan: 300,
|
|
1772
|
+
};
|
|
1773
|
+
const setup = async () => {
|
|
1774
|
+
const el = await fixture(
|
|
1775
|
+
container({
|
|
1776
|
+
userid: '@user1',
|
|
1777
|
+
identifier: 'token-refresh-failure',
|
|
1778
|
+
lendingStatus: liveLoan,
|
|
1779
|
+
})
|
|
1780
|
+
);
|
|
1781
|
+
await el.updateComplete;
|
|
1782
|
+
return el;
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
for (const [name, data] of Object.entries(noAnswer)) {
|
|
1786
|
+
it(`shows no modal and keeps the session going for ${name}`, async () => {
|
|
1787
|
+
const el = await setup();
|
|
1788
|
+
const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
|
|
1789
|
+
const clearAllSpy = Sinon.spy(window.IALendingIntervals, 'clearAll');
|
|
1790
|
+
el.tokenPoller = { disconnectedCallback: Sinon.spy() };
|
|
1791
|
+
|
|
1792
|
+
try {
|
|
1793
|
+
el.handleLendingActionError({
|
|
1794
|
+
detail: { action: 'create_token', isInitial: false, data },
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
expect(showErrorModalSpy.called).to.be.false;
|
|
1798
|
+
// none of the create_token teardown ran
|
|
1799
|
+
expect(clearAllSpy.called).to.be.false;
|
|
1800
|
+
expect(el.tokenPoller.disconnectedCallback.called).to.be.false;
|
|
1801
|
+
expect(el.lendingStatus.secondsLeftOnLoan).to.equal(300);
|
|
1802
|
+
expect(el.lendingStatus.available_to_browse).to.be.undefined;
|
|
1803
|
+
} finally {
|
|
1804
|
+
clearAllSpy.restore();
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
it(`still shows the modal for ${name} when the book is first opening`, async () => {
|
|
1809
|
+
const el = await setup();
|
|
1810
|
+
const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
|
|
1811
|
+
|
|
1812
|
+
el.handleLendingActionError({
|
|
1813
|
+
detail: { action: 'create_token', isInitial: true, data },
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
expect(showErrorModalSpy.calledOnce).to.be.true;
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
it("still shows the modal and resets the bar when the service itself says no", async () => {
|
|
1821
|
+
const el = await setup();
|
|
1822
|
+
const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
|
|
1823
|
+
|
|
1824
|
+
el.handleLendingActionError({
|
|
1825
|
+
detail: { action: 'create_token', isInitial: false, data: serviceAnswer },
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
expect(
|
|
1829
|
+
showErrorModalSpy.calledOnceWith(serviceAnswer.error, 'create_token')
|
|
1830
|
+
).to.be.true;
|
|
1831
|
+
expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
|
|
1832
|
+
});
|
|
1833
|
+
});
|