@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 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 = ['[ActionsHandlerService]', '[IABookActions]', '[Demo]'];
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: 110, pageChangedInLast: 30 },
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">3. Interact with book (turn page)<br>silently renew if interact in given time</button>
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
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.4-alpha1",
10
+ "version": "1.4.4-alpha10",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -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(`[ActionsHandlerService] ✓ ${option.action} succeeded`, data);
87
+ log(`[IABookActions] ✓ ${option.action} succeeded`, data);
88
88
  option?.success(data);
89
89
  } else {
90
- log(`[ActionsHandlerService] ✗ ${option.action} failed`, data);
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', async ({ detail }) => {
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, action, data.loan, this.identifier);
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
- async handleLoanTokenPoller(isInitial = false) {
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
  }
@@ -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 (!hasExpired && !window.IALendingIntervals.tokenPoller)
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: this.lendingStatus.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 (this.lendingStatus.browsingExpired) {
350
+ if (wasExpired) {
314
351
  this.autoRenewExpiredLoan();
315
352
  }
316
353
 
317
- if (this.borrowType === 'browsed' && !this.lendingStatus.browsingExpired) {
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: '#194880',
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.renew}"
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] startTimerCountdown: timer re-synced', {
788
+ log('[IABookActions] timer: timer re-synced', {
722
789
  secondsLeft,
723
790
  });
724
791
  }
725
792
 
726
- log('[IABookActions] startTimerCountdown', {
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
- const currStatus = {
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
- // update action bar state if book is not available to browse or borrow.
914
- if (errorMsg && errorMsg.match(/not available to borrow/gm)) {
915
- let currStatus = this.lendingStatus;
916
- if (action === 'browse_book') {
917
- currStatus = {
918
- ...this.lendingStatus,
919
- available_to_browse: false,
920
- };
921
- } else if (action === 'borrow_book') {
922
- currStatus = {
923
- ...this.lendingStatus,
924
- available_to_borrow: false,
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();