@internetarchive/ia-book-actions 1.4.4-alpha14 → 1.4.4-alpha15

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