@internetarchive/ia-book-actions 1.4.5 → 1.4.6-alpha1

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
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.5",
10
+ "version": "1.4.6-alpha1",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -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
- // The response is a Response instance.
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
- window?.Sentry?.captureException(
104
- `${sentryLogs.actionsHandlerService} - Error: ${error}`
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) or a non-JSON body (a 405 returning HTML) lands here, and
110
- * calling neither `success` nor `error` leaves every caller waiting on a
111
- * callback that never comes.
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,75 @@ 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
- option?.error?.({ error: String(error) });
148
+ option?.error?.({
149
+ error: reachedResponse ? unexpectedError : networkError,
150
+ detail: String(error),
151
+ });
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Parse a lending service response without ever surfacing a raw
157
+ * `SyntaxError` to the patron. Anything in front of PHP (the WAF, or nginx
158
+ * swapping in /about/400.html or a 5xx page) can answer with HTML instead
159
+ * of the service's JSON; that becomes a readable error carrying the HTTP
160
+ * status, and is reported to Sentry with the identifier/action.
161
+ *
162
+ * @param {Response} response
163
+ * @param {Object} option - the ActionsHandlerService options
164
+ * @returns {Promise<Object>}
165
+ */
166
+ export async function parseServiceResponse(response, option) {
167
+ const { status } = response;
168
+ const contentType = response.headers?.get('content-type') || '';
169
+
170
+ // The service always answers with JSON (application/json, or
171
+ // application/problem+json for OPDS) — anything else never came from it.
172
+ const body = await response.text();
173
+ let data;
174
+ if (contentType.includes('json')) {
175
+ try {
176
+ data = JSON.parse(body);
177
+ } catch (parseError) {
178
+ data = undefined;
179
+ }
121
180
  }
181
+
182
+ if (data === undefined) {
183
+ sentryCaptureMessage(
184
+ `${sentryLogs.actionsHandlerService} - ${option.action}: non-JSON response (HTTP ${status})`,
185
+ {
186
+ identifier: option.identifier,
187
+ action: option.action,
188
+ extra: {
189
+ status,
190
+ contentType,
191
+ bodySnippet: body.slice(0, 300),
192
+ },
193
+ }
194
+ );
195
+ // nonJsonResponse lets callers tell "something in front of PHP answered"
196
+ // apart from the service's own error — see
197
+ // IABookActions.handleLendingActionError.
198
+ return {
199
+ success: false,
200
+ error: unexpectedResponseError(status),
201
+ status,
202
+ nonJsonResponse: true,
203
+ };
204
+ }
205
+
206
+ // Carry the HTTP status on the service's own JSON errors too, so it
207
+ // reaches Sentry. A JSON error status without the service's usual `error`
208
+ // field would otherwise be treated as a success.
209
+ if (!response.ok) {
210
+ return {
211
+ ...(data && typeof data === 'object' ? data : {}),
212
+ success: false,
213
+ error: data?.error || unexpectedResponseError(status),
214
+ status,
215
+ };
216
+ }
217
+
218
+ return data;
122
219
  }
@@ -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
- window?.Sentry?.captureMessage(
232
- `${sentryLogs.bookRenewFailed} - Error: ${JSON.stringify(data)}`
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
- window?.Sentry?.captureException(
242
- `${sentryLogs.bookRenewFailed} - Exception: ${error}`
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('[LoanTokenPoller] skipping initial create_token — already minted by the renewal response', {
66
- identifier: this.identifier,
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
- window?.Sentry?.captureMessage(
87
- `${sentryLogs.bookAccessed} - not borrowed`
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 a create_token failure via errorCallback — no retry, it's
121
- * treated as terminal on the first failure. Split out from
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
- window?.Sentry?.captureMessage(
144
- `${sentryLogs.handleLoanTokenPoller} - Error: ${JSON.stringify(data)}`
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
+ }
@@ -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
- window?.Sentry?.captureMessage(msg);
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(`visibilitychange handler failed: ${error}`);
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 = event?.detail?.data?.error;
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,36 @@ 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?.status === 400
1022
+ ) {
1023
+ // Something in front of the service (the WAF's /about/400.html)
1024
+ // answered a routine refresh instead of the service itself. It's
1025
+ // already reported to Sentry (ActionsHandlerService/LoanTokenPoller);
1026
+ // the current access token keeps the book readable, and the poll
1027
+ // interval is left running, so the next tick tries again. No modal,
1028
+ // and no session teardown.
1029
+ //
1030
+ // Not for the initial call: its success is what runs
1031
+ // lendingBarPostInit() and opens the book (see startLoanTokenPoller),
1032
+ // so suppressing it would leave a reader that never loads, silently.
1033
+ log(
1034
+ '[IABookActions] create_token HTML 400 on a routine refresh — no modal',
1035
+ {
1036
+ identifier: this.identifier,
1037
+ status: eventData?.status,
1038
+ }
1039
+ );
1040
+ return;
1041
+ }
1042
+
1010
1043
  if (action === 'create_token') {
1011
- // Any create_token failure — initial or a routine interval refresh —
1012
- // means access can no longer be confirmed (e.g. a stale loan-record
1044
+ // Any other create_token failure — initial or a routine interval
1045
+ // refresh, except the routine HTML 400 above — means access can no
1046
+ // longer be confirmed (e.g. a stale loan-record
1013
1047
  // cache on some other node can make this request fail even though
1014
1048
  // the loan is fine, or the loan may genuinely have just been
1015
1049
  // returned via BookReaderImages.php's revokeAccess()). Don't retry
@@ -0,0 +1,162 @@
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
+ const [, context] = window.Sentry.captureException.firstCall.args;
139
+ expect(context.tags).to.deep.equal({
140
+ identifier: 'foo',
141
+ action: 'create_token',
142
+ });
143
+ });
144
+
145
+ it("doesn't blame the network for a throw after the response arrived", async () => {
146
+ // e.g. the caller's success callback crashing (BookReader init)
147
+ const error = Sinon.spy();
148
+
149
+ await ActionsHandlerService({
150
+ identifier: 'foo',
151
+ action: 'create_token',
152
+ success: () => {
153
+ throw new Error('boom');
154
+ },
155
+ error,
156
+ });
157
+
158
+ expect(error.calledOnce).to.be.true;
159
+ expect(error.firstCall.args[0].error).to.equal(unexpectedError);
160
+ expect(error.firstCall.args[0].detail).to.contain('boom');
161
+ });
162
+ });
@@ -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,7 @@ 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 { unexpectedResponseError } from '../src/core/services/actions-handler/actions-handler-service.js';
8
9
 
9
10
  beforeEach(() => {
10
11
  const modalManager = document.createElement('modal-manager');
@@ -1699,3 +1700,127 @@ describe('WEBDEV-8322 review fixes', () => {
1699
1700
  expect(startSpy.called).to.be.false;
1700
1701
  });
1701
1702
  });
1703
+
1704
+ describe('Sentry reports (WEBDEV-9595)', () => {
1705
+ it('tags its Sentry reports with the identifier and action', async () => {
1706
+ const originalSentry = window.Sentry;
1707
+ window.Sentry = { captureMessage: Sinon.spy() };
1708
+ try {
1709
+ const el = await fixture(
1710
+ container({
1711
+ userid: '@user1',
1712
+ identifier: 'sentry-tagged',
1713
+ lendingStatus: {
1714
+ is_lendable: true,
1715
+ available_to_browse: true,
1716
+ user_has_browsed: true,
1717
+ browsingExpired: false,
1718
+ secondsLeftOnLoan: 300,
1719
+ },
1720
+ })
1721
+ );
1722
+ await el.updateComplete;
1723
+
1724
+ el.sentryCaptureMsg('some message', 'renew_loan');
1725
+
1726
+ const [, context] = window.Sentry.captureMessage.lastCall.args;
1727
+ expect(context.tags).to.deep.equal({
1728
+ identifier: 'sentry-tagged',
1729
+ action: 'renew_loan',
1730
+ });
1731
+ } finally {
1732
+ window.Sentry = originalSentry;
1733
+ }
1734
+ });
1735
+ });
1736
+
1737
+ describe('handleLendingActionError - HTML 400 on create_token (WEBDEV-9595)', () => {
1738
+ const htmlBadRequest = {
1739
+ success: false,
1740
+ error: unexpectedResponseError(400),
1741
+ status: 400,
1742
+ nonJsonResponse: true,
1743
+ };
1744
+ const liveLoan = {
1745
+ is_lendable: true,
1746
+ user_has_borrowed: true,
1747
+ browsingExpired: false,
1748
+ secondsLeftOnLoan: 300,
1749
+ };
1750
+
1751
+ it('shows no modal and keeps the session going on a routine refresh', async () => {
1752
+ const el = await fixture(
1753
+ container({
1754
+ userid: '@user1',
1755
+ identifier: 'html-400-routine',
1756
+ lendingStatus: liveLoan,
1757
+ })
1758
+ );
1759
+ await el.updateComplete;
1760
+ const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1761
+ const clearAllSpy = Sinon.spy(window.IALendingIntervals, 'clearAll');
1762
+ el.tokenPoller = { disconnectedCallback: Sinon.spy() };
1763
+
1764
+ try {
1765
+ el.handleLendingActionError({
1766
+ detail: { action: 'create_token', isInitial: false, data: htmlBadRequest },
1767
+ });
1768
+
1769
+ expect(showErrorModalSpy.called).to.be.false;
1770
+ // none of the create_token teardown ran
1771
+ expect(clearAllSpy.called).to.be.false;
1772
+ expect(el.tokenPoller.disconnectedCallback.called).to.be.false;
1773
+ expect(el.lendingStatus.secondsLeftOnLoan).to.equal(300);
1774
+ expect(el.lendingStatus.available_to_browse).to.be.undefined;
1775
+ } finally {
1776
+ clearAllSpy.restore();
1777
+ }
1778
+ });
1779
+
1780
+ it('still shows the modal when the book is first opening', async () => {
1781
+ const el = await fixture(
1782
+ container({
1783
+ userid: '@user1',
1784
+ identifier: 'html-400-initial',
1785
+ lendingStatus: liveLoan,
1786
+ })
1787
+ );
1788
+ await el.updateComplete;
1789
+ const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1790
+
1791
+ el.handleLendingActionError({
1792
+ detail: { action: 'create_token', isInitial: true, data: htmlBadRequest },
1793
+ });
1794
+
1795
+ expect(showErrorModalSpy.calledOnce).to.be.true;
1796
+ });
1797
+
1798
+ it("still shows the modal for the service's own JSON 400 and for an HTML 5xx", async () => {
1799
+ const el = await fixture(
1800
+ container({
1801
+ userid: '@user1',
1802
+ identifier: 'html-400-other',
1803
+ lendingStatus: liveLoan,
1804
+ })
1805
+ );
1806
+ await el.updateComplete;
1807
+ const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1808
+
1809
+ el.handleLendingActionError({
1810
+ detail: {
1811
+ action: 'create_token',
1812
+ isInitial: false,
1813
+ data: { error: 'You do not currently have this book borrowed.' },
1814
+ },
1815
+ });
1816
+ el.handleLendingActionError({
1817
+ detail: {
1818
+ action: 'create_token',
1819
+ isInitial: false,
1820
+ data: { ...htmlBadRequest, status: 502 },
1821
+ },
1822
+ });
1823
+
1824
+ expect(showErrorModalSpy.calledTwice).to.be.true;
1825
+ });
1826
+ });