@internetarchive/ia-book-actions 1.4.4-alpha3 → 1.4.4-alpha5

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/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-alpha3",
10
+ "version": "1.4.4-alpha5",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -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
  /**
@@ -290,10 +299,19 @@ export default class IABookActions extends LitElement {
290
299
 
291
300
  /**
292
301
  * tokenPoller determines if user has loan token for this book
293
- * - 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.
294
308
  */
295
309
  setTimeout(() => {
296
- if (!hasExpired && !window.IALendingIntervals.tokenPoller)
310
+ if (
311
+ !hasExpired &&
312
+ !this.loanRenewInProgress &&
313
+ !window.IALendingIntervals.tokenPoller
314
+ )
297
315
  this.startLoanTokenPoller();
298
316
  }, 100);
299
317
 
@@ -412,19 +430,10 @@ export default class IABookActions extends LitElement {
412
430
  autoRenewExpiredLoan() {
413
431
  if (this.loanRenewInProgress) return;
414
432
  this.loanRenewInProgress = true;
433
+ this.recoveringFromLoanExpiry = true;
415
434
 
416
435
  this.modal?.closeModal();
417
436
 
418
- // The loan genuinely lapsed while the tab was hidden/backgrounded, so
419
- // BookReader's page-image access (issued via create_token, not
420
- // renew_loan) died with it. Resetting this lets the token poller's
421
- // next successful create_token call re-run lendingBarPostInit() —
422
- // whatever re-initializes BookReader (e.g. br.init()) — instead of
423
- // silently skipping it because a token was already issued once this
424
- // page load. Without this, the loan's countdown resets but BookReader
425
- // keeps using its now-dead session, so page images break.
426
- this.postInitComplete = false;
427
-
428
437
  // Optimistically flip browsingExpired back to false so the action bar
429
438
  // stays red (patronIsReadingAction) while the renew_loan request is in
430
439
  // flight, instead of showing the blue "Borrow" state.
@@ -666,6 +675,7 @@ export default class IABookActions extends LitElement {
666
675
  const activeLoan = detail?.data?.loan;
667
676
  const errorMessage = `Whoops, seems we hit a hiccup with renewing this book. Please refresh & retry. --- (Debug: ${detail?.data?.error})`;
668
677
  if (!activeLoan) {
678
+ this.recoveringFromLoanExpiry = false;
669
679
  this.showErrorModal(errorMessage, 'handleLoanAutoRenewed');
670
680
  return;
671
681
  }
@@ -688,6 +698,18 @@ export default class IABookActions extends LitElement {
688
698
  ajaxResponse: detail?.data,
689
699
  });
690
700
 
701
+ if (this.recoveringFromLoanExpiry) {
702
+ // The loan genuinely lapsed, and renew_loan has now CONFIRMED it's
703
+ // renewed server-side — only now is it safe to let BookReader
704
+ // re-initialize (create_token was minting its access cookie off
705
+ // the loan's expiry; doing this before confirmation risked reading
706
+ // the still-expired record). loanRenewInProgress already kept the
707
+ // token poller from restarting early, so it'll pick up fresh here
708
+ // once lendingStatus below re-triggers setupLendingToolbarActions().
709
+ this.postInitComplete = false;
710
+ this.recoveringFromLoanExpiry = false;
711
+ }
712
+
691
713
  const currStatus = {
692
714
  ...this.lendingStatus,
693
715
  user_has_browsed: true,
@@ -909,14 +931,24 @@ export default class IABookActions extends LitElement {
909
931
  handleLendingActionError(event) {
910
932
  this.disableActionGroup = false;
911
933
 
912
- // clear all intervals for lending system when error occured
913
- window?.IALendingIntervals?.clearAll();
914
-
915
934
  const action = event?.detail?.action;
916
935
  const errorMsg = event?.detail?.data?.error;
917
936
 
937
+ // A create_token hiccup only affects BookReader's page-image access
938
+ // token — it says nothing about how much time is left on the loan
939
+ // itself, so it must not stop the reading countdown. Only clear the
940
+ // token poller so it can be retried. Every other action failure
941
+ // (renew_loan, browse_book, etc.) genuinely affects loan state, so it
942
+ // still clears everything as before.
943
+ if (action === 'create_token') {
944
+ window?.IALendingIntervals?.clearTokenPoller();
945
+ } else {
946
+ window?.IALendingIntervals?.clearAll();
947
+ }
948
+
918
949
  if (action === 'renew_loan') {
919
950
  this.loanRenewInProgress = false;
951
+ this.recoveringFromLoanExpiry = false;
920
952
  }
921
953
 
922
954
  // For renew_loan failures after auto-renew, show the patron-facing message
@@ -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(100);
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({
@@ -783,6 +926,60 @@ describe('handleLendingActionError - loanRenewInProgress reset', () => {
783
926
  });
784
927
  });
785
928
 
929
+ describe('handleLendingActionError - create_token failures must not stop the countdown (WEBDEV-8322 follow-up)', () => {
930
+ it('does not clear the reading countdown on a create_token failure', async () => {
931
+ const el = await fixture(
932
+ container({
933
+ userid: '@user1',
934
+ identifier: 'foobar',
935
+ lendingStatus: {
936
+ user_has_browsed: true,
937
+ browsingExpired: false,
938
+ secondsLeftOnLoan: 100,
939
+ },
940
+ })
941
+ );
942
+ await el.updateComplete;
943
+ expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
944
+
945
+ el.handleLendingActionError({
946
+ detail: {
947
+ action: 'create_token',
948
+ data: { error: 'loan token not found. please try again later.' },
949
+ },
950
+ });
951
+
952
+ // The countdown must keep running — a token refresh hiccup says
953
+ // nothing about how much time is left on the loan itself.
954
+ expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
955
+ });
956
+
957
+ it('still clears everything on a renew_loan failure', async () => {
958
+ const el = await fixture(
959
+ container({
960
+ userid: '@user1',
961
+ identifier: 'foobar',
962
+ lendingStatus: {
963
+ user_has_browsed: true,
964
+ browsingExpired: false,
965
+ secondsLeftOnLoan: 100,
966
+ },
967
+ })
968
+ );
969
+ await el.updateComplete;
970
+ expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
971
+
972
+ el.handleLendingActionError({
973
+ detail: {
974
+ action: 'renew_loan',
975
+ data: { error: 'some other unrelated failure' },
976
+ },
977
+ });
978
+
979
+ expect(window.IALendingIntervals.timerCountdown).to.equal(0);
980
+ });
981
+ });
982
+
786
983
  describe('showWarningModal', () => {
787
984
  it('shows a headline, one Okay button, and an info-icon help link; Okay dismisses without renewing', async () => {
788
985
  const el = await fixture(