@internetarchive/ia-book-actions 1.4.4-alpha14 → 1.4.4-alpha16
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 -7
- package/src/core/services/loan-token-poller.js +42 -69
- package/src/ia-book-actions.js +73 -105
- package/test/core/services/loan-token-poller.test.js +74 -120
- package/test/ia-book-actions.test.js +121 -134
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,
|
|
@@ -109,8 +117,5 @@ export default async function ActionsHandlerService(options) {
|
|
|
109
117
|
* connection is exactly when this fires.
|
|
110
118
|
*/
|
|
111
119
|
log(`[IABookActions] ✗ ${option.action} threw`, error);
|
|
112
|
-
option?.error({
|
|
113
|
-
error: `Could not reach the lending service. Please check your connection and try again. (${error})`,
|
|
114
|
-
});
|
|
115
120
|
}
|
|
116
121
|
}
|
|
@@ -10,35 +10,42 @@ import log from './log.js';
|
|
|
10
10
|
* ActionsHandlerService is a function being used to execute
|
|
11
11
|
*/
|
|
12
12
|
export class LoanTokenPoller {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {string} options.identifier
|
|
16
|
+
* @param {string} options.borrowType
|
|
17
|
+
* @param {Function} options.successCallback - called after the initial
|
|
18
|
+
* loan token is created.
|
|
19
|
+
* @param {Function} options.errorCallback - called on any create_token
|
|
20
|
+
* failure (initial or a routine interval tick).
|
|
21
|
+
* @param {number} options.pollerDelay - interval between routine
|
|
22
|
+
* create_token checks, in seconds.
|
|
23
|
+
* @param {boolean} [options.skipInitialCall] - skip the immediate
|
|
24
|
+
* create_token call, only start the recurring interval. Used after a
|
|
25
|
+
* loan-expiry recovery renewal, where BookLoanService::
|
|
26
|
+
* attempt_to_renew_loan() already minted a valid access token as part
|
|
27
|
+
* of the renew_loan response itself — an immediate confirming
|
|
28
|
+
* create_token call is redundant.
|
|
29
|
+
*/
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
const {
|
|
32
|
+
identifier,
|
|
33
|
+
borrowType,
|
|
34
|
+
successCallback,
|
|
35
|
+
errorCallback,
|
|
36
|
+
pollerDelay,
|
|
37
|
+
skipInitialCall = false,
|
|
38
|
+
} = options;
|
|
39
|
+
|
|
40
|
+
this.identifier = identifier;
|
|
15
41
|
this.borrowType = borrowType;
|
|
16
42
|
this.successCallback = successCallback; // callback function to be called after loan token is created
|
|
17
43
|
this.errorCallback = errorCallback; // callback function to be called after loan token is created
|
|
18
44
|
this.pollerDelay = pollerDelay; // value in seconds
|
|
45
|
+
this.skipInitialCall = skipInitialCall === true;
|
|
19
46
|
|
|
20
47
|
this.loanTokenInterval = undefined;
|
|
21
48
|
|
|
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
49
|
/**
|
|
43
50
|
* loan analytics instance
|
|
44
51
|
* @see loan-analytics.js
|
|
@@ -50,14 +57,18 @@ export class LoanTokenPoller {
|
|
|
50
57
|
|
|
51
58
|
disconnectedCallback() {
|
|
52
59
|
window?.IALendingIntervals?.clearTokenPoller();
|
|
53
|
-
clearTimeout(this.retryTimeout);
|
|
54
|
-
this.retryTimeout = undefined;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
async bookAccessed() {
|
|
58
63
|
if (this.borrowType) {
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
if (this.skipInitialCall) {
|
|
65
|
+
log('[LoanTokenPoller] skipping initial create_token — already minted by the renewal response', {
|
|
66
|
+
identifier: this.identifier,
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
// Do an initial token, then set an interval
|
|
70
|
+
this.handleLoanTokenPoller(true);
|
|
71
|
+
}
|
|
61
72
|
|
|
62
73
|
// if this.borrowType = adminBorrowed,
|
|
63
74
|
// - we don't want to fetch token on interval
|
|
@@ -84,24 +95,21 @@ export class LoanTokenPoller {
|
|
|
84
95
|
/**
|
|
85
96
|
* @param {boolean} isInitial - the first create_token call right after
|
|
86
97
|
* bookAccessed()/a renewal, as opposed to a routine interval tick.
|
|
87
|
-
* @param {number} retryCount - internal, counts retries of the initial call.
|
|
88
98
|
*/
|
|
89
|
-
async handleLoanTokenPoller(isInitial = false
|
|
99
|
+
async handleLoanTokenPoller(isInitial = false) {
|
|
90
100
|
const action = 'create_token';
|
|
91
101
|
log('[LoanTokenPoller] create_token requested', {
|
|
92
102
|
identifier: this.identifier,
|
|
93
103
|
isInitial,
|
|
94
|
-
retryCount,
|
|
95
104
|
});
|
|
96
105
|
ActionsHandlerService({
|
|
97
106
|
identifier: this.identifier,
|
|
98
107
|
action,
|
|
99
|
-
error: data => this.handleTokenError(data, isInitial
|
|
108
|
+
error: data => this.handleTokenError(data, isInitial),
|
|
100
109
|
success: () => {
|
|
101
110
|
log('[LoanTokenPoller] create_token succeeded', {
|
|
102
111
|
identifier: this.identifier,
|
|
103
112
|
isInitial,
|
|
104
|
-
retryCount,
|
|
105
113
|
});
|
|
106
114
|
if (isInitial) this.successCallback();
|
|
107
115
|
},
|
|
@@ -109,58 +117,23 @@ export class LoanTokenPoller {
|
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* comment) or reported via errorCallback. Split out from
|
|
120
|
+
* Reports a create_token failure via errorCallback — no retry, it's
|
|
121
|
+
* treated as terminal on the first failure. Split out from
|
|
115
122
|
* handleLoanTokenPoller so it's directly testable without needing to
|
|
116
123
|
* mock the network call.
|
|
117
124
|
*
|
|
118
125
|
* @param {Object} data - the error payload from ActionsHandlerService.
|
|
119
126
|
* @param {boolean} isInitial
|
|
120
|
-
* @param {number} retryCount
|
|
121
127
|
*/
|
|
122
|
-
handleTokenError(data, isInitial
|
|
128
|
+
handleTokenError(data, isInitial) {
|
|
123
129
|
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
130
|
|
|
128
131
|
log('[LoanTokenPoller] create_token failed', {
|
|
129
132
|
identifier: this.identifier,
|
|
130
133
|
isInitial,
|
|
131
|
-
retryCount,
|
|
132
|
-
isStaleLoanReadError,
|
|
133
134
|
error: data?.error,
|
|
134
135
|
});
|
|
135
136
|
|
|
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
137
|
// isInitial rides along so the consumer can distinguish "the book won't
|
|
165
138
|
// open at all" from "a mid-read refresh blipped" — see
|
|
166
139
|
// 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.
|
|
@@ -116,6 +104,15 @@ export default class IABookActions extends LitElement {
|
|
|
116
104
|
*/
|
|
117
105
|
this.recoveringFromLoanExpiry = false;
|
|
118
106
|
|
|
107
|
+
/**
|
|
108
|
+
* True right after a confirmed renewal (any kind), until the token
|
|
109
|
+
* poller restarts. renew_loan's own response already carries a valid
|
|
110
|
+
* access token, so that restart should skip its normal immediate
|
|
111
|
+
* create_token call — see handleLoanAutoRenewed/
|
|
112
|
+
* setupLendingToolbarActions.
|
|
113
|
+
*/
|
|
114
|
+
this.skipNextInitialTokenCall = false;
|
|
115
|
+
|
|
119
116
|
this.warningModalOpen = false;
|
|
120
117
|
|
|
121
118
|
/** Once dismissed, don't re-show the warning modal every tick — only
|
|
@@ -301,14 +298,17 @@ export default class IABookActions extends LitElement {
|
|
|
301
298
|
|
|
302
299
|
if (!this.applyLendingActions()) return;
|
|
303
300
|
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
301
|
+
// primaryActions/borrowType/etc are plain fields, not reactive
|
|
302
|
+
// properties, so nothing pushes them to the rendered template (and
|
|
303
|
+
// from there to <collapsible-action-group>) on its own. Do that now,
|
|
304
|
+
// unconditionally — the early returns below (not borrowed, title bar)
|
|
305
|
+
// must not skip it, or the action bar visibly goes stale (e.g. a
|
|
306
|
+
// create_token failure resetting to Borrow never actually shows it).
|
|
307
|
+
this.requestUpdate();
|
|
308
|
+
|
|
309
|
+
// Don't (re)start the countdown mid-renewal — secondsLeftOnLoan could
|
|
310
|
+
// still be stale until handleLoanAutoRenewed() confirms it.
|
|
311
|
+
if (this.borrowType === 'browsed' && !this.loanRenewInProgress) {
|
|
312
312
|
await this.startTimerCountdown();
|
|
313
313
|
await this.startBrowseTimer();
|
|
314
314
|
}
|
|
@@ -327,12 +327,16 @@ export default class IABookActions extends LitElement {
|
|
|
327
327
|
!this.loanRenewInProgress &&
|
|
328
328
|
!window.IALendingIntervals.tokenPoller
|
|
329
329
|
) {
|
|
330
|
+
// Any confirmed renewal's response already minted a valid access
|
|
331
|
+
// token (see handleLoanAutoRenewed) — the poller only needs to
|
|
332
|
+
// start its recurring check, not fire an immediate confirming
|
|
333
|
+
// create_token call too.
|
|
334
|
+
const skipInitialCall = this.skipNextInitialTokenCall;
|
|
335
|
+
this.skipNextInitialTokenCall = false;
|
|
330
336
|
this.recoveringFromLoanExpiry = false;
|
|
331
|
-
this.startLoanTokenPoller();
|
|
337
|
+
this.startLoanTokenPoller(skipInitialCall);
|
|
332
338
|
}
|
|
333
339
|
}, 100);
|
|
334
|
-
|
|
335
|
-
this.requestUpdate();
|
|
336
340
|
}
|
|
337
341
|
|
|
338
342
|
/**
|
|
@@ -405,10 +409,6 @@ export default class IABookActions extends LitElement {
|
|
|
405
409
|
|
|
406
410
|
if (this.borrowType !== 'browsed') return;
|
|
407
411
|
|
|
408
|
-
// Timers are deliberately stopped during create_token recovery
|
|
409
|
-
// (see handleLendingActionError) — leave them alone here.
|
|
410
|
-
if (this.awaitingTokenRecovery) return;
|
|
411
|
-
|
|
412
412
|
if (this.lendingStatus.browsingExpired === false) {
|
|
413
413
|
const loanTime = await this.localCache.get(
|
|
414
414
|
`${this.identifier}-loanTime`
|
|
@@ -442,11 +442,9 @@ export default class IABookActions extends LitElement {
|
|
|
442
442
|
* @param {Boolean} hasPageChanged
|
|
443
443
|
*/
|
|
444
444
|
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;
|
|
445
|
+
// Re-entrancy guard against rapid BookReader:userAction events (e.g. a
|
|
446
|
+
// single scroll firing several in quick succession).
|
|
447
|
+
if (this.loanRenewInProgress) return;
|
|
450
448
|
|
|
451
449
|
this.loanRenewHelper = new LoanRenewHelper(
|
|
452
450
|
hasPageChanged,
|
|
@@ -465,14 +463,10 @@ export default class IABookActions extends LitElement {
|
|
|
465
463
|
* handleLendingActionError() handle the outcome either way.
|
|
466
464
|
*/
|
|
467
465
|
autoRenewExpiredLoan() {
|
|
468
|
-
if (this.loanRenewInProgress
|
|
466
|
+
if (this.loanRenewInProgress) {
|
|
469
467
|
log(
|
|
470
468
|
'[IABookActions] autoRenewExpiredLoan: skipped, renewal already in progress',
|
|
471
|
-
{
|
|
472
|
-
identifier: this.identifier,
|
|
473
|
-
loanRenewInProgress: this.loanRenewInProgress,
|
|
474
|
-
awaitingTokenRecovery: this.awaitingTokenRecovery,
|
|
475
|
-
}
|
|
469
|
+
{ identifier: this.identifier }
|
|
476
470
|
);
|
|
477
471
|
return;
|
|
478
472
|
}
|
|
@@ -761,10 +755,22 @@ export default class IABookActions extends LitElement {
|
|
|
761
755
|
ajaxResponse: detail?.data,
|
|
762
756
|
});
|
|
763
757
|
|
|
758
|
+
// BookLoanService::attempt_to_renew_loan() mints a valid access
|
|
759
|
+
// token as part of EVERY renew_loan response, not just a recovery
|
|
760
|
+
// from expiry — so the poller restart below never needs to fire a
|
|
761
|
+
// redundant confirming create_token call, regardless of which path
|
|
762
|
+
// triggered this renewal.
|
|
763
|
+
this.skipNextInitialTokenCall = true;
|
|
764
|
+
|
|
764
765
|
if (this.recoveringFromLoanExpiry) {
|
|
765
766
|
// Only now is it safe to re-initialize BookReader — not for a
|
|
766
|
-
// routine top-up, where that would be disruptive.
|
|
767
|
-
|
|
767
|
+
// routine top-up, where that would be disruptive. Call it
|
|
768
|
+
// directly rather than waiting on a confirming create_token
|
|
769
|
+
// success: BookLoanService::attempt_to_renew_loan() already
|
|
770
|
+
// minted a valid access token as part of this renew_loan
|
|
771
|
+
// response, so there's nothing left to confirm.
|
|
772
|
+
this.lendingBarPostInit();
|
|
773
|
+
this.postInitComplete = true;
|
|
768
774
|
}
|
|
769
775
|
|
|
770
776
|
const currStatus = {
|
|
@@ -916,59 +922,31 @@ export default class IABookActions extends LitElement {
|
|
|
916
922
|
|
|
917
923
|
/**
|
|
918
924
|
* enable access of borrowed/browsed books
|
|
925
|
+
* @param {boolean} [skipInitialCall] - see LoanTokenPoller's constructor doc
|
|
919
926
|
* @see LoanTokenPoller
|
|
920
927
|
*/
|
|
921
|
-
startLoanTokenPoller() {
|
|
922
|
-
const successCallback =
|
|
928
|
+
startLoanTokenPoller(skipInitialCall = false) {
|
|
929
|
+
const successCallback = () => {
|
|
923
930
|
if (!this.postInitComplete) {
|
|
924
931
|
this.lendingBarPostInit();
|
|
925
932
|
}
|
|
926
933
|
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
934
|
};
|
|
957
935
|
const errorCallback = eventObj => {
|
|
958
936
|
this.handleLendingActionError(eventObj);
|
|
959
937
|
};
|
|
960
938
|
|
|
961
|
-
// Tear down any previous poller first
|
|
962
|
-
//
|
|
963
|
-
// never cancelled and can fire later against stale state.
|
|
939
|
+
// Tear down any previous poller first, so only one is ever controlling
|
|
940
|
+
// window.IALendingIntervals.tokenPoller at a time.
|
|
964
941
|
this.tokenPoller?.disconnectedCallback();
|
|
965
|
-
this.tokenPoller = new LoanTokenPoller(
|
|
966
|
-
this.identifier,
|
|
967
|
-
this.borrowType,
|
|
942
|
+
this.tokenPoller = new LoanTokenPoller({
|
|
943
|
+
identifier: this.identifier,
|
|
944
|
+
borrowType: this.borrowType,
|
|
968
945
|
successCallback,
|
|
969
946
|
errorCallback,
|
|
970
|
-
this.tokenDelay // in seconds
|
|
971
|
-
|
|
947
|
+
pollerDelay: this.tokenDelay, // in seconds
|
|
948
|
+
skipInitialCall,
|
|
949
|
+
});
|
|
972
950
|
}
|
|
973
951
|
|
|
974
952
|
/*
|
|
@@ -1005,38 +983,28 @@ export default class IABookActions extends LitElement {
|
|
|
1005
983
|
});
|
|
1006
984
|
|
|
1007
985
|
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.
|
|
986
|
+
// Only the INITIAL token matters enough to interrupt the patron — a
|
|
987
|
+
// routine interval refresh stays silent; the pages already loaded
|
|
988
|
+
// are still served, and the next scheduled poll tries again on its
|
|
989
|
+
// own without any special retry.
|
|
1014
990
|
if (!isInitial) {
|
|
1015
991
|
log(
|
|
1016
|
-
'[IABookActions] create_token failed on interval refresh — staying silent
|
|
992
|
+
'[IABookActions] create_token failed on interval refresh — staying silent'
|
|
1017
993
|
);
|
|
1018
994
|
return;
|
|
1019
995
|
}
|
|
1020
996
|
|
|
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
|
-
}
|
|
997
|
+
// The book never opened, and we don't retry — reset the bar to
|
|
998
|
+
// Borrow and the timer to 0 rather than leaving a red "Return now"
|
|
999
|
+
// with a stale countdown for a session that isn't accessible.
|
|
1000
|
+
window?.IALendingIntervals?.clearAll();
|
|
1001
|
+
this.tokenPoller?.disconnectedCallback();
|
|
1002
|
+
this.lendingStatus = {
|
|
1003
|
+
...this.lendingStatus,
|
|
1004
|
+
user_has_browsed: false,
|
|
1005
|
+
available_to_browse: true,
|
|
1006
|
+
secondsLeftOnLoan: 0,
|
|
1007
|
+
};
|
|
1040
1008
|
|
|
1041
1009
|
// showErrorModal has dedicated create_token messaging (refresh
|
|
1042
1010
|
// 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';
|
|
@@ -17,18 +17,17 @@ afterEach(() => {
|
|
|
17
17
|
|
|
18
18
|
describe('Get Loan Token', () => {
|
|
19
19
|
it('get loan token for browsed books', async () => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'
|
|
23
|
-
|
|
24
|
-
() => {
|
|
20
|
+
const tokenPoller = new LoanTokenPoller({
|
|
21
|
+
identifier: 'identifier1',
|
|
22
|
+
borrowType: 'browsed',
|
|
23
|
+
successCallback: () => {
|
|
25
24
|
console.log('success callback is executed!');
|
|
26
25
|
},
|
|
27
|
-
() => {
|
|
26
|
+
errorCallback: () => {
|
|
28
27
|
console.log('error callback is executed!');
|
|
29
28
|
},
|
|
30
|
-
2000 // 2 minutes'
|
|
31
|
-
);
|
|
29
|
+
pollerDelay: 2000, // 2 minutes'
|
|
30
|
+
});
|
|
32
31
|
const successCallbackSpy = Sinon.stub(tokenPoller, 'successCallback');
|
|
33
32
|
successCallbackSpy();
|
|
34
33
|
expect(successCallbackSpy.callCount).to.equal(1);
|
|
@@ -39,13 +38,13 @@ describe('Get Loan Token', () => {
|
|
|
39
38
|
});
|
|
40
39
|
|
|
41
40
|
it('get loan token for admin borrowed books', async () => {
|
|
42
|
-
const tokenPoller = new LoanTokenPoller(
|
|
43
|
-
'identifier1',
|
|
44
|
-
'adminBorrowed',
|
|
45
|
-
() => {},
|
|
46
|
-
() => {},
|
|
47
|
-
2000 // 2 minutes'
|
|
48
|
-
);
|
|
41
|
+
const tokenPoller = new LoanTokenPoller({
|
|
42
|
+
identifier: 'identifier1',
|
|
43
|
+
borrowType: 'adminBorrowed',
|
|
44
|
+
successCallback: () => {},
|
|
45
|
+
errorCallback: () => {},
|
|
46
|
+
pollerDelay: 2000, // 2 minutes'
|
|
47
|
+
});
|
|
49
48
|
|
|
50
49
|
const successCallbackSpy = Sinon.stub(tokenPoller, 'successCallback');
|
|
51
50
|
successCallbackSpy();
|
|
@@ -57,144 +56,99 @@ describe('Get Loan Token', () => {
|
|
|
57
56
|
});
|
|
58
57
|
});
|
|
59
58
|
|
|
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
|
-
|
|
59
|
+
describe('handleTokenError (no retry)', () => {
|
|
60
|
+
// A create_token failure is treated as terminal on the first attempt —
|
|
61
|
+
// no retry, whether it's the "stale loan read" race or anything else.
|
|
69
62
|
const makeTokenPoller = () =>
|
|
70
|
-
new LoanTokenPoller(
|
|
71
|
-
'identifier1',
|
|
72
|
-
'browsed',
|
|
73
|
-
() => {},
|
|
74
|
-
() => {},
|
|
75
|
-
2000
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
it('
|
|
63
|
+
new LoanTokenPoller({
|
|
64
|
+
identifier: 'identifier1',
|
|
65
|
+
borrowType: 'browsed',
|
|
66
|
+
successCallback: () => {},
|
|
67
|
+
errorCallback: () => {},
|
|
68
|
+
pollerDelay: 2000,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports the error immediately, without retrying, on the initial call', () => {
|
|
79
72
|
const tokenPoller = makeTokenPoller();
|
|
80
73
|
const handleLoanTokenPollerSpy = Sinon.spy(
|
|
81
74
|
tokenPoller,
|
|
82
75
|
'handleLoanTokenPoller'
|
|
83
76
|
);
|
|
84
77
|
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
78
|
|
|
104
79
|
tokenPoller.handleTokenError(
|
|
105
|
-
|
|
106
|
-
true
|
|
107
|
-
tokenPoller.maxTokenRetries
|
|
80
|
+
{ error: 'You do not currently have this book borrowed.' },
|
|
81
|
+
true
|
|
108
82
|
);
|
|
109
83
|
|
|
110
84
|
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
85
|
+
expect(handleLoanTokenPollerSpy.called).to.be.false;
|
|
111
86
|
});
|
|
112
87
|
|
|
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', () => {
|
|
88
|
+
it('reports the error immediately on a routine (non-initial) poll too', () => {
|
|
139
89
|
const tokenPoller = makeTokenPoller();
|
|
140
90
|
const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
|
|
141
91
|
|
|
142
92
|
tokenPoller.handleTokenError(
|
|
143
|
-
{ error: '
|
|
144
|
-
|
|
145
|
-
0
|
|
93
|
+
{ error: 'You do not currently have this book borrowed.' },
|
|
94
|
+
false
|
|
146
95
|
);
|
|
147
96
|
|
|
148
97
|
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
149
98
|
});
|
|
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
99
|
|
|
172
100
|
it('passes isInitial through to the error callback', () => {
|
|
173
101
|
const errorCallback = Sinon.spy();
|
|
174
|
-
const poller = new LoanTokenPoller(
|
|
102
|
+
const poller = new LoanTokenPoller({
|
|
103
|
+
identifier: 'foo',
|
|
104
|
+
borrowType: 'browsed',
|
|
105
|
+
successCallback: () => {},
|
|
106
|
+
errorCallback,
|
|
107
|
+
pollerDelay: 120,
|
|
108
|
+
});
|
|
175
109
|
poller.disconnectedCallback();
|
|
176
110
|
|
|
177
|
-
poller.handleTokenError({ error: 'something else went wrong' }, true
|
|
111
|
+
poller.handleTokenError({ error: 'something else went wrong' }, true);
|
|
178
112
|
|
|
179
113
|
expect(errorCallback.calledOnce).to.be.true;
|
|
180
114
|
expect(errorCallback.firstCall.args[0].detail.isInitial).to.be.true;
|
|
181
115
|
});
|
|
116
|
+
});
|
|
182
117
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
118
|
+
describe('skipInitialCall option', () => {
|
|
119
|
+
it('starts the recurring interval but fires no immediate create_token call', () => {
|
|
120
|
+
const poller = new LoanTokenPoller({
|
|
121
|
+
identifier: 'foo',
|
|
122
|
+
borrowType: 'browsed',
|
|
123
|
+
successCallback: () => {},
|
|
124
|
+
errorCallback: () => {},
|
|
125
|
+
pollerDelay: 120,
|
|
126
|
+
skipInitialCall: true,
|
|
127
|
+
});
|
|
128
|
+
const handleLoanTokenPollerSpy = Sinon.spy(poller, 'handleLoanTokenPoller');
|
|
129
|
+
|
|
130
|
+
// bookAccessed() already ran in the constructor above (before the spy
|
|
131
|
+
// was attached) -- re-run it to observe whether it calls out.
|
|
132
|
+
poller.bookAccessed();
|
|
133
|
+
|
|
134
|
+
expect(handleLoanTokenPollerSpy.called).to.be.false;
|
|
135
|
+
expect(window.IALendingIntervals.tokenPoller).to.not.equal(0);
|
|
136
|
+
poller.disconnectedCallback();
|
|
137
|
+
});
|
|
187
138
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
139
|
+
it('fires the immediate call as usual when skipInitialCall is not set', () => {
|
|
140
|
+
const poller = new LoanTokenPoller({
|
|
141
|
+
identifier: 'foo',
|
|
142
|
+
borrowType: 'browsed',
|
|
143
|
+
successCallback: () => {},
|
|
144
|
+
errorCallback: () => {},
|
|
145
|
+
pollerDelay: 120,
|
|
146
|
+
});
|
|
147
|
+
const handleLoanTokenPollerSpy = Sinon.spy(poller, 'handleLoanTokenPoller');
|
|
194
148
|
|
|
195
|
-
poller.
|
|
196
|
-
await aTimeout(60);
|
|
149
|
+
poller.bookAccessed();
|
|
197
150
|
|
|
198
|
-
expect(
|
|
151
|
+
expect(handleLoanTokenPollerSpy.calledWith(true)).to.be.true;
|
|
152
|
+
poller.disconnectedCallback();
|
|
199
153
|
});
|
|
200
154
|
});
|
|
@@ -479,8 +479,7 @@ describe('Browsing expired status', () => {
|
|
|
479
479
|
});
|
|
480
480
|
});
|
|
481
481
|
|
|
482
|
-
describe('Auto renew one hour loan',
|
|
483
|
-
await aTimeout(5000);
|
|
482
|
+
describe('Auto renew one hour loan', () => {
|
|
484
483
|
it('Book is browsing and renewed it now', async () => {
|
|
485
484
|
const loanTime = 88;
|
|
486
485
|
const el = await fixture(
|
|
@@ -553,8 +552,7 @@ describe('Auto renew one hour loan', async () => {
|
|
|
553
552
|
});
|
|
554
553
|
});
|
|
555
554
|
|
|
556
|
-
describe('Visibility change API for document',
|
|
557
|
-
await aTimeout(5000);
|
|
555
|
+
describe('Visibility change API for document', () => {
|
|
558
556
|
|
|
559
557
|
beforeEach(() => {
|
|
560
558
|
Object.defineProperty(document, 'hidden', {
|
|
@@ -578,6 +576,10 @@ describe('Visibility change API for document', async () => {
|
|
|
578
576
|
lendingStatus: {
|
|
579
577
|
user_has_browsed: true,
|
|
580
578
|
browsingExpired: false,
|
|
579
|
+
// Without this, startBrowseTimer() computes setTimeout(fn,
|
|
580
|
+
// undefined * 1000) -> NaN, which fires almost immediately and
|
|
581
|
+
// expires the loan before this test ever dispatches its event.
|
|
582
|
+
secondsLeftOnLoan: 3600,
|
|
581
583
|
},
|
|
582
584
|
})
|
|
583
585
|
);
|
|
@@ -605,6 +607,7 @@ describe('Visibility change API for document', async () => {
|
|
|
605
607
|
lendingStatus: {
|
|
606
608
|
user_has_browsed: true,
|
|
607
609
|
browsingExpired: false,
|
|
610
|
+
secondsLeftOnLoan: 3600,
|
|
608
611
|
},
|
|
609
612
|
})
|
|
610
613
|
);
|
|
@@ -755,35 +758,6 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
755
758
|
expect(el.loanRenewResult.renewNow).to.be.true;
|
|
756
759
|
});
|
|
757
760
|
|
|
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
761
|
it('clears loanRenewInProgress after a successful loanAutoRenewed event', async () => {
|
|
788
762
|
const el = await fixture(
|
|
789
763
|
container({
|
|
@@ -969,6 +943,100 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
969
943
|
expect(el.lendingBarPostInit.called).to.be.false;
|
|
970
944
|
});
|
|
971
945
|
|
|
946
|
+
it('also skips the initial create_token after a routine (non-expiry) renewal', async () => {
|
|
947
|
+
// BookLoanService::attempt_to_renew_loan() mints the access token as
|
|
948
|
+
// part of EVERY renew_loan response, not just a recovery from expiry.
|
|
949
|
+
// A routine top-up also has its poller stopped mid-renewal (see
|
|
950
|
+
// updated()'s clearAll() on loanRenewResult.renewNow), so it would
|
|
951
|
+
// otherwise fire this same redundant call once the poller restarts.
|
|
952
|
+
const el = await fixture(
|
|
953
|
+
container({
|
|
954
|
+
userid: '@user1',
|
|
955
|
+
identifier: 'foobar',
|
|
956
|
+
lendingStatus: { user_has_browsed: true, browsingExpired: false },
|
|
957
|
+
})
|
|
958
|
+
);
|
|
959
|
+
await el.updateComplete;
|
|
960
|
+
el.postInitComplete = true;
|
|
961
|
+
|
|
962
|
+
await el.localCache.set({
|
|
963
|
+
key: 'foobar-loanTime',
|
|
964
|
+
value: new Date(new Date().getTime() + 3600 * 1000),
|
|
965
|
+
ttl: 3600,
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
const startTokenPollerSpy = Sinon.spy(el, 'startLoanTokenPoller');
|
|
969
|
+
|
|
970
|
+
// Triggers updated()'s clearAll(), stopping the running poller, same
|
|
971
|
+
// as a real routine top-up would.
|
|
972
|
+
el.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
|
|
973
|
+
await el.updateComplete;
|
|
974
|
+
|
|
975
|
+
const collapsibleActionGroupEl = el.shadowRoot.querySelector(
|
|
976
|
+
'collapsible-action-group'
|
|
977
|
+
);
|
|
978
|
+
collapsibleActionGroupEl.dispatchEvent(
|
|
979
|
+
new CustomEvent('loanAutoRenewed', {
|
|
980
|
+
detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
|
|
981
|
+
})
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
await aTimeout(200);
|
|
985
|
+
await el.updateComplete;
|
|
986
|
+
|
|
987
|
+
expect(startTokenPollerSpy.calledOnceWith(true)).to.be.true;
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it('calls lendingBarPostInit directly on a recovery renewal and skips the initial create_token', async () => {
|
|
991
|
+
// BookLoanService::attempt_to_renew_loan() now mints the access token
|
|
992
|
+
// as part of the renew_loan response itself (using the just-written
|
|
993
|
+
// loan record already in memory, not a separate read) -- so recovering
|
|
994
|
+
// from a genuine expiry no longer needs to wait on a confirming
|
|
995
|
+
// create_token success before re-initializing BookReader, and the
|
|
996
|
+
// poller it starts afterward shouldn't fire a redundant immediate call.
|
|
997
|
+
const el = await fixture(
|
|
998
|
+
container({
|
|
999
|
+
userid: '@user1',
|
|
1000
|
+
identifier: 'foobar',
|
|
1001
|
+
lendingStatus: {
|
|
1002
|
+
user_has_browsed: true,
|
|
1003
|
+
browsingExpired: true,
|
|
1004
|
+
},
|
|
1005
|
+
})
|
|
1006
|
+
);
|
|
1007
|
+
await el.updateComplete;
|
|
1008
|
+
el.lendingBarPostInit = Sinon.spy();
|
|
1009
|
+
|
|
1010
|
+
await el.localCache.set({
|
|
1011
|
+
key: 'foobar-loanTime',
|
|
1012
|
+
value: new Date(new Date().getTime() + 3600 * 1000),
|
|
1013
|
+
ttl: 3600,
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
const startTokenPollerSpy = Sinon.spy(el, 'startLoanTokenPoller');
|
|
1017
|
+
|
|
1018
|
+
el.autoRenewExpiredLoan();
|
|
1019
|
+
await el.updateComplete;
|
|
1020
|
+
await aTimeout(50); // let autoRenewExpiredLoan's setTimeout(0) set renewNow
|
|
1021
|
+
|
|
1022
|
+
await el.handleLoanAutoRenewed({
|
|
1023
|
+
detail: {
|
|
1024
|
+
action: 'renew_loan',
|
|
1025
|
+
data: { success: true, loan: { identifier: 'foobar', renewal: true } },
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// Called synchronously from handleLoanAutoRenewed, not via a
|
|
1030
|
+
// create_token success callback.
|
|
1031
|
+
expect(el.lendingBarPostInit.calledOnce).to.be.true;
|
|
1032
|
+
expect(el.postInitComplete).to.be.true;
|
|
1033
|
+
|
|
1034
|
+
await el.updateComplete;
|
|
1035
|
+
await aTimeout(150); // let the 100ms token-poller-restart check run
|
|
1036
|
+
|
|
1037
|
+
expect(startTokenPollerSpy.calledOnceWith(true)).to.be.true;
|
|
1038
|
+
});
|
|
1039
|
+
|
|
972
1040
|
it('clears loanRenewInProgress and shows unavailable modal on renew_loan failure', async () => {
|
|
973
1041
|
const el = await fixture(
|
|
974
1042
|
container({
|
|
@@ -1130,14 +1198,11 @@ describe('handleLendingActionError - create_token failures must not stop the cou
|
|
|
1130
1198
|
expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
|
|
1131
1199
|
});
|
|
1132
1200
|
|
|
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.)
|
|
1201
|
+
it('shows the error modal and resets to Borrow/0 on a failed INITIAL create_token (no retry)', async () => {
|
|
1202
|
+
// For the INITIAL token, BookReader never got page images — the book
|
|
1203
|
+
// never opened. We don't retry, so the bar must reflect that honestly
|
|
1204
|
+
// (Borrow, 0 left) instead of staying red with a stale countdown for a
|
|
1205
|
+
// session that isn't actually accessible.
|
|
1141
1206
|
const el = await fixture(
|
|
1142
1207
|
container({
|
|
1143
1208
|
userid: '@user1',
|
|
@@ -1164,24 +1229,16 @@ describe('handleLendingActionError - create_token failures must not stop the cou
|
|
|
1164
1229
|
|
|
1165
1230
|
expect(showErrorModalSpy.calledOnceWith(errorMsg, 'create_token')).to.be
|
|
1166
1231
|
.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;
|
|
1232
|
+
expect(el.lendingStatus.user_has_browsed).to.be.false;
|
|
1233
|
+
expect(el.lendingStatus.available_to_browse).to.be.true;
|
|
1234
|
+
expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
|
|
1175
1235
|
});
|
|
1176
1236
|
|
|
1177
1237
|
it('stops the countdown/expiry timers on a failed INITIAL create_token — nothing to read yet', async () => {
|
|
1178
1238
|
// 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.
|
|
1239
|
+
// ticking), an initial create_token failure means BookReader never got
|
|
1240
|
+
// page images and we're resetting the bar to Borrow — both timers
|
|
1241
|
+
// should stop rather than keep counting down a dead session.
|
|
1185
1242
|
const el = await fixture(
|
|
1186
1243
|
container({
|
|
1187
1244
|
userid: '@user1',
|
|
@@ -1582,70 +1639,6 @@ describe('WEBDEV-8322 review fixes', () => {
|
|
|
1582
1639
|
expect(el.lendingStatus.user_has_browsed).to.be.true;
|
|
1583
1640
|
});
|
|
1584
1641
|
|
|
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
1642
|
it('tears down the previous poller before starting a new one', async () => {
|
|
1650
1643
|
// Regression: startLoanTokenPoller() always replaced this.tokenPoller
|
|
1651
1644
|
// without cancelling the old instance's pending internal retry timer,
|
|
@@ -1675,9 +1668,7 @@ describe('WEBDEV-8322 review fixes', () => {
|
|
|
1675
1668
|
expect(el.tokenPoller).to.not.equal(firstPoller);
|
|
1676
1669
|
});
|
|
1677
1670
|
|
|
1678
|
-
it('
|
|
1679
|
-
// Regression: an outer restart loop with no cap meant a genuinely-lost
|
|
1680
|
-
// (not just slow-to-propagate) loan retried forever.
|
|
1671
|
+
it('never restarts the poller on a create_token failure — no retry', async () => {
|
|
1681
1672
|
const el = await fixture(
|
|
1682
1673
|
container({
|
|
1683
1674
|
userid: '@user1',
|
|
@@ -1694,19 +1685,15 @@ describe('WEBDEV-8322 review fixes', () => {
|
|
|
1694
1685
|
await el.updateComplete;
|
|
1695
1686
|
|
|
1696
1687
|
const startSpy = Sinon.spy(el, 'startLoanTokenPoller');
|
|
1697
|
-
const errorMsg = 'loan token not found. please try again later.';
|
|
1698
1688
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
});
|
|
1707
|
-
}
|
|
1689
|
+
el.handleLendingActionError({
|
|
1690
|
+
detail: {
|
|
1691
|
+
action: 'create_token',
|
|
1692
|
+
isInitial: true,
|
|
1693
|
+
data: { error: 'loan token not found. please try again later.' },
|
|
1694
|
+
},
|
|
1695
|
+
});
|
|
1708
1696
|
|
|
1709
|
-
expect(startSpy.
|
|
1710
|
-
expect(el.tokenRecoveryAttempts).to.equal(el.maxTokenRecoveryAttempts + 1);
|
|
1697
|
+
expect(startSpy.called).to.be.false;
|
|
1711
1698
|
});
|
|
1712
1699
|
});
|