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

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-alpha17",
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,46 @@ 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
+ * @param {Function} [options.onTokenRefreshed] - called after EVERY
30
+ * successful create_token, initial or routine — unlike successCallback,
31
+ * which only fires for the initial one. Lets the consumer retry any
32
+ * page image that failed during a prior access gap, now that access is
33
+ * confirmed good again.
34
+ */
35
+ constructor(options = {}) {
36
+ const {
37
+ identifier,
38
+ borrowType,
39
+ successCallback,
40
+ errorCallback,
41
+ pollerDelay,
42
+ skipInitialCall = false,
43
+ onTokenRefreshed,
44
+ } = options;
45
+
46
+ this.identifier = identifier;
15
47
  this.borrowType = borrowType;
16
48
  this.successCallback = successCallback; // callback function to be called after loan token is created
17
49
  this.errorCallback = errorCallback; // callback function to be called after loan token is created
18
50
  this.pollerDelay = pollerDelay; // value in seconds
51
+ this.skipInitialCall = skipInitialCall === true;
52
+ this.onTokenRefreshed = onTokenRefreshed;
19
53
 
20
54
  this.loanTokenInterval = undefined;
21
55
 
@@ -34,8 +68,14 @@ export class LoanTokenPoller {
34
68
 
35
69
  async bookAccessed() {
36
70
  if (this.borrowType) {
37
- // Do an initial token, then set an interval
38
- this.handleLoanTokenPoller(true);
71
+ if (this.skipInitialCall) {
72
+ log('[LoanTokenPoller] skipping initial create_token — already minted by the renewal response', {
73
+ identifier: this.identifier,
74
+ });
75
+ } else {
76
+ // Do an initial token, then set an interval
77
+ this.handleLoanTokenPoller(true);
78
+ }
39
79
 
40
80
  // if this.borrowType = adminBorrowed,
41
81
  // - we don't want to fetch token on interval
@@ -78,6 +118,7 @@ export class LoanTokenPoller {
78
118
  identifier: this.identifier,
79
119
  isInitial,
80
120
  });
121
+ this.onTokenRefreshed?.();
81
122
  if (isInitial) this.successCallback();
82
123
  },
83
124
  });
@@ -55,6 +55,10 @@ export default class IABookActions extends LitElement {
55
55
  type: Function,
56
56
  attribute: false,
57
57
  },
58
+ reloadPageImages: {
59
+ type: Function,
60
+ attribute: false,
61
+ },
58
62
  barType: { type: String },
59
63
  sharedObserver: { attribute: false },
60
64
  disableActionGroup: { type: Boolean },
@@ -77,6 +81,7 @@ export default class IABookActions extends LitElement {
77
81
  this.width = 0;
78
82
  this.bwbPurchaseUrl = '';
79
83
  this.lendingBarPostInit = () => {};
84
+ this.reloadPageImages = () => {};
80
85
  this.barType = 'action'; // 'title'|'action'
81
86
  this.sharedObserver = undefined;
82
87
  this.disableActionGroup = false;
@@ -104,6 +109,15 @@ export default class IABookActions extends LitElement {
104
109
  */
105
110
  this.recoveringFromLoanExpiry = false;
106
111
 
112
+ /**
113
+ * True right after a confirmed renewal (any kind), until the token
114
+ * poller restarts. renew_loan's own response already carries a valid
115
+ * access token, so that restart should skip its normal immediate
116
+ * create_token call — see handleLoanAutoRenewed/
117
+ * setupLendingToolbarActions.
118
+ */
119
+ this.skipNextInitialTokenCall = false;
120
+
107
121
  this.warningModalOpen = false;
108
122
 
109
123
  /** Once dismissed, don't re-show the warning modal every tick — only
@@ -318,8 +332,14 @@ export default class IABookActions extends LitElement {
318
332
  !this.loanRenewInProgress &&
319
333
  !window.IALendingIntervals.tokenPoller
320
334
  ) {
335
+ // Any confirmed renewal's response already minted a valid access
336
+ // token (see handleLoanAutoRenewed) — the poller only needs to
337
+ // start its recurring check, not fire an immediate confirming
338
+ // create_token call too.
339
+ const skipInitialCall = this.skipNextInitialTokenCall;
340
+ this.skipNextInitialTokenCall = false;
321
341
  this.recoveringFromLoanExpiry = false;
322
- this.startLoanTokenPoller();
342
+ this.startLoanTokenPoller(skipInitialCall);
323
343
  }
324
344
  }, 100);
325
345
  }
@@ -740,10 +760,28 @@ export default class IABookActions extends LitElement {
740
760
  ajaxResponse: detail?.data,
741
761
  });
742
762
 
763
+ // BookLoanService::attempt_to_renew_loan() mints a valid access
764
+ // token as part of EVERY renew_loan response, not just a recovery
765
+ // from expiry — so the poller restart below never needs to fire a
766
+ // redundant confirming create_token call, regardless of which path
767
+ // triggered this renewal.
768
+ this.skipNextInitialTokenCall = true;
769
+
770
+ // Retry any page image that failed during the gap this renewal just
771
+ // closed, right away rather than waiting for the next routine
772
+ // create_token tick (up to tokenDelay seconds away, now that the
773
+ // initial one above is skipped).
774
+ this.reloadPageImages();
775
+
743
776
  if (this.recoveringFromLoanExpiry) {
744
777
  // 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;
778
+ // routine top-up, where that would be disruptive. Call it
779
+ // directly rather than waiting on a confirming create_token
780
+ // success: BookLoanService::attempt_to_renew_loan() already
781
+ // minted a valid access token as part of this renew_loan
782
+ // response, so there's nothing left to confirm.
783
+ this.lendingBarPostInit();
784
+ this.postInitComplete = true;
747
785
  }
748
786
 
749
787
  const currStatus = {
@@ -895,9 +933,10 @@ export default class IABookActions extends LitElement {
895
933
 
896
934
  /**
897
935
  * enable access of borrowed/browsed books
936
+ * @param {boolean} [skipInitialCall] - see LoanTokenPoller's constructor doc
898
937
  * @see LoanTokenPoller
899
938
  */
900
- startLoanTokenPoller() {
939
+ startLoanTokenPoller(skipInitialCall = false) {
901
940
  const successCallback = () => {
902
941
  if (!this.postInitComplete) {
903
942
  this.lendingBarPostInit();
@@ -907,17 +946,26 @@ export default class IABookActions extends LitElement {
907
946
  const errorCallback = eventObj => {
908
947
  this.handleLendingActionError(eventObj);
909
948
  };
949
+ // BookReader has no self-healing for a page <img> that failed while
950
+ // access was briefly invalid — retry any of those now that this
951
+ // create_token confirms access is good again, on every success, not
952
+ // just the initial one.
953
+ const onTokenRefreshed = () => {
954
+ this.reloadPageImages();
955
+ };
910
956
 
911
957
  // Tear down any previous poller first, so only one is ever controlling
912
958
  // window.IALendingIntervals.tokenPoller at a time.
913
959
  this.tokenPoller?.disconnectedCallback();
914
- this.tokenPoller = new LoanTokenPoller(
915
- this.identifier,
916
- this.borrowType,
960
+ this.tokenPoller = new LoanTokenPoller({
961
+ identifier: this.identifier,
962
+ borrowType: this.borrowType,
917
963
  successCallback,
918
964
  errorCallback,
919
- this.tokenDelay // in seconds
920
- );
965
+ pollerDelay: this.tokenDelay, // in seconds
966
+ skipInitialCall,
967
+ onTokenRefreshed,
968
+ });
921
969
  }
922
970
 
923
971
  /*
@@ -1101,6 +1149,10 @@ export default class IABookActions extends LitElement {
1101
1149
  justify-content: center;
1102
1150
  flex-wrap: wrap;
1103
1151
  }
1152
+
1153
+ #action-bar-modal {
1154
+ --modalWidth: 36rem;
1155
+ }
1104
1156
  `;
1105
1157
  }
1106
1158
  }
@@ -1,4 +1,4 @@
1
- import { expect } from '@open-wc/testing';
1
+ import { expect, aTimeout } from '@open-wc/testing';
2
2
  import Sinon from 'sinon';
3
3
 
4
4
  import { LoanTokenPoller } from '../../../src/core/services/loan-token-poller.js';
@@ -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,83 @@ 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
+ });
155
+
156
+ describe('onTokenRefreshed option', () => {
157
+ // BookReader has no self-healing for a page <img> that failed while
158
+ // access was briefly invalid, so the consumer needs a signal on EVERY
159
+ // create_token success (not just the initial one) to know it's safe to
160
+ // retry those images.
161
+ it('fires on the initial create_token success', async () => {
162
+ const onTokenRefreshed = Sinon.spy();
163
+ const poller = new LoanTokenPoller({
164
+ identifier: 'foo',
165
+ borrowType: 'browsed',
166
+ successCallback: () => {},
167
+ errorCallback: () => {},
168
+ pollerDelay: 120,
169
+ onTokenRefreshed,
170
+ });
171
+
172
+ await aTimeout(50);
173
+
174
+ expect(onTokenRefreshed.called).to.be.true;
175
+ poller.disconnectedCallback();
176
+ });
177
+
178
+ it('fires on a routine (non-initial) create_token success too', async () => {
179
+ const onTokenRefreshed = Sinon.spy();
180
+ const poller = new LoanTokenPoller({
181
+ identifier: 'foo',
182
+ borrowType: 'browsed',
183
+ successCallback: () => {},
184
+ errorCallback: () => {},
185
+ pollerDelay: 120,
186
+ onTokenRefreshed,
187
+ });
188
+ onTokenRefreshed.resetHistory();
189
+
190
+ await poller.handleLoanTokenPoller(false);
191
+ await aTimeout(50);
192
+
193
+ expect(onTokenRefreshed.called).to.be.true;
194
+ poller.disconnectedCallback();
195
+ });
196
+ });
@@ -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,106 @@ 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
+ el.reloadPageImages = Sinon.spy();
962
+
963
+ await el.localCache.set({
964
+ key: 'foobar-loanTime',
965
+ value: new Date(new Date().getTime() + 3600 * 1000),
966
+ ttl: 3600,
967
+ });
968
+
969
+ const startTokenPollerSpy = Sinon.spy(el, 'startLoanTokenPoller');
970
+
971
+ // Triggers updated()'s clearAll(), stopping the running poller, same
972
+ // as a real routine top-up would.
973
+ el.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
974
+ await el.updateComplete;
975
+
976
+ const collapsibleActionGroupEl = el.shadowRoot.querySelector(
977
+ 'collapsible-action-group'
978
+ );
979
+ collapsibleActionGroupEl.dispatchEvent(
980
+ new CustomEvent('loanAutoRenewed', {
981
+ detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
982
+ })
983
+ );
984
+
985
+ await aTimeout(200);
986
+ await el.updateComplete;
987
+
988
+ expect(startTokenPollerSpy.calledOnceWith(true)).to.be.true;
989
+ // Retried right away rather than waiting for the (now-skipped) initial
990
+ // create_token or the next routine tick.
991
+ expect(el.reloadPageImages.called).to.be.true;
992
+ });
993
+
994
+ it('calls lendingBarPostInit directly on a recovery renewal and skips the initial create_token', async () => {
995
+ // BookLoanService::attempt_to_renew_loan() now mints the access token
996
+ // as part of the renew_loan response itself (using the just-written
997
+ // loan record already in memory, not a separate read) -- so recovering
998
+ // from a genuine expiry no longer needs to wait on a confirming
999
+ // create_token success before re-initializing BookReader, and the
1000
+ // poller it starts afterward shouldn't fire a redundant immediate call.
1001
+ const el = await fixture(
1002
+ container({
1003
+ userid: '@user1',
1004
+ identifier: 'foobar',
1005
+ lendingStatus: {
1006
+ user_has_browsed: true,
1007
+ browsingExpired: true,
1008
+ },
1009
+ })
1010
+ );
1011
+ await el.updateComplete;
1012
+ el.lendingBarPostInit = Sinon.spy();
1013
+ el.reloadPageImages = Sinon.spy();
1014
+
1015
+ await el.localCache.set({
1016
+ key: 'foobar-loanTime',
1017
+ value: new Date(new Date().getTime() + 3600 * 1000),
1018
+ ttl: 3600,
1019
+ });
1020
+
1021
+ const startTokenPollerSpy = Sinon.spy(el, 'startLoanTokenPoller');
1022
+
1023
+ el.autoRenewExpiredLoan();
1024
+ await el.updateComplete;
1025
+ await aTimeout(50); // let autoRenewExpiredLoan's setTimeout(0) set renewNow
1026
+
1027
+ await el.handleLoanAutoRenewed({
1028
+ detail: {
1029
+ action: 'renew_loan',
1030
+ data: { success: true, loan: { identifier: 'foobar', renewal: true } },
1031
+ },
1032
+ });
1033
+
1034
+ // Called synchronously from handleLoanAutoRenewed, not via a
1035
+ // create_token success callback.
1036
+ expect(el.lendingBarPostInit.calledOnce).to.be.true;
1037
+ expect(el.postInitComplete).to.be.true;
1038
+ expect(el.reloadPageImages.called).to.be.true;
1039
+
1040
+ await el.updateComplete;
1041
+ await aTimeout(150); // let the 100ms token-poller-restart check run
1042
+
1043
+ expect(startTokenPollerSpy.calledOnceWith(true)).to.be.true;
1044
+ });
1045
+
943
1046
  it('clears loanRenewInProgress and shows unavailable modal on renew_loan failure', async () => {
944
1047
  const el = await fixture(
945
1048
  container({