@internetarchive/ia-book-actions 1.4.3 → 1.4.4-alpha10

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.
@@ -1,7 +1,9 @@
1
- import { expect, fixture } from '@open-wc/testing';
1
+ import { expect, fixture, aTimeout } from '@open-wc/testing';
2
2
  import sinon from 'sinon';
3
+ import { LocalCache } from '@internetarchive/local-cache';
3
4
 
4
5
  import ActionsHandler from '../../../src/core/services/actions-handler/actions-handler.js';
6
+ import { analyticsCategories, analyticsActions, analyticsLabels } from '../../../src/core/config/analytics-event-and-category.js';
5
7
 
6
8
  // Define a temporary tag for this element for testing
7
9
  const TEST_TAG = 'ia-actions-handler-test';
@@ -89,3 +91,110 @@ describe('ActionsHandler#setStickyAdminAccess', () => {
89
91
  expect(lastSetCookie).to.contain(`${encodeURIComponent('sticky-admin-access')}=${encodeURIComponent('false')}`);
90
92
  });
91
93
  });
94
+
95
+ describe('ActionsHandler#handleLoanRenewNow (WEBDEV-8322)', () => {
96
+ let el;
97
+ let localCache;
98
+ const identifier = 'renew-now-test-book';
99
+
100
+ beforeEach(async () => {
101
+ // Make sure no other test in this run left ?error=true in the URL —
102
+ // ActionsHandlerService reads it directly off window.location.
103
+ const params = new URLSearchParams(window.location.search);
104
+ if (params.has('error')) {
105
+ params.delete('error');
106
+ const query = params.toString();
107
+ window.history.replaceState(
108
+ {},
109
+ '',
110
+ `${window.location.pathname}${query ? `?${query}` : ''}`
111
+ );
112
+ }
113
+
114
+ el = await fixture(`<${TEST_TAG}></${TEST_TAG}>`);
115
+ el.identifier = identifier;
116
+ el.loanTotalTime = 3600;
117
+ localCache = new LocalCache({ namespace: 'loanRenew' });
118
+ el.localCache = localCache;
119
+ // The demo/test environment's ActionsHandlerService fakes a renew_loan
120
+ // success after a 5s delay to simulate a real network round-trip.
121
+ await localCache.delete(`${identifier}-loanTime`);
122
+ });
123
+
124
+ afterEach(() => {
125
+ sinon.restore();
126
+ });
127
+
128
+ it('awaits the loanTime cache write before dispatching loanAutoRenewed, and fires the renew analytics event only on success', async function test() {
129
+ this.timeout(7000);
130
+
131
+ const storeStatsSpy = sinon.spy(el.loanAnanlytics, 'storeLoanStatsCount');
132
+ const sendEventSpy = sinon.spy(el.loanAnanlytics, 'sendEvent');
133
+
134
+ let loanTimeWhenEventFired;
135
+ el.addEventListener('loanAutoRenewed', () => {
136
+ loanTimeWhenEventFired = localCache.get(`${identifier}-loanTime`);
137
+ });
138
+
139
+ el.handleLoanRenewNow('auto');
140
+
141
+ // ActionsHandlerService fakes a 5s delay for renew_loan in this
142
+ // (localhost) test environment — wait it out, matching the existing
143
+ // pattern used elsewhere in this test suite for the same fake delay.
144
+ await aTimeout(5500);
145
+
146
+ // The race this guards against: loanAutoRenewed used to dispatch
147
+ // before setBrowseTimeSession()'s cache write landed, so a listener
148
+ // reading the cache immediately could still see the old/deleted value.
149
+ const resolvedLoanTime = await loanTimeWhenEventFired;
150
+ expect(resolvedLoanTime).to.exist;
151
+ expect(resolvedLoanTime.getTime()).to.be.greaterThan(Date.now());
152
+
153
+ expect(storeStatsSpy.calledWith(identifier, 'autorenew')).to.be.true;
154
+ // storeLoanStatsCount fires its own matrix-stats sendEvent internally,
155
+ // so assert on the specific renew-success event rather than call count.
156
+ expect(
157
+ sendEventSpy.calledWith(
158
+ analyticsCategories.browse,
159
+ analyticsActions.browseRenew,
160
+ analyticsLabels.browseAutoRenew,
161
+ identifier
162
+ )
163
+ ).to.be.true;
164
+ });
165
+
166
+ it('does not fire the renew analytics event when the renewal fails', async () => {
167
+ const sendEventSpy = sinon.spy(el.loanAnanlytics, 'sendEvent');
168
+ const storeStatsSpy = sinon.spy(el.loanAnanlytics, 'storeLoanStatsCount');
169
+
170
+ // Force ActionsHandlerService's shouldReturnError branch for this call.
171
+ const originalSearch = window.location.search;
172
+ const params = new URLSearchParams(originalSearch);
173
+ params.set('error', 'true');
174
+ window.history.pushState({}, '', `?${params.toString()}`);
175
+
176
+ let errorEventDetail;
177
+ el.addEventListener('lendingActionError', ({ detail }) => {
178
+ errorEventDetail = detail;
179
+ });
180
+
181
+ try {
182
+ el.handleLoanRenewNow('auto');
183
+ await aTimeout(200);
184
+
185
+ expect(errorEventDetail?.action).to.equal('renew_loan');
186
+ // dispatchActionError fires its own 'LendingServiceError' analytics
187
+ // event on any error — that's expected. What must NOT fire is the
188
+ // renew-success event this fix gates on `isRenewal`.
189
+ expect(
190
+ sendEventSpy.calledWith(
191
+ analyticsCategories.browse,
192
+ analyticsActions.browseRenew
193
+ )
194
+ ).to.be.false;
195
+ expect(storeStatsSpy.calledWith(identifier, 'autorenew')).to.be.false;
196
+ } finally {
197
+ window.history.pushState({}, '', `${window.location.pathname}${originalSearch}`);
198
+ }
199
+ });
200
+ });
@@ -80,7 +80,7 @@ describe('Loan Renew Determine', () => {
80
80
  );
81
81
 
82
82
  expect(toastMsg).to.be.equal(
83
- 'With no action, this book will be auto-returned in 2 minutes.'
83
+ 'Go to any other page to keep your loan active.'
84
84
  );
85
85
  });
86
86
  });
@@ -56,3 +56,79 @@ describe('Get Loan Token', () => {
56
56
  expect(tokenPoller.loanTokenInterval).to.equal(undefined);
57
57
  });
58
58
  });
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
+
69
+ const makeTokenPoller = () =>
70
+ new LoanTokenPoller(
71
+ 'identifier1',
72
+ 'browsed',
73
+ () => {},
74
+ () => {},
75
+ 2000
76
+ );
77
+
78
+ it('retries the initial call on a stale-loan-read error instead of failing immediately', () => {
79
+ const tokenPoller = makeTokenPoller();
80
+ const handleLoanTokenPollerSpy = Sinon.spy(
81
+ tokenPoller,
82
+ 'handleLoanTokenPoller'
83
+ );
84
+ 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.initialTokenRetryDelay);
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
+
104
+ tokenPoller.handleTokenError(
105
+ staleLoanReadError,
106
+ true,
107
+ tokenPoller.maxInitialTokenRetries
108
+ );
109
+
110
+ expect(errorCallbackSpy.calledOnce).to.be.true;
111
+ });
112
+
113
+ it('does not retry a routine (non-initial) poll on the same error', () => {
114
+ const tokenPoller = makeTokenPoller();
115
+ const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
116
+
117
+ tokenPoller.handleTokenError(staleLoanReadError, false, 0);
118
+
119
+ expect(errorCallbackSpy.calledOnce).to.be.true;
120
+ });
121
+
122
+ it('does not retry a genuinely different error, even on the initial call', () => {
123
+ const tokenPoller = makeTokenPoller();
124
+ const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
125
+
126
+ tokenPoller.handleTokenError(
127
+ { error: 'loan token not found. please try again later.' },
128
+ true,
129
+ 0
130
+ );
131
+
132
+ expect(errorCallbackSpy.calledOnce).to.be.true;
133
+ });
134
+ });