@internetarchive/ia-book-actions 1.4.4-alpha2 → 1.4.4-alpha4

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-alpha2",
10
+ "version": "1.4.4-alpha4",
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,6 +430,7 @@ 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
 
@@ -656,6 +675,7 @@ export default class IABookActions extends LitElement {
656
675
  const activeLoan = detail?.data?.loan;
657
676
  const errorMessage = `Whoops, seems we hit a hiccup with renewing this book. Please refresh & retry. --- (Debug: ${detail?.data?.error})`;
658
677
  if (!activeLoan) {
678
+ this.recoveringFromLoanExpiry = false;
659
679
  this.showErrorModal(errorMessage, 'handleLoanAutoRenewed');
660
680
  return;
661
681
  }
@@ -678,6 +698,18 @@ export default class IABookActions extends LitElement {
678
698
  ajaxResponse: detail?.data,
679
699
  });
680
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
+
681
713
  const currStatus = {
682
714
  ...this.lendingStatus,
683
715
  user_has_browsed: true,
@@ -899,14 +931,24 @@ export default class IABookActions extends LitElement {
899
931
  handleLendingActionError(event) {
900
932
  this.disableActionGroup = false;
901
933
 
902
- // clear all intervals for lending system when error occured
903
- window?.IALendingIntervals?.clearAll();
904
-
905
934
  const action = event?.detail?.action;
906
935
  const errorMsg = event?.detail?.data?.error;
907
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
+
908
949
  if (action === 'renew_loan') {
909
950
  this.loanRenewInProgress = false;
951
+ this.recoveringFromLoanExpiry = false;
910
952
  }
911
953
 
912
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(