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

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-alpha15",
10
+ "version": "1.4.4-alpha16",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -117,8 +117,5 @@ export default async function ActionsHandlerService(options) {
117
117
  * connection is exactly when this fires.
118
118
  */
119
119
  log(`[IABookActions] ✗ ${option.action} threw`, error);
120
- option?.error({
121
- error: `Could not reach the lending service. Please check your connection and try again. (${error})`,
122
- });
123
120
  }
124
121
  }
@@ -10,12 +10,39 @@ import log from './log.js';
10
10
  * ActionsHandlerService is a function being used to execute
11
11
  */
12
12
  export class LoanTokenPoller {
13
- constructor(id, borrowType, successCallback, errorCallback, pollerDelay) {
14
- this.identifier = id;
13
+ /**
14
+ * @param {Object} options
15
+ * @param {string} options.identifier
16
+ * @param {string} options.borrowType
17
+ * @param {Function} options.successCallback - called after the initial
18
+ * loan token is created.
19
+ * @param {Function} options.errorCallback - called on any create_token
20
+ * failure (initial or a routine interval tick).
21
+ * @param {number} options.pollerDelay - interval between routine
22
+ * create_token checks, in seconds.
23
+ * @param {boolean} [options.skipInitialCall] - skip the immediate
24
+ * create_token call, only start the recurring interval. Used after a
25
+ * loan-expiry recovery renewal, where BookLoanService::
26
+ * attempt_to_renew_loan() already minted a valid access token as part
27
+ * of the renew_loan response itself — an immediate confirming
28
+ * create_token call is redundant.
29
+ */
30
+ constructor(options = {}) {
31
+ const {
32
+ identifier,
33
+ borrowType,
34
+ successCallback,
35
+ errorCallback,
36
+ pollerDelay,
37
+ skipInitialCall = false,
38
+ } = options;
39
+
40
+ this.identifier = identifier;
15
41
  this.borrowType = borrowType;
16
42
  this.successCallback = successCallback; // callback function to be called after loan token is created
17
43
  this.errorCallback = errorCallback; // callback function to be called after loan token is created
18
44
  this.pollerDelay = pollerDelay; // value in seconds
45
+ this.skipInitialCall = skipInitialCall === true;
19
46
 
20
47
  this.loanTokenInterval = undefined;
21
48
 
@@ -34,8 +61,14 @@ export class LoanTokenPoller {
34
61
 
35
62
  async bookAccessed() {
36
63
  if (this.borrowType) {
37
- // Do an initial token, then set an interval
38
- this.handleLoanTokenPoller(true);
64
+ if (this.skipInitialCall) {
65
+ log('[LoanTokenPoller] skipping initial create_token — already minted by the renewal response', {
66
+ identifier: this.identifier,
67
+ });
68
+ } else {
69
+ // Do an initial token, then set an interval
70
+ this.handleLoanTokenPoller(true);
71
+ }
39
72
 
40
73
  // if this.borrowType = adminBorrowed,
41
74
  // - we don't want to fetch token on interval
@@ -104,6 +104,15 @@ export default class IABookActions extends LitElement {
104
104
  */
105
105
  this.recoveringFromLoanExpiry = false;
106
106
 
107
+ /**
108
+ * True right after a confirmed renewal (any kind), until the token
109
+ * poller restarts. renew_loan's own response already carries a valid
110
+ * access token, so that restart should skip its normal immediate
111
+ * create_token call — see handleLoanAutoRenewed/
112
+ * setupLendingToolbarActions.
113
+ */
114
+ this.skipNextInitialTokenCall = false;
115
+
107
116
  this.warningModalOpen = false;
108
117
 
109
118
  /** Once dismissed, don't re-show the warning modal every tick — only
@@ -318,8 +327,14 @@ export default class IABookActions extends LitElement {
318
327
  !this.loanRenewInProgress &&
319
328
  !window.IALendingIntervals.tokenPoller
320
329
  ) {
330
+ // Any confirmed renewal's response already minted a valid access
331
+ // token (see handleLoanAutoRenewed) — the poller only needs to
332
+ // start its recurring check, not fire an immediate confirming
333
+ // create_token call too.
334
+ const skipInitialCall = this.skipNextInitialTokenCall;
335
+ this.skipNextInitialTokenCall = false;
321
336
  this.recoveringFromLoanExpiry = false;
322
- this.startLoanTokenPoller();
337
+ this.startLoanTokenPoller(skipInitialCall);
323
338
  }
324
339
  }, 100);
325
340
  }
@@ -740,10 +755,22 @@ export default class IABookActions extends LitElement {
740
755
  ajaxResponse: detail?.data,
741
756
  });
742
757
 
758
+ // BookLoanService::attempt_to_renew_loan() mints a valid access
759
+ // token as part of EVERY renew_loan response, not just a recovery
760
+ // from expiry — so the poller restart below never needs to fire a
761
+ // redundant confirming create_token call, regardless of which path
762
+ // triggered this renewal.
763
+ this.skipNextInitialTokenCall = true;
764
+
743
765
  if (this.recoveringFromLoanExpiry) {
744
766
  // Only now is it safe to re-initialize BookReader — not for a
745
- // routine top-up, where that would be disruptive.
746
- this.postInitComplete = false;
767
+ // routine top-up, where that would be disruptive. Call it
768
+ // directly rather than waiting on a confirming create_token
769
+ // success: BookLoanService::attempt_to_renew_loan() already
770
+ // minted a valid access token as part of this renew_loan
771
+ // response, so there's nothing left to confirm.
772
+ this.lendingBarPostInit();
773
+ this.postInitComplete = true;
747
774
  }
748
775
 
749
776
  const currStatus = {
@@ -895,9 +922,10 @@ export default class IABookActions extends LitElement {
895
922
 
896
923
  /**
897
924
  * enable access of borrowed/browsed books
925
+ * @param {boolean} [skipInitialCall] - see LoanTokenPoller's constructor doc
898
926
  * @see LoanTokenPoller
899
927
  */
900
- startLoanTokenPoller() {
928
+ startLoanTokenPoller(skipInitialCall = false) {
901
929
  const successCallback = () => {
902
930
  if (!this.postInitComplete) {
903
931
  this.lendingBarPostInit();
@@ -911,13 +939,14 @@ export default class IABookActions extends LitElement {
911
939
  // Tear down any previous poller first, so only one is ever controlling
912
940
  // window.IALendingIntervals.tokenPoller at a time.
913
941
  this.tokenPoller?.disconnectedCallback();
914
- this.tokenPoller = new LoanTokenPoller(
915
- this.identifier,
916
- this.borrowType,
942
+ this.tokenPoller = new LoanTokenPoller({
943
+ identifier: this.identifier,
944
+ borrowType: this.borrowType,
917
945
  successCallback,
918
946
  errorCallback,
919
- this.tokenDelay // in seconds
920
- );
947
+ pollerDelay: this.tokenDelay, // in seconds
948
+ skipInitialCall,
949
+ });
921
950
  }
922
951
 
923
952
  /*
@@ -17,18 +17,17 @@ afterEach(() => {
17
17
 
18
18
  describe('Get Loan Token', () => {
19
19
  it('get loan token for browsed books', async () => {
20
- // id, borrowType, successCallback, errorCallback, pollerDelay
21
- const tokenPoller = new LoanTokenPoller(
22
- 'identifier1',
23
- 'browsed',
24
- () => {
20
+ const tokenPoller = new LoanTokenPoller({
21
+ identifier: 'identifier1',
22
+ borrowType: 'browsed',
23
+ successCallback: () => {
25
24
  console.log('success callback is executed!');
26
25
  },
27
- () => {
26
+ errorCallback: () => {
28
27
  console.log('error callback is executed!');
29
28
  },
30
- 2000 // 2 minutes'
31
- );
29
+ pollerDelay: 2000, // 2 minutes'
30
+ });
32
31
  const successCallbackSpy = Sinon.stub(tokenPoller, 'successCallback');
33
32
  successCallbackSpy();
34
33
  expect(successCallbackSpy.callCount).to.equal(1);
@@ -39,13 +38,13 @@ describe('Get Loan Token', () => {
39
38
  });
40
39
 
41
40
  it('get loan token for admin borrowed books', async () => {
42
- const tokenPoller = new LoanTokenPoller(
43
- 'identifier1',
44
- 'adminBorrowed',
45
- () => {},
46
- () => {},
47
- 2000 // 2 minutes'
48
- );
41
+ const tokenPoller = new LoanTokenPoller({
42
+ identifier: 'identifier1',
43
+ borrowType: 'adminBorrowed',
44
+ successCallback: () => {},
45
+ errorCallback: () => {},
46
+ pollerDelay: 2000, // 2 minutes'
47
+ });
49
48
 
50
49
  const successCallbackSpy = Sinon.stub(tokenPoller, 'successCallback');
51
50
  successCallbackSpy();
@@ -61,13 +60,13 @@ describe('handleTokenError (no retry)', () => {
61
60
  // A create_token failure is treated as terminal on the first attempt —
62
61
  // no retry, whether it's the "stale loan read" race or anything else.
63
62
  const makeTokenPoller = () =>
64
- new LoanTokenPoller(
65
- 'identifier1',
66
- 'browsed',
67
- () => {},
68
- () => {},
69
- 2000
70
- );
63
+ new LoanTokenPoller({
64
+ identifier: 'identifier1',
65
+ borrowType: 'browsed',
66
+ successCallback: () => {},
67
+ errorCallback: () => {},
68
+ pollerDelay: 2000,
69
+ });
71
70
 
72
71
  it('reports the error immediately, without retrying, on the initial call', () => {
73
72
  const tokenPoller = makeTokenPoller();
@@ -100,7 +99,13 @@ describe('handleTokenError (no retry)', () => {
100
99
 
101
100
  it('passes isInitial through to the error callback', () => {
102
101
  const errorCallback = Sinon.spy();
103
- const poller = new LoanTokenPoller('foo', 'browsed', () => {}, errorCallback, 120);
102
+ const poller = new LoanTokenPoller({
103
+ identifier: 'foo',
104
+ borrowType: 'browsed',
105
+ successCallback: () => {},
106
+ errorCallback,
107
+ pollerDelay: 120,
108
+ });
104
109
  poller.disconnectedCallback();
105
110
 
106
111
  poller.handleTokenError({ error: 'something else went wrong' }, true);
@@ -109,3 +114,41 @@ describe('handleTokenError (no retry)', () => {
109
114
  expect(errorCallback.firstCall.args[0].detail.isInitial).to.be.true;
110
115
  });
111
116
  });
117
+
118
+ describe('skipInitialCall option', () => {
119
+ it('starts the recurring interval but fires no immediate create_token call', () => {
120
+ const poller = new LoanTokenPoller({
121
+ identifier: 'foo',
122
+ borrowType: 'browsed',
123
+ successCallback: () => {},
124
+ errorCallback: () => {},
125
+ pollerDelay: 120,
126
+ skipInitialCall: true,
127
+ });
128
+ const handleLoanTokenPollerSpy = Sinon.spy(poller, 'handleLoanTokenPoller');
129
+
130
+ // bookAccessed() already ran in the constructor above (before the spy
131
+ // was attached) -- re-run it to observe whether it calls out.
132
+ poller.bookAccessed();
133
+
134
+ expect(handleLoanTokenPollerSpy.called).to.be.false;
135
+ expect(window.IALendingIntervals.tokenPoller).to.not.equal(0);
136
+ poller.disconnectedCallback();
137
+ });
138
+
139
+ it('fires the immediate call as usual when skipInitialCall is not set', () => {
140
+ const poller = new LoanTokenPoller({
141
+ identifier: 'foo',
142
+ borrowType: 'browsed',
143
+ successCallback: () => {},
144
+ errorCallback: () => {},
145
+ pollerDelay: 120,
146
+ });
147
+ const handleLoanTokenPollerSpy = Sinon.spy(poller, 'handleLoanTokenPoller');
148
+
149
+ poller.bookAccessed();
150
+
151
+ expect(handleLoanTokenPollerSpy.calledWith(true)).to.be.true;
152
+ poller.disconnectedCallback();
153
+ });
154
+ });
@@ -479,8 +479,7 @@ describe('Browsing expired status', () => {
479
479
  });
480
480
  });
481
481
 
482
- describe('Auto renew one hour loan', async () => {
483
- await aTimeout(5000);
482
+ describe('Auto renew one hour loan', () => {
484
483
  it('Book is browsing and renewed it now', async () => {
485
484
  const loanTime = 88;
486
485
  const el = await fixture(
@@ -553,8 +552,7 @@ describe('Auto renew one hour loan', async () => {
553
552
  });
554
553
  });
555
554
 
556
- describe('Visibility change API for document', async () => {
557
- await aTimeout(5000);
555
+ describe('Visibility change API for document', () => {
558
556
 
559
557
  beforeEach(() => {
560
558
  Object.defineProperty(document, 'hidden', {
@@ -578,6 +576,10 @@ describe('Visibility change API for document', async () => {
578
576
  lendingStatus: {
579
577
  user_has_browsed: true,
580
578
  browsingExpired: false,
579
+ // Without this, startBrowseTimer() computes setTimeout(fn,
580
+ // undefined * 1000) -> NaN, which fires almost immediately and
581
+ // expires the loan before this test ever dispatches its event.
582
+ secondsLeftOnLoan: 3600,
581
583
  },
582
584
  })
583
585
  );
@@ -605,6 +607,7 @@ describe('Visibility change API for document', async () => {
605
607
  lendingStatus: {
606
608
  user_has_browsed: true,
607
609
  browsingExpired: false,
610
+ secondsLeftOnLoan: 3600,
608
611
  },
609
612
  })
610
613
  );
@@ -940,6 +943,100 @@ describe('autoRenewExpiredLoan', () => {
940
943
  expect(el.lendingBarPostInit.called).to.be.false;
941
944
  });
942
945
 
946
+ it('also skips the initial create_token after a routine (non-expiry) renewal', async () => {
947
+ // BookLoanService::attempt_to_renew_loan() mints the access token as
948
+ // part of EVERY renew_loan response, not just a recovery from expiry.
949
+ // A routine top-up also has its poller stopped mid-renewal (see
950
+ // updated()'s clearAll() on loanRenewResult.renewNow), so it would
951
+ // otherwise fire this same redundant call once the poller restarts.
952
+ const el = await fixture(
953
+ container({
954
+ userid: '@user1',
955
+ identifier: 'foobar',
956
+ lendingStatus: { user_has_browsed: true, browsingExpired: false },
957
+ })
958
+ );
959
+ await el.updateComplete;
960
+ el.postInitComplete = true;
961
+
962
+ await el.localCache.set({
963
+ key: 'foobar-loanTime',
964
+ value: new Date(new Date().getTime() + 3600 * 1000),
965
+ ttl: 3600,
966
+ });
967
+
968
+ const startTokenPollerSpy = Sinon.spy(el, 'startLoanTokenPoller');
969
+
970
+ // Triggers updated()'s clearAll(), stopping the running poller, same
971
+ // as a real routine top-up would.
972
+ el.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
973
+ await el.updateComplete;
974
+
975
+ const collapsibleActionGroupEl = el.shadowRoot.querySelector(
976
+ 'collapsible-action-group'
977
+ );
978
+ collapsibleActionGroupEl.dispatchEvent(
979
+ new CustomEvent('loanAutoRenewed', {
980
+ detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
981
+ })
982
+ );
983
+
984
+ await aTimeout(200);
985
+ await el.updateComplete;
986
+
987
+ expect(startTokenPollerSpy.calledOnceWith(true)).to.be.true;
988
+ });
989
+
990
+ it('calls lendingBarPostInit directly on a recovery renewal and skips the initial create_token', async () => {
991
+ // BookLoanService::attempt_to_renew_loan() now mints the access token
992
+ // as part of the renew_loan response itself (using the just-written
993
+ // loan record already in memory, not a separate read) -- so recovering
994
+ // from a genuine expiry no longer needs to wait on a confirming
995
+ // create_token success before re-initializing BookReader, and the
996
+ // poller it starts afterward shouldn't fire a redundant immediate call.
997
+ const el = await fixture(
998
+ container({
999
+ userid: '@user1',
1000
+ identifier: 'foobar',
1001
+ lendingStatus: {
1002
+ user_has_browsed: true,
1003
+ browsingExpired: true,
1004
+ },
1005
+ })
1006
+ );
1007
+ await el.updateComplete;
1008
+ el.lendingBarPostInit = Sinon.spy();
1009
+
1010
+ await el.localCache.set({
1011
+ key: 'foobar-loanTime',
1012
+ value: new Date(new Date().getTime() + 3600 * 1000),
1013
+ ttl: 3600,
1014
+ });
1015
+
1016
+ const startTokenPollerSpy = Sinon.spy(el, 'startLoanTokenPoller');
1017
+
1018
+ el.autoRenewExpiredLoan();
1019
+ await el.updateComplete;
1020
+ await aTimeout(50); // let autoRenewExpiredLoan's setTimeout(0) set renewNow
1021
+
1022
+ await el.handleLoanAutoRenewed({
1023
+ detail: {
1024
+ action: 'renew_loan',
1025
+ data: { success: true, loan: { identifier: 'foobar', renewal: true } },
1026
+ },
1027
+ });
1028
+
1029
+ // Called synchronously from handleLoanAutoRenewed, not via a
1030
+ // create_token success callback.
1031
+ expect(el.lendingBarPostInit.calledOnce).to.be.true;
1032
+ expect(el.postInitComplete).to.be.true;
1033
+
1034
+ await el.updateComplete;
1035
+ await aTimeout(150); // let the 100ms token-poller-restart check run
1036
+
1037
+ expect(startTokenPollerSpy.calledOnceWith(true)).to.be.true;
1038
+ });
1039
+
943
1040
  it('clears loanRenewInProgress and shows unavailable modal on renew_loan failure', async () => {
944
1041
  const el = await fixture(
945
1042
  container({