@internetarchive/ia-book-actions 1.4.4-alpha14 → 1.4.4-alpha15
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/index.html +38 -1
- package/package.json +1 -1
- package/src/core/services/actions-handler/actions-handler-service.js +12 -4
- package/src/core/services/loan-token-poller.js +5 -65
- package/src/ia-book-actions.js +35 -96
- package/test/core/services/loan-token-poller.test.js +12 -101
- package/test/ia-book-actions.test.js +20 -130
package/index.html
CHANGED
|
@@ -360,7 +360,33 @@
|
|
|
360
360
|
});
|
|
361
361
|
});
|
|
362
362
|
|
|
363
|
-
|
|
363
|
+
// WEBDEV-8322 repro: fail only create_token (via ?failAction=create_token),
|
|
364
|
+
// leaving renew_loan (and every other action) succeeding normally —
|
|
365
|
+
// see actions-handler-service.js's shouldReturnError.
|
|
366
|
+
document.querySelectorAll('.failCreateToken input[type=checkbox]').forEach((item) => {
|
|
367
|
+
if (window.location.href.indexOf('?failAction=create_token') !== -1) {
|
|
368
|
+
item.checked = true;
|
|
369
|
+
}
|
|
370
|
+
item.addEventListener('click', (e) => {
|
|
371
|
+
console.log(`[Demo] fail-create_token-only mode ${e.target.checked ? 'enabled' : 'disabled'}`);
|
|
372
|
+
|
|
373
|
+
var searchParams = new URLSearchParams(window.location.search);
|
|
374
|
+
var separate = '';
|
|
375
|
+
if (e.target.checked) {
|
|
376
|
+
separate = '?';
|
|
377
|
+
searchParams.set('failAction', 'create_token');
|
|
378
|
+
} else {
|
|
379
|
+
separate = '';
|
|
380
|
+
searchParams.delete('failAction');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (history.pushState) {
|
|
384
|
+
var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + separate + searchParams;
|
|
385
|
+
window.history.pushState({path:newurl},'',newurl);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
364
390
|
document.querySelectorAll('.userState input[type=checkbox]').forEach((item) => {
|
|
365
391
|
item.addEventListener('click', (e) => {
|
|
366
392
|
if (e.target.checked) {
|
|
@@ -726,6 +752,17 @@
|
|
|
726
752
|
</span>
|
|
727
753
|
</td>
|
|
728
754
|
</tr>
|
|
755
|
+
<tr colspan='5'>
|
|
756
|
+
<td class="failCreateToken">
|
|
757
|
+
<label><input type="checkbox" name="failCreateToken" value="failCreateToken" />Fail create_token only (renew_loan still succeeds)</label>
|
|
758
|
+
<span style="color: #666; font-size: 11px; display: block; padding: 0 10px 10px;">
|
|
759
|
+
WEBDEV-8322 repro: unlike the checkbox above, this leaves renew_loan
|
|
760
|
+
succeeding and fails only the create_token call right after it — turn
|
|
761
|
+
this on, then run scenario 2 → 3 (or 2 → 4) below. Expect the error
|
|
762
|
+
modal, and the bar/timer resetting to Borrow/0 (no retry).
|
|
763
|
+
</span>
|
|
764
|
+
</td>
|
|
765
|
+
</tr>
|
|
729
766
|
<tr>
|
|
730
767
|
<td class="userState">
|
|
731
768
|
<label><input type="checkbox" name="user" value="isAdmin" />isAdmin</label>
|
package/package.json
CHANGED
|
@@ -18,7 +18,6 @@ export default async function ActionsHandlerService(options) {
|
|
|
18
18
|
let baseHost = '/services/loans/loan';
|
|
19
19
|
const location = window?.location;
|
|
20
20
|
|
|
21
|
-
// return error reponse when not production and has ?error=true param...
|
|
22
21
|
const tokenError = 'loan token not found. please try again later.';
|
|
23
22
|
const borrowError =
|
|
24
23
|
'This book is not available to borrow at this time. Please try again later.';
|
|
@@ -29,9 +28,18 @@ export default async function ActionsHandlerService(options) {
|
|
|
29
28
|
'renew_loan',
|
|
30
29
|
'return_loan',
|
|
31
30
|
];
|
|
31
|
+
const searchParams = new URLSearchParams(location?.search);
|
|
32
|
+
// ?error=true fails every erroneous action (e.g. simulates the book
|
|
33
|
+
// already being borrowed by someone else). ?failAction=create_token (or
|
|
34
|
+
// any action name) fails ONLY that action, so a renew_loan can succeed
|
|
35
|
+
// and the immediately-following create_token can be made to fail —
|
|
36
|
+
// reproducing WEBDEV-8322's "renewed, but create_token failed" bug
|
|
37
|
+
// without needing a real book.
|
|
32
38
|
const shouldReturnError =
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
location?.hostname !== 'archive.org' &&
|
|
40
|
+
erroneousActions.includes(option?.action) &&
|
|
41
|
+
(searchParams.get('error') === 'true' ||
|
|
42
|
+
searchParams.get('failAction') === option?.action);
|
|
35
43
|
|
|
36
44
|
const testHostname = ['localhost', 'internetarchive.github.io'];
|
|
37
45
|
let isTest = false;
|
|
@@ -51,7 +59,7 @@ export default async function ActionsHandlerService(options) {
|
|
|
51
59
|
})
|
|
52
60
|
.then(async response => {
|
|
53
61
|
// intentional error on localhost
|
|
54
|
-
if (shouldReturnError
|
|
62
|
+
if (shouldReturnError) {
|
|
55
63
|
return {
|
|
56
64
|
success: false,
|
|
57
65
|
error: option?.action === 'create_token' ? tokenError : borrowError,
|
|
@@ -19,26 +19,6 @@ export class LoanTokenPoller {
|
|
|
19
19
|
|
|
20
20
|
this.loanTokenInterval = undefined;
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
* Lending::do_renew() (backend) deletes the old loan record and
|
|
24
|
-
* writes a brand-new one on every renewal — this poller's initial
|
|
25
|
-
* create_token call (fired right after a renewal re-establishes the
|
|
26
|
-
* reading session) can race that write's propagation and come back
|
|
27
|
-
* with "you do not currently have this book borrowed" even though
|
|
28
|
-
* the loan is genuinely valid. Retry a few times with backoff instead
|
|
29
|
-
* of giving up on the first attempt.
|
|
30
|
-
*/
|
|
31
|
-
this.maxTokenRetries = 3;
|
|
32
|
-
this.tokenRetryDelay = 1000; // ms, multiplied by attempt number
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Handle for a pending retry, so teardown can cancel it. It's a bare
|
|
36
|
-
* setTimeout rather than an IALendingIntervals entry, so without this
|
|
37
|
-
* a retry scheduled just before the loan is returned would still fire
|
|
38
|
-
* its create_token afterwards.
|
|
39
|
-
*/
|
|
40
|
-
this.retryTimeout = undefined;
|
|
41
|
-
|
|
42
22
|
/**
|
|
43
23
|
* loan analytics instance
|
|
44
24
|
* @see loan-analytics.js
|
|
@@ -50,8 +30,6 @@ export class LoanTokenPoller {
|
|
|
50
30
|
|
|
51
31
|
disconnectedCallback() {
|
|
52
32
|
window?.IALendingIntervals?.clearTokenPoller();
|
|
53
|
-
clearTimeout(this.retryTimeout);
|
|
54
|
-
this.retryTimeout = undefined;
|
|
55
33
|
}
|
|
56
34
|
|
|
57
35
|
async bookAccessed() {
|
|
@@ -84,24 +62,21 @@ export class LoanTokenPoller {
|
|
|
84
62
|
/**
|
|
85
63
|
* @param {boolean} isInitial - the first create_token call right after
|
|
86
64
|
* bookAccessed()/a renewal, as opposed to a routine interval tick.
|
|
87
|
-
* @param {number} retryCount - internal, counts retries of the initial call.
|
|
88
65
|
*/
|
|
89
|
-
async handleLoanTokenPoller(isInitial = false
|
|
66
|
+
async handleLoanTokenPoller(isInitial = false) {
|
|
90
67
|
const action = 'create_token';
|
|
91
68
|
log('[LoanTokenPoller] create_token requested', {
|
|
92
69
|
identifier: this.identifier,
|
|
93
70
|
isInitial,
|
|
94
|
-
retryCount,
|
|
95
71
|
});
|
|
96
72
|
ActionsHandlerService({
|
|
97
73
|
identifier: this.identifier,
|
|
98
74
|
action,
|
|
99
|
-
error: data => this.handleTokenError(data, isInitial
|
|
75
|
+
error: data => this.handleTokenError(data, isInitial),
|
|
100
76
|
success: () => {
|
|
101
77
|
log('[LoanTokenPoller] create_token succeeded', {
|
|
102
78
|
identifier: this.identifier,
|
|
103
79
|
isInitial,
|
|
104
|
-
retryCount,
|
|
105
80
|
});
|
|
106
81
|
if (isInitial) this.successCallback();
|
|
107
82
|
},
|
|
@@ -109,58 +84,23 @@ export class LoanTokenPoller {
|
|
|
109
84
|
}
|
|
110
85
|
|
|
111
86
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* comment) or reported via errorCallback. Split out from
|
|
87
|
+
* Reports a create_token failure via errorCallback — no retry, it's
|
|
88
|
+
* treated as terminal on the first failure. Split out from
|
|
115
89
|
* handleLoanTokenPoller so it's directly testable without needing to
|
|
116
90
|
* mock the network call.
|
|
117
91
|
*
|
|
118
92
|
* @param {Object} data - the error payload from ActionsHandlerService.
|
|
119
93
|
* @param {boolean} isInitial
|
|
120
|
-
* @param {number} retryCount
|
|
121
94
|
*/
|
|
122
|
-
handleTokenError(data, isInitial
|
|
95
|
+
handleTokenError(data, isInitial) {
|
|
123
96
|
const action = 'create_token';
|
|
124
|
-
const isStaleLoanReadError =
|
|
125
|
-
typeof data?.error === 'string' &&
|
|
126
|
-
/do not currently have this book borrowed/i.test(data.error);
|
|
127
97
|
|
|
128
98
|
log('[LoanTokenPoller] create_token failed', {
|
|
129
99
|
identifier: this.identifier,
|
|
130
100
|
isInitial,
|
|
131
|
-
retryCount,
|
|
132
|
-
isStaleLoanReadError,
|
|
133
101
|
error: data?.error,
|
|
134
102
|
});
|
|
135
103
|
|
|
136
|
-
// The renewal write can be mid-propagation for an interval refresh too,
|
|
137
|
-
// not just the initial call — a routine tick landing moments after a
|
|
138
|
-
// renewal hits the same race. Retry both; isInitial only decides how a
|
|
139
|
-
// final failure is reported, not whether it's worth retrying.
|
|
140
|
-
if (isStaleLoanReadError && retryCount < this.maxTokenRetries) {
|
|
141
|
-
const delay = this.tokenRetryDelay * (retryCount + 1);
|
|
142
|
-
log(
|
|
143
|
-
'[LoanTokenPoller] retrying create_token after stale-loan-read error',
|
|
144
|
-
{
|
|
145
|
-
identifier: this.identifier,
|
|
146
|
-
nextRetryCount: retryCount + 1,
|
|
147
|
-
delay,
|
|
148
|
-
}
|
|
149
|
-
);
|
|
150
|
-
clearTimeout(this.retryTimeout);
|
|
151
|
-
this.retryTimeout = setTimeout(() => {
|
|
152
|
-
this.retryTimeout = undefined;
|
|
153
|
-
this.handleLoanTokenPoller(isInitial, retryCount + 1);
|
|
154
|
-
}, delay);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
log('[LoanTokenPoller] giving up on create_token, reporting error', {
|
|
159
|
-
identifier: this.identifier,
|
|
160
|
-
isInitial,
|
|
161
|
-
retryCount,
|
|
162
|
-
});
|
|
163
|
-
|
|
164
104
|
// isInitial rides along so the consumer can distinguish "the book won't
|
|
165
105
|
// open at all" from "a mid-read refresh blipped" — see
|
|
166
106
|
// IABookActions.handleLendingActionError.
|
package/src/ia-book-actions.js
CHANGED
|
@@ -96,18 +96,6 @@ export default class IABookActions extends LitElement {
|
|
|
96
96
|
|
|
97
97
|
this.loanRenewInProgress = false;
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
* True while a create_token error modal is up and the token poller is
|
|
101
|
-
* retrying in the background. Cleared by startLoanTokenPoller()'s
|
|
102
|
-
* successCallback. See handleLendingActionError.
|
|
103
|
-
*/
|
|
104
|
-
this.awaitingTokenRecovery = false;
|
|
105
|
-
|
|
106
|
-
/** Bounds how many times handleLendingActionError restarts the poller
|
|
107
|
-
* for one outage, so a genuinely-lost loan can't retry forever. */
|
|
108
|
-
this.tokenRecoveryAttempts = 0;
|
|
109
|
-
this.maxTokenRecoveryAttempts = 3;
|
|
110
|
-
|
|
111
99
|
/**
|
|
112
100
|
* True while recovering from a loan that genuinely lapsed (set by
|
|
113
101
|
* autoRenewExpiredLoan), as opposed to a routine pre-expiry top-up.
|
|
@@ -301,14 +289,17 @@ export default class IABookActions extends LitElement {
|
|
|
301
289
|
|
|
302
290
|
if (!this.applyLendingActions()) return;
|
|
303
291
|
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
292
|
+
// primaryActions/borrowType/etc are plain fields, not reactive
|
|
293
|
+
// properties, so nothing pushes them to the rendered template (and
|
|
294
|
+
// from there to <collapsible-action-group>) on its own. Do that now,
|
|
295
|
+
// unconditionally — the early returns below (not borrowed, title bar)
|
|
296
|
+
// must not skip it, or the action bar visibly goes stale (e.g. a
|
|
297
|
+
// create_token failure resetting to Borrow never actually shows it).
|
|
298
|
+
this.requestUpdate();
|
|
299
|
+
|
|
300
|
+
// Don't (re)start the countdown mid-renewal — secondsLeftOnLoan could
|
|
301
|
+
// still be stale until handleLoanAutoRenewed() confirms it.
|
|
302
|
+
if (this.borrowType === 'browsed' && !this.loanRenewInProgress) {
|
|
312
303
|
await this.startTimerCountdown();
|
|
313
304
|
await this.startBrowseTimer();
|
|
314
305
|
}
|
|
@@ -331,8 +322,6 @@ export default class IABookActions extends LitElement {
|
|
|
331
322
|
this.startLoanTokenPoller();
|
|
332
323
|
}
|
|
333
324
|
}, 100);
|
|
334
|
-
|
|
335
|
-
this.requestUpdate();
|
|
336
325
|
}
|
|
337
326
|
|
|
338
327
|
/**
|
|
@@ -405,10 +394,6 @@ export default class IABookActions extends LitElement {
|
|
|
405
394
|
|
|
406
395
|
if (this.borrowType !== 'browsed') return;
|
|
407
396
|
|
|
408
|
-
// Timers are deliberately stopped during create_token recovery
|
|
409
|
-
// (see handleLendingActionError) — leave them alone here.
|
|
410
|
-
if (this.awaitingTokenRecovery) return;
|
|
411
|
-
|
|
412
397
|
if (this.lendingStatus.browsingExpired === false) {
|
|
413
398
|
const loanTime = await this.localCache.get(
|
|
414
399
|
`${this.identifier}-loanTime`
|
|
@@ -442,11 +427,9 @@ export default class IABookActions extends LitElement {
|
|
|
442
427
|
* @param {Boolean} hasPageChanged
|
|
443
428
|
*/
|
|
444
429
|
async autoLoanRenewChecker(hasPageChanged = false) {
|
|
445
|
-
// Re-entrancy guard against rapid BookReader:userAction events
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
// itself succeeds, before that's known).
|
|
449
|
-
if (this.loanRenewInProgress || this.awaitingTokenRecovery) return;
|
|
430
|
+
// Re-entrancy guard against rapid BookReader:userAction events (e.g. a
|
|
431
|
+
// single scroll firing several in quick succession).
|
|
432
|
+
if (this.loanRenewInProgress) return;
|
|
450
433
|
|
|
451
434
|
this.loanRenewHelper = new LoanRenewHelper(
|
|
452
435
|
hasPageChanged,
|
|
@@ -465,14 +448,10 @@ export default class IABookActions extends LitElement {
|
|
|
465
448
|
* handleLendingActionError() handle the outcome either way.
|
|
466
449
|
*/
|
|
467
450
|
autoRenewExpiredLoan() {
|
|
468
|
-
if (this.loanRenewInProgress
|
|
451
|
+
if (this.loanRenewInProgress) {
|
|
469
452
|
log(
|
|
470
453
|
'[IABookActions] autoRenewExpiredLoan: skipped, renewal already in progress',
|
|
471
|
-
{
|
|
472
|
-
identifier: this.identifier,
|
|
473
|
-
loanRenewInProgress: this.loanRenewInProgress,
|
|
474
|
-
awaitingTokenRecovery: this.awaitingTokenRecovery,
|
|
475
|
-
}
|
|
454
|
+
{ identifier: this.identifier }
|
|
476
455
|
);
|
|
477
456
|
return;
|
|
478
457
|
}
|
|
@@ -919,48 +898,18 @@ export default class IABookActions extends LitElement {
|
|
|
919
898
|
* @see LoanTokenPoller
|
|
920
899
|
*/
|
|
921
900
|
startLoanTokenPoller() {
|
|
922
|
-
const successCallback =
|
|
901
|
+
const successCallback = () => {
|
|
923
902
|
if (!this.postInitComplete) {
|
|
924
903
|
this.lendingBarPostInit();
|
|
925
904
|
}
|
|
926
905
|
this.postInitComplete = true;
|
|
927
|
-
|
|
928
|
-
if (this.awaitingTokenRecovery) {
|
|
929
|
-
log(
|
|
930
|
-
'[IABookActions] create_token recovered — closing error modal, resuming timer',
|
|
931
|
-
{ identifier: this.identifier }
|
|
932
|
-
);
|
|
933
|
-
this.awaitingTokenRecovery = false;
|
|
934
|
-
this.tokenRecoveryAttempts = 0;
|
|
935
|
-
this.modal?.closeModal();
|
|
936
|
-
this.modal.removeAttribute('id');
|
|
937
|
-
this.modal.customModalContent = nothing;
|
|
938
|
-
|
|
939
|
-
// Recompute the true remaining time from the cache — not however
|
|
940
|
-
// long the outage lasted — before resuming the stopped timers.
|
|
941
|
-
const loanTime = await this.localCache.get(
|
|
942
|
-
`${this.identifier}-loanTime`
|
|
943
|
-
);
|
|
944
|
-
const rawSecondsLeft = Math.round((loanTime - new Date()) / 1000);
|
|
945
|
-
const secondsLeft =
|
|
946
|
-
Number.isFinite(rawSecondsLeft) && rawSecondsLeft > 0
|
|
947
|
-
? rawSecondsLeft
|
|
948
|
-
: this.loanRenewTimeConfig.loanTotalTime;
|
|
949
|
-
this.lendingStatus = {
|
|
950
|
-
...this.lendingStatus,
|
|
951
|
-
secondsLeftOnLoan: secondsLeft,
|
|
952
|
-
};
|
|
953
|
-
await this.startTimerCountdown();
|
|
954
|
-
await this.startBrowseTimer();
|
|
955
|
-
}
|
|
956
906
|
};
|
|
957
907
|
const errorCallback = eventObj => {
|
|
958
908
|
this.handleLendingActionError(eventObj);
|
|
959
909
|
};
|
|
960
910
|
|
|
961
|
-
// Tear down any previous poller first
|
|
962
|
-
//
|
|
963
|
-
// never cancelled and can fire later against stale state.
|
|
911
|
+
// Tear down any previous poller first, so only one is ever controlling
|
|
912
|
+
// window.IALendingIntervals.tokenPoller at a time.
|
|
964
913
|
this.tokenPoller?.disconnectedCallback();
|
|
965
914
|
this.tokenPoller = new LoanTokenPoller(
|
|
966
915
|
this.identifier,
|
|
@@ -1005,38 +954,28 @@ export default class IABookActions extends LitElement {
|
|
|
1005
954
|
});
|
|
1006
955
|
|
|
1007
956
|
if (action === 'create_token') {
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
// Only the INITIAL token matters enough to interrupt the patron —
|
|
1013
|
-
// a routine interval refresh stays silent and just retries next tick.
|
|
957
|
+
// Only the INITIAL token matters enough to interrupt the patron — a
|
|
958
|
+
// routine interval refresh stays silent; the pages already loaded
|
|
959
|
+
// are still served, and the next scheduled poll tries again on its
|
|
960
|
+
// own without any special retry.
|
|
1014
961
|
if (!isInitial) {
|
|
1015
962
|
log(
|
|
1016
|
-
'[IABookActions] create_token failed on interval refresh — staying silent
|
|
963
|
+
'[IABookActions] create_token failed on interval refresh — staying silent'
|
|
1017
964
|
);
|
|
1018
965
|
return;
|
|
1019
966
|
}
|
|
1020
967
|
|
|
1021
|
-
// The
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
this.
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
this.tokenRecoveryAttempts += 1;
|
|
1033
|
-
if (this.tokenRecoveryAttempts <= this.maxTokenRecoveryAttempts) {
|
|
1034
|
-
this.startLoanTokenPoller();
|
|
1035
|
-
} else {
|
|
1036
|
-
log('[IABookActions] create_token recovery attempts exhausted', {
|
|
1037
|
-
identifier: this.identifier,
|
|
1038
|
-
});
|
|
1039
|
-
}
|
|
968
|
+
// The book never opened, and we don't retry — reset the bar to
|
|
969
|
+
// Borrow and the timer to 0 rather than leaving a red "Return now"
|
|
970
|
+
// with a stale countdown for a session that isn't accessible.
|
|
971
|
+
window?.IALendingIntervals?.clearAll();
|
|
972
|
+
this.tokenPoller?.disconnectedCallback();
|
|
973
|
+
this.lendingStatus = {
|
|
974
|
+
...this.lendingStatus,
|
|
975
|
+
user_has_browsed: false,
|
|
976
|
+
available_to_browse: true,
|
|
977
|
+
secondsLeftOnLoan: 0,
|
|
978
|
+
};
|
|
1040
979
|
|
|
1041
980
|
// showErrorModal has dedicated create_token messaging (refresh
|
|
1042
981
|
// button + support email).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect
|
|
1
|
+
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';
|
|
@@ -57,15 +57,9 @@ describe('Get Loan Token', () => {
|
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
describe('handleTokenError
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// right after a renewal can race that write's propagation and come
|
|
64
|
-
// back with this exact error even though the loan is genuinely valid.
|
|
65
|
-
const staleLoanReadError = {
|
|
66
|
-
error: 'You do not currently have this book borrowed.',
|
|
67
|
-
};
|
|
68
|
-
|
|
60
|
+
describe('handleTokenError (no retry)', () => {
|
|
61
|
+
// A create_token failure is treated as terminal on the first attempt —
|
|
62
|
+
// no retry, whether it's the "stale loan read" race or anything else.
|
|
69
63
|
const makeTokenPoller = () =>
|
|
70
64
|
new LoanTokenPoller(
|
|
71
65
|
'identifier1',
|
|
@@ -75,126 +69,43 @@ describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
|
|
|
75
69
|
2000
|
|
76
70
|
);
|
|
77
71
|
|
|
78
|
-
it('
|
|
72
|
+
it('reports the error immediately, without retrying, on the initial call', () => {
|
|
79
73
|
const tokenPoller = makeTokenPoller();
|
|
80
74
|
const handleLoanTokenPollerSpy = Sinon.spy(
|
|
81
75
|
tokenPoller,
|
|
82
76
|
'handleLoanTokenPoller'
|
|
83
77
|
);
|
|
84
78
|
const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
|
|
85
|
-
const clock = Sinon.useFakeTimers();
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
tokenPoller.handleTokenError(staleLoanReadError, true, 0);
|
|
89
|
-
|
|
90
|
-
expect(errorCallbackSpy.called).to.be.false;
|
|
91
|
-
|
|
92
|
-
clock.tick(tokenPoller.tokenRetryDelay);
|
|
93
|
-
|
|
94
|
-
expect(handleLoanTokenPollerSpy.calledWith(true, 1)).to.be.true;
|
|
95
|
-
} finally {
|
|
96
|
-
clock.restore();
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('gives up and calls errorCallback after exhausting retries', () => {
|
|
101
|
-
const tokenPoller = makeTokenPoller();
|
|
102
|
-
const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
|
|
103
79
|
|
|
104
80
|
tokenPoller.handleTokenError(
|
|
105
|
-
|
|
106
|
-
true
|
|
107
|
-
tokenPoller.maxTokenRetries
|
|
81
|
+
{ error: 'You do not currently have this book borrowed.' },
|
|
82
|
+
true
|
|
108
83
|
);
|
|
109
84
|
|
|
110
85
|
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
86
|
+
expect(handleLoanTokenPollerSpy.called).to.be.false;
|
|
111
87
|
});
|
|
112
88
|
|
|
113
|
-
it('
|
|
114
|
-
// A routine tick landing moments after a renewal hits the very same
|
|
115
|
-
// write-propagation race as the initial call, so it's equally worth
|
|
116
|
-
// retrying. isInitial only decides how a FINAL failure is reported.
|
|
117
|
-
const tokenPoller = makeTokenPoller();
|
|
118
|
-
const handleLoanTokenPollerSpy = Sinon.spy(
|
|
119
|
-
tokenPoller,
|
|
120
|
-
'handleLoanTokenPoller'
|
|
121
|
-
);
|
|
122
|
-
const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
|
|
123
|
-
const clock = Sinon.useFakeTimers();
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
tokenPoller.handleTokenError(staleLoanReadError, false, 0);
|
|
127
|
-
|
|
128
|
-
expect(errorCallbackSpy.called).to.be.false;
|
|
129
|
-
|
|
130
|
-
clock.tick(tokenPoller.tokenRetryDelay);
|
|
131
|
-
|
|
132
|
-
expect(handleLoanTokenPollerSpy.calledWith(false, 1)).to.be.true;
|
|
133
|
-
} finally {
|
|
134
|
-
clock.restore();
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('does not retry a genuinely different error, even on the initial call', () => {
|
|
89
|
+
it('reports the error immediately on a routine (non-initial) poll too', () => {
|
|
139
90
|
const tokenPoller = makeTokenPoller();
|
|
140
91
|
const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
|
|
141
92
|
|
|
142
93
|
tokenPoller.handleTokenError(
|
|
143
|
-
{ error: '
|
|
144
|
-
|
|
145
|
-
0
|
|
94
|
+
{ error: 'You do not currently have this book borrowed.' },
|
|
95
|
+
false
|
|
146
96
|
);
|
|
147
97
|
|
|
148
98
|
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
149
99
|
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('LoanTokenPoller - WEBDEV-8322 review fixes', () => {
|
|
153
|
-
it('retries a stale-loan-read error on interval refreshes too, not just the initial call', async () => {
|
|
154
|
-
const poller = new LoanTokenPoller('foo', 'browsed', () => {}, () => {}, 120);
|
|
155
|
-
poller.disconnectedCallback(); // stop the real poller started in the ctor
|
|
156
|
-
|
|
157
|
-
const retrySpy = Sinon.stub(poller, 'handleLoanTokenPoller');
|
|
158
|
-
poller.tokenRetryDelay = 1;
|
|
159
|
-
|
|
160
|
-
poller.handleTokenError(
|
|
161
|
-
{ error: 'you do not currently have this book borrowed' },
|
|
162
|
-
false, // interval refresh
|
|
163
|
-
0
|
|
164
|
-
);
|
|
165
|
-
await aTimeout(30);
|
|
166
|
-
|
|
167
|
-
expect(retrySpy.calledOnce).to.be.true;
|
|
168
|
-
expect(retrySpy.firstCall.args).to.deep.equal([false, 1]);
|
|
169
|
-
poller.disconnectedCallback();
|
|
170
|
-
});
|
|
171
100
|
|
|
172
101
|
it('passes isInitial through to the error callback', () => {
|
|
173
102
|
const errorCallback = Sinon.spy();
|
|
174
103
|
const poller = new LoanTokenPoller('foo', 'browsed', () => {}, errorCallback, 120);
|
|
175
104
|
poller.disconnectedCallback();
|
|
176
105
|
|
|
177
|
-
poller.handleTokenError({ error: 'something else went wrong' }, true
|
|
106
|
+
poller.handleTokenError({ error: 'something else went wrong' }, true);
|
|
178
107
|
|
|
179
108
|
expect(errorCallback.calledOnce).to.be.true;
|
|
180
109
|
expect(errorCallback.firstCall.args[0].detail.isInitial).to.be.true;
|
|
181
110
|
});
|
|
182
|
-
|
|
183
|
-
it('cancels a pending retry on teardown', async () => {
|
|
184
|
-
const poller = new LoanTokenPoller('foo', 'browsed', () => {}, () => {}, 120);
|
|
185
|
-
const retrySpy = Sinon.stub(poller, 'handleLoanTokenPoller');
|
|
186
|
-
poller.tokenRetryDelay = 20;
|
|
187
|
-
|
|
188
|
-
poller.handleTokenError(
|
|
189
|
-
{ error: 'you do not currently have this book borrowed' },
|
|
190
|
-
true,
|
|
191
|
-
0
|
|
192
|
-
);
|
|
193
|
-
expect(poller.retryTimeout).to.not.be.undefined;
|
|
194
|
-
|
|
195
|
-
poller.disconnectedCallback();
|
|
196
|
-
await aTimeout(60);
|
|
197
|
-
|
|
198
|
-
expect(retrySpy.called).to.be.false;
|
|
199
|
-
});
|
|
200
111
|
});
|
|
@@ -755,35 +755,6 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
755
755
|
expect(el.loanRenewResult.renewNow).to.be.true;
|
|
756
756
|
});
|
|
757
757
|
|
|
758
|
-
it('does not start a new renewal while awaiting create_token recovery from the last one', async () => {
|
|
759
|
-
// loanRenewInProgress clears as soon as renew_loan itself succeeds
|
|
760
|
-
// (see handleLoanAutoRenewed), before create_token is known to work.
|
|
761
|
-
// A visibilitychange/userAction landing in that window must not kick
|
|
762
|
-
// off ANOTHER renewal — that repeats the do_renew() delete+create
|
|
763
|
-
// write for no reason. See WEBDEV-8322 production log, 2026-09-28.
|
|
764
|
-
const el = await fixture(
|
|
765
|
-
container({
|
|
766
|
-
userid: '@user1',
|
|
767
|
-
identifier: 'foobar',
|
|
768
|
-
lendingStatus: {
|
|
769
|
-
user_has_browsed: true,
|
|
770
|
-
browsingExpired: true,
|
|
771
|
-
},
|
|
772
|
-
})
|
|
773
|
-
);
|
|
774
|
-
await el.updateComplete;
|
|
775
|
-
|
|
776
|
-
el.loanRenewInProgress = false;
|
|
777
|
-
el.awaitingTokenRecovery = true;
|
|
778
|
-
|
|
779
|
-
const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
|
|
780
|
-
el.autoRenewExpiredLoan();
|
|
781
|
-
|
|
782
|
-
expect(spy.callCount).to.equal(1);
|
|
783
|
-
expect(el.loanRenewInProgress).to.be.false;
|
|
784
|
-
expect(el.loanRenewResult.renewNow).to.be.false;
|
|
785
|
-
});
|
|
786
|
-
|
|
787
758
|
it('clears loanRenewInProgress after a successful loanAutoRenewed event', async () => {
|
|
788
759
|
const el = await fixture(
|
|
789
760
|
container({
|
|
@@ -1130,14 +1101,11 @@ describe('handleLendingActionError - create_token failures must not stop the cou
|
|
|
1130
1101
|
expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
|
|
1131
1102
|
});
|
|
1132
1103
|
|
|
1133
|
-
it('shows the error modal
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
// that
|
|
1138
|
-
// won't open — so the patron must be told rather than left staring
|
|
1139
|
-
// at nothing. (An interval refresh failing is handled separately and
|
|
1140
|
-
// stays silent; see the WEBDEV-8322 review fixes below.)
|
|
1104
|
+
it('shows the error modal and resets to Borrow/0 on a failed INITIAL create_token (no retry)', async () => {
|
|
1105
|
+
// For the INITIAL token, BookReader never got page images — the book
|
|
1106
|
+
// never opened. We don't retry, so the bar must reflect that honestly
|
|
1107
|
+
// (Borrow, 0 left) instead of staying red with a stale countdown for a
|
|
1108
|
+
// session that isn't actually accessible.
|
|
1141
1109
|
const el = await fixture(
|
|
1142
1110
|
container({
|
|
1143
1111
|
userid: '@user1',
|
|
@@ -1164,24 +1132,16 @@ describe('handleLendingActionError - create_token failures must not stop the cou
|
|
|
1164
1132
|
|
|
1165
1133
|
expect(showErrorModalSpy.calledOnceWith(errorMsg, 'create_token')).to.be
|
|
1166
1134
|
.true;
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
// bail out before ever restarting the token poller, silently
|
|
1171
|
-
// stranding the patron until the countdown force-returned the book.
|
|
1172
|
-
// See WEBDEV-8322 production log, 2026-09-28.
|
|
1173
|
-
expect(el.lendingStatus.user_has_browsed).to.be.true;
|
|
1174
|
-
expect(el.awaitingTokenRecovery).to.be.true;
|
|
1135
|
+
expect(el.lendingStatus.user_has_browsed).to.be.false;
|
|
1136
|
+
expect(el.lendingStatus.available_to_browse).to.be.true;
|
|
1137
|
+
expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
|
|
1175
1138
|
});
|
|
1176
1139
|
|
|
1177
1140
|
it('stops the countdown/expiry timers on a failed INITIAL create_token — nothing to read yet', async () => {
|
|
1178
1141
|
// Unlike the interval-refresh case above (pages already loaded, keep
|
|
1179
|
-
// ticking), an initial create_token failure means BookReader never
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
// session (see WEBDEV-8322 QA log, 2026-09-28) until the loan
|
|
1183
|
-
// eventually force-expires. Stop both timers instead; they resume
|
|
1184
|
-
// once startLoanTokenPoller()'s successCallback confirms recovery.
|
|
1142
|
+
// ticking), an initial create_token failure means BookReader never got
|
|
1143
|
+
// page images and we're resetting the bar to Borrow — both timers
|
|
1144
|
+
// should stop rather than keep counting down a dead session.
|
|
1185
1145
|
const el = await fixture(
|
|
1186
1146
|
container({
|
|
1187
1147
|
userid: '@user1',
|
|
@@ -1582,70 +1542,6 @@ describe('WEBDEV-8322 review fixes', () => {
|
|
|
1582
1542
|
expect(el.lendingStatus.user_has_browsed).to.be.true;
|
|
1583
1543
|
});
|
|
1584
1544
|
|
|
1585
|
-
it('surfaces a failed initial create_token, which blocks opening the book', async () => {
|
|
1586
|
-
const el = await fixture(
|
|
1587
|
-
container({
|
|
1588
|
-
userid: '@user1',
|
|
1589
|
-
identifier: 'token-initial',
|
|
1590
|
-
lendingStatus: {
|
|
1591
|
-
is_lendable: true,
|
|
1592
|
-
available_to_browse: true,
|
|
1593
|
-
user_has_browsed: true,
|
|
1594
|
-
browsingExpired: false,
|
|
1595
|
-
secondsLeftOnLoan: 300,
|
|
1596
|
-
},
|
|
1597
|
-
})
|
|
1598
|
-
);
|
|
1599
|
-
await el.updateComplete;
|
|
1600
|
-
const spy = Sinon.spy(el, 'showErrorModal');
|
|
1601
|
-
|
|
1602
|
-
el.handleLendingActionError({
|
|
1603
|
-
detail: {
|
|
1604
|
-
action: 'create_token',
|
|
1605
|
-
isInitial: true,
|
|
1606
|
-
data: { error: 'loan token not found. please try again later.' },
|
|
1607
|
-
},
|
|
1608
|
-
});
|
|
1609
|
-
await el.updateComplete;
|
|
1610
|
-
|
|
1611
|
-
expect(spy.calledOnce).to.be.true;
|
|
1612
|
-
// Still reading — see the identical assertion above for why this must
|
|
1613
|
-
// stay true (WEBDEV-8322 production log, 2026-09-28).
|
|
1614
|
-
expect(el.lendingStatus.user_has_browsed).to.be.true;
|
|
1615
|
-
expect(el.awaitingTokenRecovery).to.be.true;
|
|
1616
|
-
});
|
|
1617
|
-
|
|
1618
|
-
it('does not resume the countdown via visibilitychange while awaiting create_token recovery', async () => {
|
|
1619
|
-
// Regression: visibilitychange's resync path didn't check
|
|
1620
|
-
// awaitingTokenRecovery, so backgrounding/foregrounding the tab during
|
|
1621
|
-
// a create_token outage could resume the timers that
|
|
1622
|
-
// handleLendingActionError had deliberately stopped, and auto-return a
|
|
1623
|
-
// loan the server still considers valid.
|
|
1624
|
-
const el = await fixture(
|
|
1625
|
-
container({
|
|
1626
|
-
userid: '@user1',
|
|
1627
|
-
identifier: 'foobar',
|
|
1628
|
-
lendingStatus: {
|
|
1629
|
-
user_has_browsed: true,
|
|
1630
|
-
browsingExpired: false,
|
|
1631
|
-
secondsLeftOnLoan: 300,
|
|
1632
|
-
},
|
|
1633
|
-
})
|
|
1634
|
-
);
|
|
1635
|
-
await el.updateComplete;
|
|
1636
|
-
el.awaitingTokenRecovery = true;
|
|
1637
|
-
|
|
1638
|
-
const spy = Sinon.spy(el, 'loanStatusCheckInterval');
|
|
1639
|
-
Object.defineProperty(document, 'hidden', {
|
|
1640
|
-
value: false,
|
|
1641
|
-
configurable: true,
|
|
1642
|
-
});
|
|
1643
|
-
document.dispatchEvent(new Event('visibilitychange'));
|
|
1644
|
-
await aTimeout(100);
|
|
1645
|
-
|
|
1646
|
-
expect(spy.called).to.be.false;
|
|
1647
|
-
});
|
|
1648
|
-
|
|
1649
1545
|
it('tears down the previous poller before starting a new one', async () => {
|
|
1650
1546
|
// Regression: startLoanTokenPoller() always replaced this.tokenPoller
|
|
1651
1547
|
// without cancelling the old instance's pending internal retry timer,
|
|
@@ -1675,9 +1571,7 @@ describe('WEBDEV-8322 review fixes', () => {
|
|
|
1675
1571
|
expect(el.tokenPoller).to.not.equal(firstPoller);
|
|
1676
1572
|
});
|
|
1677
1573
|
|
|
1678
|
-
it('
|
|
1679
|
-
// Regression: an outer restart loop with no cap meant a genuinely-lost
|
|
1680
|
-
// (not just slow-to-propagate) loan retried forever.
|
|
1574
|
+
it('never restarts the poller on a create_token failure — no retry', async () => {
|
|
1681
1575
|
const el = await fixture(
|
|
1682
1576
|
container({
|
|
1683
1577
|
userid: '@user1',
|
|
@@ -1694,19 +1588,15 @@ describe('WEBDEV-8322 review fixes', () => {
|
|
|
1694
1588
|
await el.updateComplete;
|
|
1695
1589
|
|
|
1696
1590
|
const startSpy = Sinon.spy(el, 'startLoanTokenPoller');
|
|
1697
|
-
const errorMsg = 'loan token not found. please try again later.';
|
|
1698
1591
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
});
|
|
1707
|
-
}
|
|
1592
|
+
el.handleLendingActionError({
|
|
1593
|
+
detail: {
|
|
1594
|
+
action: 'create_token',
|
|
1595
|
+
isInitial: true,
|
|
1596
|
+
data: { error: 'loan token not found. please try again later.' },
|
|
1597
|
+
},
|
|
1598
|
+
});
|
|
1708
1599
|
|
|
1709
|
-
expect(startSpy.
|
|
1710
|
-
expect(el.tokenRecoveryAttempts).to.equal(el.maxTokenRecoveryAttempts + 1);
|
|
1600
|
+
expect(startSpy.called).to.be.false;
|
|
1711
1601
|
});
|
|
1712
1602
|
});
|