@internetarchive/ia-book-actions 1.4.4-alpha1 → 1.4.4-alpha10
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 +25 -6
- package/package.json +1 -1
- package/src/core/services/actions-handler/actions-handler-service.js +2 -2
- package/src/core/services/actions-handler/actions-handler.js +21 -16
- package/src/core/services/loan-token-poller.js +63 -16
- package/src/ia-book-actions.js +133 -44
- package/test/core/services/actions-handler.test.js +110 -1
- package/test/core/services/loan-token-poller.test.js +76 -0
- package/test/ia-book-actions.test.js +495 -0
package/index.html
CHANGED
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
border: 2px solid #194880;
|
|
98
98
|
border-radius: 10px;
|
|
99
99
|
background: white;
|
|
100
|
+
height: 50px;
|
|
100
101
|
}
|
|
101
102
|
.timer-presets button.active-preset {
|
|
102
103
|
background: #194880;
|
|
@@ -137,6 +138,9 @@
|
|
|
137
138
|
.loan-status-badge.status-active {
|
|
138
139
|
background: #2e7d32; /* green: loan is currently checked out */
|
|
139
140
|
}
|
|
141
|
+
.loan-status-badge.status-renewing {
|
|
142
|
+
background: #b8860b; /* amber: a renew_loan attempt is in flight */
|
|
143
|
+
}
|
|
140
144
|
.loan-status-badge.status-returned {
|
|
141
145
|
background: #d9534f; /* red: auto-returned / not on loan */
|
|
142
146
|
}
|
|
@@ -216,7 +220,7 @@
|
|
|
216
220
|
console.log = (...consoleArgs) => {
|
|
217
221
|
__nativeConsoleLog(...consoleArgs);
|
|
218
222
|
|
|
219
|
-
const loggedPrefixes = ['[
|
|
223
|
+
const loggedPrefixes = ['[IABookActions]', '[IABookActions]', '[Demo]'];
|
|
220
224
|
if (typeof consoleArgs[0] !== 'string' || !loggedPrefixes.some((prefix) => consoleArgs[0].startsWith(prefix))) {
|
|
221
225
|
return;
|
|
222
226
|
}
|
|
@@ -280,7 +284,7 @@
|
|
|
280
284
|
const timerPresets = {
|
|
281
285
|
'15s': { loanTotalTime: 15, loanRenewAtLast: 13, pageChangedInLast: 5 },
|
|
282
286
|
'30s': { loanTotalTime: 30, loanRenewAtLast: 27, pageChangedInLast: 10 },
|
|
283
|
-
'2': { loanTotalTime: 120, loanRenewAtLast:
|
|
287
|
+
'2': { loanTotalTime: 120, loanRenewAtLast: 95, pageChangedInLast: 15 },
|
|
284
288
|
'5': { loanTotalTime: 300, loanRenewAtLast: 240, pageChangedInLast: 60 },
|
|
285
289
|
'10': { loanTotalTime: 600, loanRenewAtLast: 480, pageChangedInLast: 120 },
|
|
286
290
|
'30': { loanTotalTime: 1800, loanRenewAtLast: 1500, pageChangedInLast: 600 },
|
|
@@ -439,6 +443,14 @@
|
|
|
439
443
|
await iaBookActions.updateComplete;
|
|
440
444
|
}
|
|
441
445
|
|
|
446
|
+
// showWarningModal() reads its message text from
|
|
447
|
+
// loanRenewHelper.getMessageTexts(loanRenewResult.texts, ...) — that
|
|
448
|
+
// helper only exists, and loanRenewResult.texts only gets populated,
|
|
449
|
+
// once autoLoanRenewChecker() has run at least once (this is what the
|
|
450
|
+
// real periodic timer calls before ever showing the modal). Run it
|
|
451
|
+
// here too so the button shows the same populated message a real
|
|
452
|
+
// warning would.
|
|
453
|
+
await iaBookActions.autoLoanRenewChecker(false);
|
|
442
454
|
iaBookActions.showWarningModal();
|
|
443
455
|
});
|
|
444
456
|
|
|
@@ -578,6 +590,13 @@
|
|
|
578
590
|
function computeLoanStatus() {
|
|
579
591
|
const lendingStatus = iaBookActions.lendingStatus || {};
|
|
580
592
|
|
|
593
|
+
// Checked first: autoRenewExpiredLoan() optimistically flips
|
|
594
|
+
// browsingExpired back to false the moment a retry starts (before the
|
|
595
|
+
// renew_loan call has actually landed), so without this check the
|
|
596
|
+
// badge would misleadingly jump straight to ACTIVE during the retry.
|
|
597
|
+
if (iaBookActions.loanRenewInProgress) {
|
|
598
|
+
return { badge: 'RENEWING', className: 'status-renewing' };
|
|
599
|
+
}
|
|
581
600
|
if (lendingStatus.browsingExpired) {
|
|
582
601
|
return { badge: 'RETURNED', className: 'status-returned' };
|
|
583
602
|
}
|
|
@@ -662,7 +681,10 @@
|
|
|
662
681
|
<button id="show_expired_modal">2. Auto-return due to inactivity<br>(no modal, page stays put)</button>
|
|
663
682
|
</td>
|
|
664
683
|
<td class="action-button">
|
|
665
|
-
<button class="pageChangedEvent">
|
|
684
|
+
<button class="pageChangedEvent">
|
|
685
|
+
3. Interact with book (turn page)<br>
|
|
686
|
+
silently renew while active or after auto-return
|
|
687
|
+
</button>
|
|
666
688
|
</td>
|
|
667
689
|
<td class="action-button">
|
|
668
690
|
<button id="resync_timer">4. Return to tab after time away<br>(visibilitychange — silent retry if expired)</button>
|
|
@@ -688,9 +710,6 @@
|
|
|
688
710
|
<td class="searchParam">
|
|
689
711
|
<label><input type="checkbox" name="search" value="Search Param" />Search Param</label>
|
|
690
712
|
</td>
|
|
691
|
-
<td>
|
|
692
|
-
<label class="ia-button"><button class="pageChangedEvent">Page Change Event</button></label>
|
|
693
|
-
</td>
|
|
694
713
|
</tr>
|
|
695
714
|
</table>
|
|
696
715
|
</fieldset>
|
package/package.json
CHANGED
|
@@ -84,10 +84,10 @@ export default async function ActionsHandlerService(options) {
|
|
|
84
84
|
.then(data => {
|
|
85
85
|
// `data` is the parsed version of the JSON returned from the above endpoint.
|
|
86
86
|
if (!data?.error) {
|
|
87
|
-
log(`[
|
|
87
|
+
log(`[IABookActions] ✓ ${option.action} succeeded`, data);
|
|
88
88
|
option?.success(data);
|
|
89
89
|
} else {
|
|
90
|
-
log(`[
|
|
90
|
+
log(`[IABookActions] ✗ ${option.action} failed`, data);
|
|
91
91
|
option?.error(data);
|
|
92
92
|
}
|
|
93
93
|
});
|
|
@@ -53,20 +53,8 @@ export default class ActionsHandler extends LitElement {
|
|
|
53
53
|
);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
this.addEventListener('autoRenew',
|
|
57
|
-
this.handleLoanRenewNow();
|
|
58
|
-
await this.loanAnanlytics?.storeLoanStatsCount(
|
|
59
|
-
this.identifier,
|
|
60
|
-
'autorenew'
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
const analyticsLabel = detail?.renewType === 'auto' ? analyticsLabels.browseAutoRenew : analyticsLabels.browseManualRenew;
|
|
64
|
-
this.loanAnanlytics?.sendEvent(
|
|
65
|
-
analyticsCategories.browse,
|
|
66
|
-
analyticsActions.browseRenew,
|
|
67
|
-
analyticsLabel,
|
|
68
|
-
this.identifier
|
|
69
|
-
);
|
|
56
|
+
this.addEventListener('autoRenew', ({ detail }) => {
|
|
57
|
+
this.handleLoanRenewNow(detail?.renewType);
|
|
70
58
|
});
|
|
71
59
|
|
|
72
60
|
this.addEventListener('autoReturn', async () => {
|
|
@@ -177,14 +165,14 @@ export default class ActionsHandler extends LitElement {
|
|
|
177
165
|
});
|
|
178
166
|
}
|
|
179
167
|
|
|
180
|
-
handleLoanRenewNow() {
|
|
168
|
+
handleLoanRenewNow(renewType) {
|
|
181
169
|
const action = 'renew_loan';
|
|
182
170
|
|
|
183
171
|
ActionsHandlerService({
|
|
184
172
|
action,
|
|
185
173
|
identifier: this.identifier,
|
|
186
174
|
success: async data => {
|
|
187
|
-
log('RENEW_LOAN --- ', data,
|
|
175
|
+
log('RENEW_LOAN --- ', data, this.identifier);
|
|
188
176
|
const activeLoan = data.loan ? data.loan : undefined;
|
|
189
177
|
const isRenewal = activeLoan.renewal;
|
|
190
178
|
|
|
@@ -192,6 +180,23 @@ export default class ActionsHandler extends LitElement {
|
|
|
192
180
|
// Await — loanAutoRenewed listeners read this same cache key
|
|
193
181
|
// immediately; dispatching before the write lands was a race.
|
|
194
182
|
await this.setBrowseTimeSession();
|
|
183
|
+
|
|
184
|
+
// Only record the renew analytics event once the renewal has
|
|
185
|
+
// actually succeeded — not merely attempted.
|
|
186
|
+
await this.loanAnanlytics?.storeLoanStatsCount(
|
|
187
|
+
this.identifier,
|
|
188
|
+
'autorenew'
|
|
189
|
+
);
|
|
190
|
+
const analyticsLabel =
|
|
191
|
+
renewType === 'auto'
|
|
192
|
+
? analyticsLabels.browseAutoRenew
|
|
193
|
+
: analyticsLabels.browseManualRenew;
|
|
194
|
+
this.loanAnanlytics?.sendEvent(
|
|
195
|
+
analyticsCategories.browse,
|
|
196
|
+
analyticsActions.browseRenew,
|
|
197
|
+
analyticsLabel,
|
|
198
|
+
this.identifier
|
|
199
|
+
);
|
|
195
200
|
} else {
|
|
196
201
|
log('RENEW_LOAN ERROR --- ', {
|
|
197
202
|
action,
|
|
@@ -18,6 +18,18 @@ export class LoanTokenPoller {
|
|
|
18
18
|
|
|
19
19
|
this.loanTokenInterval = undefined;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Lending::do_renew() (backend) deletes the old loan record and
|
|
23
|
+
* writes a brand-new one on every renewal — this poller's initial
|
|
24
|
+
* create_token call (fired right after a renewal re-establishes the
|
|
25
|
+
* reading session) can race that write's propagation and come back
|
|
26
|
+
* with "you do not currently have this book borrowed" even though
|
|
27
|
+
* the loan is genuinely valid. Retry a few times with backoff instead
|
|
28
|
+
* of giving up on the first attempt.
|
|
29
|
+
*/
|
|
30
|
+
this.maxInitialTokenRetries = 3;
|
|
31
|
+
this.initialTokenRetryDelay = 1000; // ms, multiplied by attempt number
|
|
32
|
+
|
|
21
33
|
/**
|
|
22
34
|
* loan analytics instance
|
|
23
35
|
* @see loan-analytics.js
|
|
@@ -58,29 +70,64 @@ export class LoanTokenPoller {
|
|
|
58
70
|
}
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
/**
|
|
74
|
+
* @param {boolean} isInitial - the first create_token call right after
|
|
75
|
+
* bookAccessed()/a renewal, as opposed to a routine interval tick.
|
|
76
|
+
* @param {number} retryCount - internal, counts retries of the initial call.
|
|
77
|
+
*/
|
|
78
|
+
async handleLoanTokenPoller(isInitial = false, retryCount = 0) {
|
|
62
79
|
const action = 'create_token';
|
|
63
80
|
ActionsHandlerService({
|
|
64
81
|
identifier: this.identifier,
|
|
65
82
|
action,
|
|
66
|
-
error: data =>
|
|
67
|
-
this.errorCallback({ detail: { action, data } });
|
|
68
|
-
|
|
69
|
-
// send error to Sentry
|
|
70
|
-
window?.Sentry?.captureMessage(
|
|
71
|
-
`${sentryLogs.handleLoanTokenPoller} - Error: ${JSON.stringify(data)}`
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
// send LendingServiceError to GA
|
|
75
|
-
this.loanAnalytics?.sendEvent(
|
|
76
|
-
'LendingServiceLoanError',
|
|
77
|
-
action,
|
|
78
|
-
this.identifier
|
|
79
|
-
);
|
|
80
|
-
},
|
|
83
|
+
error: data => this.handleTokenError(data, isInitial, retryCount),
|
|
81
84
|
success: () => {
|
|
82
85
|
if (isInitial) this.successCallback();
|
|
83
86
|
},
|
|
84
87
|
});
|
|
85
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decide whether a create_token failure should be retried (a stale
|
|
92
|
+
* read of the loan record right after a renewal, see the constructor
|
|
93
|
+
* comment) or reported via errorCallback. Split out from
|
|
94
|
+
* handleLoanTokenPoller so it's directly testable without needing to
|
|
95
|
+
* mock the network call.
|
|
96
|
+
*
|
|
97
|
+
* @param {Object} data - the error payload from ActionsHandlerService.
|
|
98
|
+
* @param {boolean} isInitial
|
|
99
|
+
* @param {number} retryCount
|
|
100
|
+
*/
|
|
101
|
+
handleTokenError(data, isInitial, retryCount) {
|
|
102
|
+
const action = 'create_token';
|
|
103
|
+
const isStaleLoanReadError =
|
|
104
|
+
typeof data?.error === 'string' &&
|
|
105
|
+
/do not currently have this book borrowed/i.test(data.error);
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
isInitial &&
|
|
109
|
+
isStaleLoanReadError &&
|
|
110
|
+
retryCount < this.maxInitialTokenRetries
|
|
111
|
+
) {
|
|
112
|
+
const delay = this.initialTokenRetryDelay * (retryCount + 1);
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
this.handleLoanTokenPoller(true, retryCount + 1);
|
|
115
|
+
}, delay);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.errorCallback({ detail: { action, data } });
|
|
120
|
+
|
|
121
|
+
// send error to Sentry
|
|
122
|
+
window?.Sentry?.captureMessage(
|
|
123
|
+
`${sentryLogs.handleLoanTokenPoller} - Error: ${JSON.stringify(data)}`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// send LendingServiceError to GA
|
|
127
|
+
this.loanAnalytics?.sendEvent(
|
|
128
|
+
'LendingServiceLoanError',
|
|
129
|
+
action,
|
|
130
|
+
this.identifier
|
|
131
|
+
);
|
|
132
|
+
}
|
|
86
133
|
}
|
package/src/ia-book-actions.js
CHANGED
|
@@ -96,6 +96,15 @@ export default class IABookActions extends LitElement {
|
|
|
96
96
|
|
|
97
97
|
this.loanRenewInProgress = false;
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True while a renewal in flight is recovering from a loan that
|
|
101
|
+
* genuinely lapsed (set by autoRenewExpiredLoan), as opposed to a
|
|
102
|
+
* routine pre-expiry top-up. Only the recovery case needs BookReader
|
|
103
|
+
* re-initialized once the renewal is confirmed — see
|
|
104
|
+
* handleLoanAutoRenewed.
|
|
105
|
+
*/
|
|
106
|
+
this.recoveringFromLoanExpiry = false;
|
|
107
|
+
|
|
99
108
|
this.warningModalOpen = false;
|
|
100
109
|
|
|
101
110
|
/**
|
|
@@ -180,6 +189,13 @@ export default class IABookActions extends LitElement {
|
|
|
180
189
|
}
|
|
181
190
|
|
|
182
191
|
if (changed.has('loanRenewResult') && this.loanRenewResult.renewNow) {
|
|
192
|
+
// Any renewal — not just the auto-return recovery path — pauses the
|
|
193
|
+
// countdown for the renew_loan round-trip. Flag it here, centrally,
|
|
194
|
+
// so every path (interaction during active reading, periodic
|
|
195
|
+
// checker, auto-return recovery) gets the same "renewing" signal
|
|
196
|
+
// instead of just looking frozen until handleLoanAutoRenewed() clears
|
|
197
|
+
// this and restarts the timer.
|
|
198
|
+
this.loanRenewInProgress = true;
|
|
183
199
|
window.IALendingIntervals.clearAll();
|
|
184
200
|
}
|
|
185
201
|
}
|
|
@@ -283,11 +299,22 @@ export default class IABookActions extends LitElement {
|
|
|
283
299
|
|
|
284
300
|
/**
|
|
285
301
|
* tokenPoller determines if user has loan token for this book
|
|
286
|
-
* - if book is going to renew, need to wait until renew is completed
|
|
302
|
+
* - if book is going to renew, need to wait until renew is completed:
|
|
303
|
+
* loanRenewInProgress is set before renew_loan is even dispatched
|
|
304
|
+
* (including the optimistic pre-confirmation window) and only
|
|
305
|
+
* clears once the renewal is confirmed, so starting the poller here
|
|
306
|
+
* mid-renewal would call create_token against the loan record
|
|
307
|
+
* before it's actually renewed.
|
|
287
308
|
*/
|
|
288
309
|
setTimeout(() => {
|
|
289
|
-
if (
|
|
310
|
+
if (
|
|
311
|
+
!hasExpired &&
|
|
312
|
+
!this.loanRenewInProgress &&
|
|
313
|
+
!window.IALendingIntervals.tokenPoller
|
|
314
|
+
) {
|
|
315
|
+
this.recoveringFromLoanExpiry = false;
|
|
290
316
|
this.startLoanTokenPoller();
|
|
317
|
+
}
|
|
291
318
|
}, 100);
|
|
292
319
|
|
|
293
320
|
this.requestUpdate();
|
|
@@ -304,17 +331,27 @@ export default class IABookActions extends LitElement {
|
|
|
304
331
|
* dispatched this event from bookreader page changed
|
|
305
332
|
*/
|
|
306
333
|
window.addEventListener('BookReader:userAction', () => {
|
|
334
|
+
// Capture before autoRenewExpiredLoan() runs — it synchronously flips
|
|
335
|
+
// lendingStatus.browsingExpired to false as an optimistic UI update,
|
|
336
|
+
// so checking the live property afterward would always see it as
|
|
337
|
+
// false and wrongly let autoLoanRenewChecker() run too. That second
|
|
338
|
+
// call would then overwrite loanRenewResult.renewNow (set true by
|
|
339
|
+
// autoRenewExpiredLoan) back to false, since the loanTime cache entry
|
|
340
|
+
// was already deleted by browseHasExpired() — silently killing the
|
|
341
|
+
// in-flight renewal and leaving the countdown stuck.
|
|
342
|
+
const wasExpired = this.lendingStatus.browsingExpired;
|
|
343
|
+
|
|
307
344
|
log('[IABookActions] BookReader:userAction received', {
|
|
308
345
|
borrowType: this.borrowType,
|
|
309
|
-
browsingExpired:
|
|
346
|
+
browsingExpired: wasExpired,
|
|
310
347
|
});
|
|
311
348
|
|
|
312
349
|
// If the loan expired while the tab stayed visible, auto-renew on page turn
|
|
313
|
-
if (
|
|
350
|
+
if (wasExpired) {
|
|
314
351
|
this.autoRenewExpiredLoan();
|
|
315
352
|
}
|
|
316
353
|
|
|
317
|
-
if (this.borrowType === 'browsed' && !
|
|
354
|
+
if (this.borrowType === 'browsed' && !wasExpired) {
|
|
318
355
|
this.autoLoanRenewChecker(true);
|
|
319
356
|
}
|
|
320
357
|
});
|
|
@@ -372,6 +409,16 @@ export default class IABookActions extends LitElement {
|
|
|
372
409
|
* @param {Boolean} hasPageChanged
|
|
373
410
|
*/
|
|
374
411
|
async autoLoanRenewChecker(hasPageChanged = false) {
|
|
412
|
+
// Guards against re-entrancy the same way autoRenewExpiredLoan() does.
|
|
413
|
+
// BookReader:userAction can fire several times in quick succession
|
|
414
|
+
// (e.g. a single scroll gesture), and this method has no debouncing of
|
|
415
|
+
// its own — without this guard, each event would spin up its own
|
|
416
|
+
// LoanRenewHelper concurrently, and whichever's async localCache reads
|
|
417
|
+
// resolve last would clobber this.loanRenewResult, potentially
|
|
418
|
+
// re-triggering the whole renew_loan/create_token flow while a
|
|
419
|
+
// renewal from an earlier event is already in flight or just landed.
|
|
420
|
+
if (this.loanRenewInProgress) return;
|
|
421
|
+
|
|
375
422
|
this.loanRenewHelper = new LoanRenewHelper(
|
|
376
423
|
hasPageChanged,
|
|
377
424
|
this.identifier,
|
|
@@ -395,6 +442,7 @@ export default class IABookActions extends LitElement {
|
|
|
395
442
|
autoRenewExpiredLoan() {
|
|
396
443
|
if (this.loanRenewInProgress) return;
|
|
397
444
|
this.loanRenewInProgress = true;
|
|
445
|
+
this.recoveringFromLoanExpiry = true;
|
|
398
446
|
|
|
399
447
|
this.modal?.closeModal();
|
|
400
448
|
|
|
@@ -532,20 +580,21 @@ export default class IABookActions extends LitElement {
|
|
|
532
580
|
* Show modal when the book can no longer be automatically renewed
|
|
533
581
|
* because another patron has checked it out.
|
|
534
582
|
*/
|
|
535
|
-
async showLoanUnavailableModal() {
|
|
583
|
+
async showLoanUnavailableModal(errorMsg) {
|
|
536
584
|
const config = new ModalConfig({
|
|
537
585
|
headline: '',
|
|
538
586
|
showCloseButton: false,
|
|
539
587
|
closeOnBackdropClick: false,
|
|
540
|
-
headerColor: '#
|
|
588
|
+
headerColor: '#d9534f',
|
|
541
589
|
message:
|
|
590
|
+
errorMsg ||
|
|
542
591
|
'Due to inactivity, this book was returned, and someone else has now borrowed it. Please try again later.',
|
|
543
592
|
});
|
|
544
593
|
|
|
545
594
|
const customModalContent = html`<br />
|
|
546
595
|
<div style="text-align: center">
|
|
547
596
|
<button
|
|
548
|
-
style="${modalButtonStyle.iaButton} ${modalButtonStyle.
|
|
597
|
+
style="${modalButtonStyle.iaButton} ${modalButtonStyle.return}"
|
|
549
598
|
@click=${() => URLHelper.goToUrl(this.returnUrl, true)}
|
|
550
599
|
>
|
|
551
600
|
Okay
|
|
@@ -639,6 +688,7 @@ export default class IABookActions extends LitElement {
|
|
|
639
688
|
const activeLoan = detail?.data?.loan;
|
|
640
689
|
const errorMessage = `Whoops, seems we hit a hiccup with renewing this book. Please refresh & retry. --- (Debug: ${detail?.data?.error})`;
|
|
641
690
|
if (!activeLoan) {
|
|
691
|
+
this.recoveringFromLoanExpiry = false;
|
|
642
692
|
this.showErrorModal(errorMessage, 'handleLoanAutoRenewed');
|
|
643
693
|
return;
|
|
644
694
|
}
|
|
@@ -661,6 +711,23 @@ export default class IABookActions extends LitElement {
|
|
|
661
711
|
ajaxResponse: detail?.data,
|
|
662
712
|
});
|
|
663
713
|
|
|
714
|
+
if (this.recoveringFromLoanExpiry) {
|
|
715
|
+
// The loan genuinely lapsed, and renew_loan has now CONFIRMED it's
|
|
716
|
+
// renewed server-side — only now is it safe to let BookReader
|
|
717
|
+
// re-initialize (create_token was minting its access cookie off
|
|
718
|
+
// the loan's expiry; doing this before confirmation risked reading
|
|
719
|
+
// the still-expired record). loanRenewInProgress already kept the
|
|
720
|
+
// token poller from restarting early, so it'll pick up fresh here
|
|
721
|
+
// once lendingStatus below re-triggers setupLendingToolbarActions().
|
|
722
|
+
// recoveringFromLoanExpiry itself is consumed/reset there, not
|
|
723
|
+
// here — this ONLY applies to an actual recovery, not a routine
|
|
724
|
+
// renewal (re-running br.init() on every routine top-up would be
|
|
725
|
+
// needlessly disruptive). create_token's own retry logic (see
|
|
726
|
+
// LoanTokenPoller) handles the datanode write-propagation race
|
|
727
|
+
// for both recovery and routine renewals.
|
|
728
|
+
this.postInitComplete = false;
|
|
729
|
+
}
|
|
730
|
+
|
|
664
731
|
const currStatus = {
|
|
665
732
|
...this.lendingStatus,
|
|
666
733
|
user_has_browsed: true,
|
|
@@ -718,14 +785,15 @@ export default class IABookActions extends LitElement {
|
|
|
718
785
|
|
|
719
786
|
if (resyncd.hasSynced) {
|
|
720
787
|
secondsLeft = resyncd.whatShouldLeft;
|
|
721
|
-
log('[IABookActions]
|
|
788
|
+
log('[IABookActions] timer: timer re-synced', {
|
|
722
789
|
secondsLeft,
|
|
723
790
|
});
|
|
724
791
|
}
|
|
725
792
|
|
|
726
|
-
log('[IABookActions]
|
|
793
|
+
log('[IABookActions] timer', {
|
|
727
794
|
secondsLeft,
|
|
728
795
|
loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
|
|
796
|
+
pageChangedInLast: this.loanRenewTimeConfig.pageChangedInLast,
|
|
729
797
|
});
|
|
730
798
|
|
|
731
799
|
/**
|
|
@@ -881,50 +949,71 @@ export default class IABookActions extends LitElement {
|
|
|
881
949
|
handleLendingActionError(event) {
|
|
882
950
|
this.disableActionGroup = false;
|
|
883
951
|
|
|
884
|
-
// clear all intervals for lending system when error occured
|
|
885
|
-
window?.IALendingIntervals?.clearAll();
|
|
886
|
-
|
|
887
952
|
const action = event?.detail?.action;
|
|
888
953
|
const errorMsg = event?.detail?.data?.error;
|
|
889
954
|
|
|
890
|
-
// For renew_loan failures after auto-renew, show the patron-facing message
|
|
891
|
-
if (
|
|
892
|
-
errorMsg &&
|
|
893
|
-
action === 'renew_loan' &&
|
|
894
|
-
errorMsg.match(/not available/)
|
|
895
|
-
) {
|
|
896
|
-
this.loanRenewInProgress = false;
|
|
897
|
-
this.showLoanUnavailableModal();
|
|
898
|
-
} else if (errorMsg && action !== 'create_token') {
|
|
899
|
-
this.showErrorModal(errorMsg, action);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// if error related to loan token
|
|
903
|
-
// - set user_has_browsed to `false`
|
|
904
955
|
if (action === 'create_token') {
|
|
905
|
-
|
|
956
|
+
// A create_token hiccup only affects BookReader's page-image access
|
|
957
|
+
// token — it says nothing about how much time is left on the loan
|
|
958
|
+
// itself, so it must not stop the reading countdown. Only clear the
|
|
959
|
+
// token poller so it can be retried.
|
|
960
|
+
window?.IALendingIntervals?.clearTokenPoller();
|
|
961
|
+
|
|
962
|
+
// The loan itself is fine — just this token refresh failed — but
|
|
963
|
+
// the action bar must reflect that it isn't safely browsable right
|
|
964
|
+
// now rather than silently doing nothing.
|
|
965
|
+
this.lendingStatus = {
|
|
906
966
|
...this.lendingStatus,
|
|
907
967
|
user_has_browsed: false,
|
|
908
968
|
available_to_browse: true,
|
|
909
969
|
};
|
|
910
|
-
this.lendingStatus = currStatus;
|
|
911
|
-
}
|
|
912
970
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
971
|
+
// By the time this handler runs, LoanTokenPoller has already
|
|
972
|
+
// exhausted its retries for the transient datanode-propagation
|
|
973
|
+
// race (or the error wasn't that in the first place) — this is a
|
|
974
|
+
// genuine failure, so let the patron know instead of doing this
|
|
975
|
+
// silently. showErrorModal has dedicated create_token messaging
|
|
976
|
+
// (refresh button + support email).
|
|
977
|
+
if (errorMsg) this.showErrorModal(errorMsg, action);
|
|
978
|
+
} else if (action === 'renew_loan') {
|
|
979
|
+
window?.IALendingIntervals?.clearAll();
|
|
980
|
+
this.loanRenewInProgress = false;
|
|
981
|
+
this.recoveringFromLoanExpiry = false;
|
|
982
|
+
|
|
983
|
+
// The loan was NOT actually renewed — reflect that immediately
|
|
984
|
+
// (show Borrow, and clear the stale timer value) instead of leaving
|
|
985
|
+
// the action bar showing "Return now" with a frozen countdown from
|
|
986
|
+
// before the failed attempt, which falsely implies the book is
|
|
987
|
+
// still actively being read.
|
|
988
|
+
this.lendingStatus = {
|
|
989
|
+
...this.lendingStatus,
|
|
990
|
+
user_has_browsed: false,
|
|
991
|
+
available_to_browse: true,
|
|
992
|
+
secondsLeftOnLoan: 0,
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
// Show the real error and refresh the page on dismissal (the
|
|
996
|
+
// modal's Okay button) so the client picks up whatever the server
|
|
997
|
+
// now authoritatively considers true — matching on "not available"
|
|
998
|
+
// to guess the reason was fragile since the server can return any
|
|
999
|
+
// message (e.g. a lending limit).
|
|
1000
|
+
this.showLoanUnavailableModal(errorMsg);
|
|
1001
|
+
} else {
|
|
1002
|
+
// Every other action failure genuinely affects loan state.
|
|
1003
|
+
window?.IALendingIntervals?.clearAll();
|
|
1004
|
+
|
|
1005
|
+
if (!errorMsg) return;
|
|
1006
|
+
|
|
1007
|
+
this.showErrorModal(errorMsg, action);
|
|
1008
|
+
|
|
1009
|
+
// update action bar state if book is not available to browse or borrow.
|
|
1010
|
+
if (errorMsg.match(/not available to borrow/gm)) {
|
|
1011
|
+
if (action === 'browse_book') {
|
|
1012
|
+
this.lendingStatus = { ...this.lendingStatus, available_to_browse: false };
|
|
1013
|
+
} else if (action === 'borrow_book') {
|
|
1014
|
+
this.lendingStatus = { ...this.lendingStatus, available_to_borrow: false };
|
|
1015
|
+
}
|
|
926
1016
|
}
|
|
927
|
-
this.lendingStatus = currStatus;
|
|
928
1017
|
}
|
|
929
1018
|
}
|
|
930
1019
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { expect, fixture } from '@open-wc/testing';
|
|
1
|
+
import { expect, fixture, aTimeout } from '@open-wc/testing';
|
|
2
2
|
import sinon from 'sinon';
|
|
3
|
+
import { LocalCache } from '@internetarchive/local-cache';
|
|
3
4
|
|
|
4
5
|
import ActionsHandler from '../../../src/core/services/actions-handler/actions-handler.js';
|
|
6
|
+
import { analyticsCategories, analyticsActions, analyticsLabels } from '../../../src/core/config/analytics-event-and-category.js';
|
|
5
7
|
|
|
6
8
|
// Define a temporary tag for this element for testing
|
|
7
9
|
const TEST_TAG = 'ia-actions-handler-test';
|
|
@@ -89,3 +91,110 @@ describe('ActionsHandler#setStickyAdminAccess', () => {
|
|
|
89
91
|
expect(lastSetCookie).to.contain(`${encodeURIComponent('sticky-admin-access')}=${encodeURIComponent('false')}`);
|
|
90
92
|
});
|
|
91
93
|
});
|
|
94
|
+
|
|
95
|
+
describe('ActionsHandler#handleLoanRenewNow (WEBDEV-8322)', () => {
|
|
96
|
+
let el;
|
|
97
|
+
let localCache;
|
|
98
|
+
const identifier = 'renew-now-test-book';
|
|
99
|
+
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
// Make sure no other test in this run left ?error=true in the URL —
|
|
102
|
+
// ActionsHandlerService reads it directly off window.location.
|
|
103
|
+
const params = new URLSearchParams(window.location.search);
|
|
104
|
+
if (params.has('error')) {
|
|
105
|
+
params.delete('error');
|
|
106
|
+
const query = params.toString();
|
|
107
|
+
window.history.replaceState(
|
|
108
|
+
{},
|
|
109
|
+
'',
|
|
110
|
+
`${window.location.pathname}${query ? `?${query}` : ''}`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
el = await fixture(`<${TEST_TAG}></${TEST_TAG}>`);
|
|
115
|
+
el.identifier = identifier;
|
|
116
|
+
el.loanTotalTime = 3600;
|
|
117
|
+
localCache = new LocalCache({ namespace: 'loanRenew' });
|
|
118
|
+
el.localCache = localCache;
|
|
119
|
+
// The demo/test environment's ActionsHandlerService fakes a renew_loan
|
|
120
|
+
// success after a 5s delay to simulate a real network round-trip.
|
|
121
|
+
await localCache.delete(`${identifier}-loanTime`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
sinon.restore();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('awaits the loanTime cache write before dispatching loanAutoRenewed, and fires the renew analytics event only on success', async function test() {
|
|
129
|
+
this.timeout(7000);
|
|
130
|
+
|
|
131
|
+
const storeStatsSpy = sinon.spy(el.loanAnanlytics, 'storeLoanStatsCount');
|
|
132
|
+
const sendEventSpy = sinon.spy(el.loanAnanlytics, 'sendEvent');
|
|
133
|
+
|
|
134
|
+
let loanTimeWhenEventFired;
|
|
135
|
+
el.addEventListener('loanAutoRenewed', () => {
|
|
136
|
+
loanTimeWhenEventFired = localCache.get(`${identifier}-loanTime`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
el.handleLoanRenewNow('auto');
|
|
140
|
+
|
|
141
|
+
// ActionsHandlerService fakes a 5s delay for renew_loan in this
|
|
142
|
+
// (localhost) test environment — wait it out, matching the existing
|
|
143
|
+
// pattern used elsewhere in this test suite for the same fake delay.
|
|
144
|
+
await aTimeout(5500);
|
|
145
|
+
|
|
146
|
+
// The race this guards against: loanAutoRenewed used to dispatch
|
|
147
|
+
// before setBrowseTimeSession()'s cache write landed, so a listener
|
|
148
|
+
// reading the cache immediately could still see the old/deleted value.
|
|
149
|
+
const resolvedLoanTime = await loanTimeWhenEventFired;
|
|
150
|
+
expect(resolvedLoanTime).to.exist;
|
|
151
|
+
expect(resolvedLoanTime.getTime()).to.be.greaterThan(Date.now());
|
|
152
|
+
|
|
153
|
+
expect(storeStatsSpy.calledWith(identifier, 'autorenew')).to.be.true;
|
|
154
|
+
// storeLoanStatsCount fires its own matrix-stats sendEvent internally,
|
|
155
|
+
// so assert on the specific renew-success event rather than call count.
|
|
156
|
+
expect(
|
|
157
|
+
sendEventSpy.calledWith(
|
|
158
|
+
analyticsCategories.browse,
|
|
159
|
+
analyticsActions.browseRenew,
|
|
160
|
+
analyticsLabels.browseAutoRenew,
|
|
161
|
+
identifier
|
|
162
|
+
)
|
|
163
|
+
).to.be.true;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('does not fire the renew analytics event when the renewal fails', async () => {
|
|
167
|
+
const sendEventSpy = sinon.spy(el.loanAnanlytics, 'sendEvent');
|
|
168
|
+
const storeStatsSpy = sinon.spy(el.loanAnanlytics, 'storeLoanStatsCount');
|
|
169
|
+
|
|
170
|
+
// Force ActionsHandlerService's shouldReturnError branch for this call.
|
|
171
|
+
const originalSearch = window.location.search;
|
|
172
|
+
const params = new URLSearchParams(originalSearch);
|
|
173
|
+
params.set('error', 'true');
|
|
174
|
+
window.history.pushState({}, '', `?${params.toString()}`);
|
|
175
|
+
|
|
176
|
+
let errorEventDetail;
|
|
177
|
+
el.addEventListener('lendingActionError', ({ detail }) => {
|
|
178
|
+
errorEventDetail = detail;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
el.handleLoanRenewNow('auto');
|
|
183
|
+
await aTimeout(200);
|
|
184
|
+
|
|
185
|
+
expect(errorEventDetail?.action).to.equal('renew_loan');
|
|
186
|
+
// dispatchActionError fires its own 'LendingServiceError' analytics
|
|
187
|
+
// event on any error — that's expected. What must NOT fire is the
|
|
188
|
+
// renew-success event this fix gates on `isRenewal`.
|
|
189
|
+
expect(
|
|
190
|
+
sendEventSpy.calledWith(
|
|
191
|
+
analyticsCategories.browse,
|
|
192
|
+
analyticsActions.browseRenew
|
|
193
|
+
)
|
|
194
|
+
).to.be.false;
|
|
195
|
+
expect(storeStatsSpy.calledWith(identifier, 'autorenew')).to.be.false;
|
|
196
|
+
} finally {
|
|
197
|
+
window.history.pushState({}, '', `${window.location.pathname}${originalSearch}`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -56,3 +56,79 @@ describe('Get Loan Token', () => {
|
|
|
56
56
|
expect(tokenPoller.loanTokenInterval).to.equal(undefined);
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
|
|
61
|
+
// Lending::do_renew() (backend) deletes the old loan record and writes
|
|
62
|
+
// a brand-new one on every renewal, so the initial create_token call
|
|
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
|
+
|
|
69
|
+
const makeTokenPoller = () =>
|
|
70
|
+
new LoanTokenPoller(
|
|
71
|
+
'identifier1',
|
|
72
|
+
'browsed',
|
|
73
|
+
() => {},
|
|
74
|
+
() => {},
|
|
75
|
+
2000
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
it('retries the initial call on a stale-loan-read error instead of failing immediately', () => {
|
|
79
|
+
const tokenPoller = makeTokenPoller();
|
|
80
|
+
const handleLoanTokenPollerSpy = Sinon.spy(
|
|
81
|
+
tokenPoller,
|
|
82
|
+
'handleLoanTokenPoller'
|
|
83
|
+
);
|
|
84
|
+
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.initialTokenRetryDelay);
|
|
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
|
+
|
|
104
|
+
tokenPoller.handleTokenError(
|
|
105
|
+
staleLoanReadError,
|
|
106
|
+
true,
|
|
107
|
+
tokenPoller.maxInitialTokenRetries
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not retry a routine (non-initial) poll on the same error', () => {
|
|
114
|
+
const tokenPoller = makeTokenPoller();
|
|
115
|
+
const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
|
|
116
|
+
|
|
117
|
+
tokenPoller.handleTokenError(staleLoanReadError, false, 0);
|
|
118
|
+
|
|
119
|
+
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('does not retry a genuinely different error, even on the initial call', () => {
|
|
123
|
+
const tokenPoller = makeTokenPoller();
|
|
124
|
+
const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
|
|
125
|
+
|
|
126
|
+
tokenPoller.handleTokenError(
|
|
127
|
+
{ error: 'loan token not found. please try again later.' },
|
|
128
|
+
true,
|
|
129
|
+
0
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -689,6 +689,149 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
689
689
|
expect(el.loanRenewInProgress).to.be.false;
|
|
690
690
|
});
|
|
691
691
|
|
|
692
|
+
it('does not reset postInitComplete until renewal is confirmed, not on the optimistic flip (WEBDEV-8322 follow-up)', async () => {
|
|
693
|
+
// Regression: resetting postInitComplete (which lets BookReader
|
|
694
|
+
// re-initialize via create_token) as soon as autoRenewExpiredLoan()
|
|
695
|
+
// optimistically flips browsingExpired would let create_token race
|
|
696
|
+
// ahead of renew_loan's response, reading the still-expired loan
|
|
697
|
+
// record and minting a bad access cookie — breaking page images with
|
|
698
|
+
// CORS/auth errors until the next natural token refresh.
|
|
699
|
+
const el = await fixture(
|
|
700
|
+
container({
|
|
701
|
+
userid: '@user1',
|
|
702
|
+
identifier: 'foobar',
|
|
703
|
+
lendingStatus: {
|
|
704
|
+
user_has_browsed: true,
|
|
705
|
+
browsingExpired: true,
|
|
706
|
+
},
|
|
707
|
+
})
|
|
708
|
+
);
|
|
709
|
+
await el.updateComplete;
|
|
710
|
+
el.postInitComplete = true;
|
|
711
|
+
el.lendingBarPostInit = Sinon.spy();
|
|
712
|
+
|
|
713
|
+
await el.localCache.set({
|
|
714
|
+
key: 'foobar-loanTime',
|
|
715
|
+
value: new Date(new Date().getTime() + 3600 * 1000),
|
|
716
|
+
ttl: 3600,
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
el.autoRenewExpiredLoan();
|
|
720
|
+
await el.updateComplete;
|
|
721
|
+
|
|
722
|
+
// Still true right after the optimistic flip — must not reset early.
|
|
723
|
+
expect(el.postInitComplete).to.be.true;
|
|
724
|
+
expect(el.lendingBarPostInit.called).to.be.false;
|
|
725
|
+
|
|
726
|
+
await aTimeout(50);
|
|
727
|
+
|
|
728
|
+
const collapsibleActionGroupEl = el.shadowRoot.querySelector(
|
|
729
|
+
'collapsible-action-group'
|
|
730
|
+
);
|
|
731
|
+
collapsibleActionGroupEl.dispatchEvent(
|
|
732
|
+
new CustomEvent('loanAutoRenewed', {
|
|
733
|
+
detail: { data: { loan: { identifier: 'foobar' } } },
|
|
734
|
+
})
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
await aTimeout(200);
|
|
738
|
+
await el.updateComplete;
|
|
739
|
+
|
|
740
|
+
// The confirmed renewal must have forced a fresh create_token cycle
|
|
741
|
+
// that re-ran lendingBarPostInit() (BookReader re-init) — postInitComplete
|
|
742
|
+
// itself ends up true again once that cycle completes, so we assert on
|
|
743
|
+
// the re-init actually having fired instead of the transient flag.
|
|
744
|
+
expect(el.lendingBarPostInit.calledOnce).to.be.true;
|
|
745
|
+
expect(el.recoveringFromLoanExpiry).to.be.false;
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('does not start the token poller (create_token) on the optimistic flip, only after renewal is confirmed', async () => {
|
|
749
|
+
// Regression: even without re-running lendingBarPostInit early, the
|
|
750
|
+
// token poller restart in setupLendingToolbarActions() was ungated —
|
|
751
|
+
// it fired on the optimistic lendingStatus flip too, calling
|
|
752
|
+
// create_token against the still-expired loan. When that failed,
|
|
753
|
+
// handleLendingActionError cleared ALL lending intervals (including
|
|
754
|
+
// the countdown timer) and flipped user_has_browsed false, sometimes
|
|
755
|
+
// permanently killing the countdown if it lost the race against the
|
|
756
|
+
// real renewal confirming afterward.
|
|
757
|
+
const el = await fixture(
|
|
758
|
+
container({
|
|
759
|
+
userid: '@user1',
|
|
760
|
+
identifier: 'foobar',
|
|
761
|
+
lendingStatus: {
|
|
762
|
+
user_has_browsed: true,
|
|
763
|
+
browsingExpired: true,
|
|
764
|
+
},
|
|
765
|
+
})
|
|
766
|
+
);
|
|
767
|
+
await el.updateComplete;
|
|
768
|
+
|
|
769
|
+
await el.localCache.set({
|
|
770
|
+
key: 'foobar-loanTime',
|
|
771
|
+
value: new Date(new Date().getTime() + 3600 * 1000),
|
|
772
|
+
ttl: 3600,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const startTokenPollerSpy = Sinon.spy(el, 'startLoanTokenPoller');
|
|
776
|
+
|
|
777
|
+
el.autoRenewExpiredLoan();
|
|
778
|
+
await el.updateComplete;
|
|
779
|
+
await aTimeout(150); // let the 100ms token-poller-restart check run
|
|
780
|
+
|
|
781
|
+
expect(startTokenPollerSpy.called).to.be.false;
|
|
782
|
+
|
|
783
|
+
await aTimeout(50);
|
|
784
|
+
const collapsibleActionGroupEl = el.shadowRoot.querySelector(
|
|
785
|
+
'collapsible-action-group'
|
|
786
|
+
);
|
|
787
|
+
collapsibleActionGroupEl.dispatchEvent(
|
|
788
|
+
new CustomEvent('loanAutoRenewed', {
|
|
789
|
+
detail: { data: { loan: { identifier: 'foobar' } } },
|
|
790
|
+
})
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
await aTimeout(200);
|
|
794
|
+
await el.updateComplete;
|
|
795
|
+
|
|
796
|
+
expect(startTokenPollerSpy.calledOnce).to.be.true;
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('does not touch postInitComplete on a routine (non-expiry) renewal', async () => {
|
|
800
|
+
// A background top-up renewal (loan never actually lapsed) never
|
|
801
|
+
// interrupted BookReader, so it must not force a re-init.
|
|
802
|
+
const el = await fixture(
|
|
803
|
+
container({
|
|
804
|
+
userid: '@user1',
|
|
805
|
+
identifier: 'foobar',
|
|
806
|
+
lendingStatus: { user_has_browsed: true, browsingExpired: false },
|
|
807
|
+
})
|
|
808
|
+
);
|
|
809
|
+
await el.updateComplete;
|
|
810
|
+
el.postInitComplete = true;
|
|
811
|
+
el.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
|
|
812
|
+
el.lendingBarPostInit = Sinon.spy();
|
|
813
|
+
|
|
814
|
+
await el.localCache.set({
|
|
815
|
+
key: 'foobar-loanTime',
|
|
816
|
+
value: new Date(new Date().getTime() + 3600 * 1000),
|
|
817
|
+
ttl: 3600,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
const collapsibleActionGroupEl = el.shadowRoot.querySelector(
|
|
821
|
+
'collapsible-action-group'
|
|
822
|
+
);
|
|
823
|
+
collapsibleActionGroupEl.dispatchEvent(
|
|
824
|
+
new CustomEvent('loanAutoRenewed', {
|
|
825
|
+
detail: { data: { loan: { identifier: 'foobar' } } },
|
|
826
|
+
})
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
await aTimeout(100);
|
|
830
|
+
await el.updateComplete;
|
|
831
|
+
|
|
832
|
+
expect(el.lendingBarPostInit.called).to.be.false;
|
|
833
|
+
});
|
|
834
|
+
|
|
692
835
|
it('clears loanRenewInProgress and shows unavailable modal on renew_loan failure', async () => {
|
|
693
836
|
const el = await fixture(
|
|
694
837
|
container({
|
|
@@ -719,6 +862,358 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
719
862
|
});
|
|
720
863
|
});
|
|
721
864
|
|
|
865
|
+
describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
|
|
866
|
+
it('does not let autoLoanRenewChecker clobber an in-flight expired-loan renewal', async () => {
|
|
867
|
+
// Get borrowType derived as 'browsed' first, same as the
|
|
868
|
+
// "Expiring book cancels interval" test above — hasExpired renders
|
|
869
|
+
// leave borrowType untouched, so it must already be 'browsed' before
|
|
870
|
+
// the loan is marked expired.
|
|
871
|
+
const baseStatus = {
|
|
872
|
+
is_lendable: true,
|
|
873
|
+
user_has_browsed: true,
|
|
874
|
+
browsingExpired: false,
|
|
875
|
+
};
|
|
876
|
+
const el = await fixture(
|
|
877
|
+
container({
|
|
878
|
+
userid: '@user1',
|
|
879
|
+
identifier: 'foobar',
|
|
880
|
+
lendingStatus: baseStatus,
|
|
881
|
+
})
|
|
882
|
+
);
|
|
883
|
+
await el.updateComplete;
|
|
884
|
+
expect(el.borrowType).to.equal('browsed');
|
|
885
|
+
|
|
886
|
+
el.lendingStatus = { ...baseStatus, browsingExpired: true };
|
|
887
|
+
await el.updateComplete;
|
|
888
|
+
|
|
889
|
+
const autoLoanRenewCheckerSpy = Sinon.spy(el, 'autoLoanRenewChecker');
|
|
890
|
+
|
|
891
|
+
window.dispatchEvent(new CustomEvent('BookReader:userAction'));
|
|
892
|
+
|
|
893
|
+
// autoRenewExpiredLoan()'s setTimeout(0) sets renewNow=true. If the old
|
|
894
|
+
// bug were present, the still-live lendingStatus.browsingExpired check
|
|
895
|
+
// would (wrongly) also let autoLoanRenewChecker(true) run — which reads
|
|
896
|
+
// the already-deleted loanTime cache key and overwrites renewNow back
|
|
897
|
+
// to false. Give both paths plenty of time to resolve.
|
|
898
|
+
await aTimeout(300);
|
|
899
|
+
|
|
900
|
+
expect(autoLoanRenewCheckerSpy.called).to.be.false;
|
|
901
|
+
expect(el.loanRenewResult.renewNow).to.be.true;
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it('ignores repeated BookReader:userAction events firing in quick succession (e.g. from a scroll)', async () => {
|
|
905
|
+
// Regression: a single scroll gesture can fire BookReader:userAction
|
|
906
|
+
// several times in rapid succession. autoLoanRenewChecker() had no
|
|
907
|
+
// guard against concurrent/repeated invocation, unlike
|
|
908
|
+
// autoRenewExpiredLoan() — each event spun up its own LoanRenewHelper,
|
|
909
|
+
// racing to overwrite this.loanRenewResult and potentially
|
|
910
|
+
// re-triggering renew_loan/create_token while a prior call from an
|
|
911
|
+
// earlier event was still in flight or had just landed.
|
|
912
|
+
const baseStatus = {
|
|
913
|
+
is_lendable: true,
|
|
914
|
+
user_has_browsed: true,
|
|
915
|
+
browsingExpired: false,
|
|
916
|
+
};
|
|
917
|
+
const el = await fixture(
|
|
918
|
+
container({
|
|
919
|
+
userid: '@user1',
|
|
920
|
+
identifier: 'foobar',
|
|
921
|
+
lendingStatus: baseStatus,
|
|
922
|
+
})
|
|
923
|
+
);
|
|
924
|
+
await el.updateComplete;
|
|
925
|
+
expect(el.borrowType).to.equal('browsed');
|
|
926
|
+
|
|
927
|
+
el.loanRenewInProgress = true;
|
|
928
|
+
|
|
929
|
+
const loanRenewHelperSpy = Sinon.spy(el, 'autoLoanRenewChecker');
|
|
930
|
+
|
|
931
|
+
// Simulate a scroll firing the event several times back to back.
|
|
932
|
+
window.dispatchEvent(new CustomEvent('BookReader:userAction'));
|
|
933
|
+
window.dispatchEvent(new CustomEvent('BookReader:userAction'));
|
|
934
|
+
window.dispatchEvent(new CustomEvent('BookReader:userAction'));
|
|
935
|
+
await aTimeout(50);
|
|
936
|
+
|
|
937
|
+
// The method is still called each time (the guard is inside it), but
|
|
938
|
+
// must no-op instead of spinning up a new LoanRenewHelper each time.
|
|
939
|
+
expect(loanRenewHelperSpy.callCount).to.equal(3);
|
|
940
|
+
expect(el.loanRenewHelper).to.be.undefined;
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
describe('handleLendingActionError - loanRenewInProgress reset', () => {
|
|
945
|
+
it('clears loanRenewInProgress on any renew_loan failure, not just "not available"', async () => {
|
|
946
|
+
const el = await fixture(
|
|
947
|
+
container({
|
|
948
|
+
userid: '@user1',
|
|
949
|
+
identifier: 'foobar',
|
|
950
|
+
lendingStatus: { user_has_browsed: true, browsingExpired: true },
|
|
951
|
+
})
|
|
952
|
+
);
|
|
953
|
+
await el.updateComplete;
|
|
954
|
+
|
|
955
|
+
el.loanRenewInProgress = true;
|
|
956
|
+
|
|
957
|
+
el.handleLendingActionError({
|
|
958
|
+
detail: {
|
|
959
|
+
action: 'renew_loan',
|
|
960
|
+
data: { error: 'some other unrelated failure' },
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
expect(el.loanRenewInProgress).to.be.false;
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
describe('handleLendingActionError - create_token failures must not stop the countdown (WEBDEV-8322 follow-up)', () => {
|
|
969
|
+
it('does not clear the reading countdown on a create_token failure', async () => {
|
|
970
|
+
const el = await fixture(
|
|
971
|
+
container({
|
|
972
|
+
userid: '@user1',
|
|
973
|
+
identifier: 'foobar',
|
|
974
|
+
lendingStatus: {
|
|
975
|
+
user_has_browsed: true,
|
|
976
|
+
browsingExpired: false,
|
|
977
|
+
secondsLeftOnLoan: 100,
|
|
978
|
+
},
|
|
979
|
+
})
|
|
980
|
+
);
|
|
981
|
+
await el.updateComplete;
|
|
982
|
+
expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
|
|
983
|
+
|
|
984
|
+
el.handleLendingActionError({
|
|
985
|
+
detail: {
|
|
986
|
+
action: 'create_token',
|
|
987
|
+
data: { error: 'loan token not found. please try again later.' },
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// The countdown must keep running — a token refresh hiccup says
|
|
992
|
+
// nothing about how much time is left on the loan itself.
|
|
993
|
+
expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it('shows the error modal for a create_token failure that reaches here (retries already exhausted)', async () => {
|
|
997
|
+
// By the time handleLendingActionError runs for a create_token
|
|
998
|
+
// failure, LoanTokenPoller has already retried and given up (see
|
|
999
|
+
// loan-token-poller.js's handleTokenError) — this is a genuine
|
|
1000
|
+
// failure, so the patron must be told instead of it happening
|
|
1001
|
+
// silently.
|
|
1002
|
+
const el = await fixture(
|
|
1003
|
+
container({
|
|
1004
|
+
userid: '@user1',
|
|
1005
|
+
identifier: 'foobar',
|
|
1006
|
+
lendingStatus: {
|
|
1007
|
+
user_has_browsed: true,
|
|
1008
|
+
browsingExpired: false,
|
|
1009
|
+
secondsLeftOnLoan: 100,
|
|
1010
|
+
},
|
|
1011
|
+
})
|
|
1012
|
+
);
|
|
1013
|
+
await el.updateComplete;
|
|
1014
|
+
|
|
1015
|
+
const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
|
|
1016
|
+
const errorMsg = 'loan token not found. please try again later.';
|
|
1017
|
+
|
|
1018
|
+
el.handleLendingActionError({
|
|
1019
|
+
detail: {
|
|
1020
|
+
action: 'create_token',
|
|
1021
|
+
data: { error: errorMsg },
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
expect(showErrorModalSpy.calledOnceWith(errorMsg, 'create_token')).to.be
|
|
1026
|
+
.true;
|
|
1027
|
+
expect(el.lendingStatus.user_has_browsed).to.be.false;
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it('still clears everything on a renew_loan failure', async () => {
|
|
1031
|
+
const el = await fixture(
|
|
1032
|
+
container({
|
|
1033
|
+
userid: '@user1',
|
|
1034
|
+
identifier: 'foobar',
|
|
1035
|
+
lendingStatus: {
|
|
1036
|
+
user_has_browsed: true,
|
|
1037
|
+
browsingExpired: false,
|
|
1038
|
+
secondsLeftOnLoan: 100,
|
|
1039
|
+
},
|
|
1040
|
+
})
|
|
1041
|
+
);
|
|
1042
|
+
await el.updateComplete;
|
|
1043
|
+
expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
|
|
1044
|
+
|
|
1045
|
+
el.handleLendingActionError({
|
|
1046
|
+
detail: {
|
|
1047
|
+
action: 'renew_loan',
|
|
1048
|
+
data: { error: 'some other unrelated failure' },
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
expect(window.IALendingIntervals.timerCountdown).to.equal(0);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('shows the same unavailable-modal-and-refresh path for any renew_loan failure, regardless of message text', async () => {
|
|
1056
|
+
// Regression: branching on errorMsg.match(/not available/) to decide
|
|
1057
|
+
// how to recover was fragile — the server can return any message
|
|
1058
|
+
// (e.g. a lending limit hit), and guessing which lendingStatus fields
|
|
1059
|
+
// are now accurate client-side is error-prone. Every renew_loan
|
|
1060
|
+
// failure now shows the real error and refreshes the page on
|
|
1061
|
+
// dismissal (showLoanUnavailableModal's Okay button), rather than
|
|
1062
|
+
// trying to patch up state without reloading from the server.
|
|
1063
|
+
const el = await fixture(
|
|
1064
|
+
container({
|
|
1065
|
+
userid: '@user1',
|
|
1066
|
+
identifier: 'foobar',
|
|
1067
|
+
lendingStatus: {
|
|
1068
|
+
is_lendable: true,
|
|
1069
|
+
user_has_browsed: true,
|
|
1070
|
+
browsingExpired: false,
|
|
1071
|
+
available_to_browse: false,
|
|
1072
|
+
secondsLeftOnLoan: 100,
|
|
1073
|
+
},
|
|
1074
|
+
})
|
|
1075
|
+
);
|
|
1076
|
+
await el.updateComplete;
|
|
1077
|
+
|
|
1078
|
+
const showUnavailableSpy = Sinon.spy(el, 'showLoanUnavailableModal');
|
|
1079
|
+
const lendingLimitMsg =
|
|
1080
|
+
'Your account has hit a lending limit. Please try again later or contact info@archive.org.';
|
|
1081
|
+
|
|
1082
|
+
el.handleLendingActionError({
|
|
1083
|
+
detail: {
|
|
1084
|
+
action: 'renew_loan',
|
|
1085
|
+
data: { error: lendingLimitMsg },
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
await el.updateComplete;
|
|
1089
|
+
|
|
1090
|
+
expect(showUnavailableSpy.calledOnceWith(lendingLimitMsg)).to.be.true;
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it('updates lendingStatus immediately on a renew_loan failure — Borrow state, timer cleared', async () => {
|
|
1094
|
+
// The modal's Okay button refreshes the page, but that shouldn't be
|
|
1095
|
+
// the only thing correcting the UI — while the modal is still open,
|
|
1096
|
+
// the action bar must already show Borrow instead of "Return now"
|
|
1097
|
+
// with a stale countdown value from before the failed renewal.
|
|
1098
|
+
const el = await fixture(
|
|
1099
|
+
container({
|
|
1100
|
+
userid: '@user1',
|
|
1101
|
+
identifier: 'foobar',
|
|
1102
|
+
lendingStatus: {
|
|
1103
|
+
is_lendable: true,
|
|
1104
|
+
user_has_browsed: true,
|
|
1105
|
+
browsingExpired: false,
|
|
1106
|
+
available_to_browse: false,
|
|
1107
|
+
secondsLeftOnLoan: 100,
|
|
1108
|
+
},
|
|
1109
|
+
})
|
|
1110
|
+
);
|
|
1111
|
+
await el.updateComplete;
|
|
1112
|
+
|
|
1113
|
+
el.handleLendingActionError({
|
|
1114
|
+
detail: {
|
|
1115
|
+
action: 'renew_loan',
|
|
1116
|
+
data: {
|
|
1117
|
+
error:
|
|
1118
|
+
'Your account has hit a lending limit. Please try again later or contact info@archive.org.',
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
await el.updateComplete;
|
|
1123
|
+
|
|
1124
|
+
expect(el.lendingStatus.user_has_browsed).to.be.false;
|
|
1125
|
+
expect(el.lendingStatus.available_to_browse).to.be.true;
|
|
1126
|
+
expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
|
|
1127
|
+
expect(el.primaryActions[0].text).to.not.equal('Return now');
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it('also shows the unavailable modal for the "not available" message, with its own text', async () => {
|
|
1131
|
+
const el = await fixture(
|
|
1132
|
+
container({
|
|
1133
|
+
userid: '@user1',
|
|
1134
|
+
identifier: 'foobar',
|
|
1135
|
+
lendingStatus: {
|
|
1136
|
+
is_lendable: true,
|
|
1137
|
+
user_has_browsed: true,
|
|
1138
|
+
browsingExpired: false,
|
|
1139
|
+
secondsLeftOnLoan: 100,
|
|
1140
|
+
},
|
|
1141
|
+
})
|
|
1142
|
+
);
|
|
1143
|
+
await el.updateComplete;
|
|
1144
|
+
|
|
1145
|
+
const showUnavailableSpy = Sinon.spy(el, 'showLoanUnavailableModal');
|
|
1146
|
+
const notAvailableMsg = 'This book is not available to borrow at this time.';
|
|
1147
|
+
|
|
1148
|
+
el.handleLendingActionError({
|
|
1149
|
+
detail: {
|
|
1150
|
+
action: 'renew_loan',
|
|
1151
|
+
data: { error: notAvailableMsg },
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
await el.updateComplete;
|
|
1155
|
+
|
|
1156
|
+
expect(showUnavailableSpy.calledOnceWith(notAvailableMsg)).to.be.true;
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
describe('showWarningModal', () => {
|
|
1161
|
+
it('shows a headline, one Okay button, and an info-icon help link; Okay dismisses without renewing', async () => {
|
|
1162
|
+
const el = await fixture(
|
|
1163
|
+
container({
|
|
1164
|
+
userid: '@user1',
|
|
1165
|
+
identifier: 'foobar',
|
|
1166
|
+
lendingStatus: { user_has_browsed: true, secondsLeftOnLoan: 100 },
|
|
1167
|
+
})
|
|
1168
|
+
);
|
|
1169
|
+
await el.updateComplete;
|
|
1170
|
+
|
|
1171
|
+
await el.showWarningModal();
|
|
1172
|
+
|
|
1173
|
+
const modalManagerEl = document.body.querySelector('modal-manager');
|
|
1174
|
+
const modalTemplateEl =
|
|
1175
|
+
modalManagerEl.shadowRoot.querySelector('modal-template');
|
|
1176
|
+
const headline =
|
|
1177
|
+
modalTemplateEl.shadowRoot.querySelector('.headline')?.textContent;
|
|
1178
|
+
expect(headline).to.contain('Are you still there?');
|
|
1179
|
+
|
|
1180
|
+
const helpLink = modalTemplateEl.shadowRoot.querySelector(
|
|
1181
|
+
'a[href="https://help.archive.org/help/borrowing-from-the-lending-library"]'
|
|
1182
|
+
);
|
|
1183
|
+
expect(helpLink).to.exist;
|
|
1184
|
+
|
|
1185
|
+
const buttons = modalManagerEl.shadowRoot.querySelectorAll(
|
|
1186
|
+
'#book-action-bar-custom-buttons button'
|
|
1187
|
+
);
|
|
1188
|
+
expect(buttons.length).to.equal(1);
|
|
1189
|
+
expect(buttons[0].textContent).to.contain('Okay');
|
|
1190
|
+
|
|
1191
|
+
buttons[0].click();
|
|
1192
|
+
|
|
1193
|
+
expect(el.warningModalOpen).to.be.false;
|
|
1194
|
+
expect(el.warningModalDismissed).to.be.true;
|
|
1195
|
+
expect(el.loanRenewResult.renewNow).to.be.false;
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
it('does not reopen while already open', async () => {
|
|
1199
|
+
const el = await fixture(
|
|
1200
|
+
container({
|
|
1201
|
+
userid: '@user1',
|
|
1202
|
+
identifier: 'foobar',
|
|
1203
|
+
lendingStatus: { user_has_browsed: true, secondsLeftOnLoan: 100 },
|
|
1204
|
+
})
|
|
1205
|
+
);
|
|
1206
|
+
await el.updateComplete;
|
|
1207
|
+
|
|
1208
|
+
await el.showWarningModal();
|
|
1209
|
+
const showModalSpy = Sinon.spy(el.modal, 'showModal');
|
|
1210
|
+
|
|
1211
|
+
await el.showWarningModal();
|
|
1212
|
+
|
|
1213
|
+
expect(showModalSpy.called).to.be.false;
|
|
1214
|
+
});
|
|
1215
|
+
});
|
|
1216
|
+
|
|
722
1217
|
describe('Shared Resize Observer', () => {
|
|
723
1218
|
it('can receive a Shared Resize Observer', async () => {
|
|
724
1219
|
const sharedObserverStub = new SharedResizeObserver();
|