@internetarchive/ia-book-actions 1.4.4-alpha12 → 1.4.4-alpha13
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 +1 -1
- package/src/assets/data/info.js +10 -5
- package/src/core/services/actions-handler/actions-handler-service.js +17 -0
- package/src/core/services/actions-handler/actions-handler.js +64 -35
- package/src/core/services/loan-analytics.js +0 -11
- package/src/core/services/loan-renew-helper.js +2 -2
- package/src/core/services/loan-token-poller.js +34 -16
- package/src/ia-book-actions.js +175 -65
- package/test/core/services/loan-token-poller.test.js +72 -6
- package/test/ia-book-actions.test.js +250 -25
package/package.json
CHANGED
package/src/assets/data/info.js
CHANGED
|
@@ -4,9 +4,14 @@ export const infoIcon = html`<svg
|
|
|
4
4
|
height="20"
|
|
5
5
|
width="20"
|
|
6
6
|
viewBox="0 0 100 100"
|
|
7
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
8
|
+
>
|
|
9
|
+
<g fill="none" fill-rule="evenodd">
|
|
10
|
+
<path d="m0 0h100v100h-100z" fill="#fff" />
|
|
11
|
+
<path
|
|
12
|
+
d="m49.8315487 0h.1702245c6.7356878 0 13.1853038 1.31117332 19.3488483 3.93351997 6.1635444 2.62234664 11.4854233 6.15778963 15.9656369 10.60632903 4.4802135 4.4485394 8.0478347 9.7522946 10.7028636 15.9112655 2.655029 6.1589709 3.980878 12.6038012 3.9789971 19.3344909.0567419 6.6716279-1.1702933 13.0585776-3.6811042 19.1608491-2.510811 6.1022715-6.106803 11.5206067-10.7879759 16.2550055-9.7027949 9.7522946-21.4884754 14.6851412-35.3570414 14.79854h-.1702244c-6.7333236 0-13.1829397-1.3111733-19.3488483-3.93352-6.1659087-2.6223466-11.4877876-6.1577896-15.9656369-10.606329s-8.04547055-9.7522946-10.7028637-15.9112655c-2.65739314-6.1589709-3.9844243-12.6038012-3.98254337-19.3344909-.05674149-6.6716279 1.17029325-13.0585776 3.68110421-19.1608491 2.51081095-6.1022715 6.10680292-11.5206067 10.78797586-16.2550055 9.7027949-9.75229456 21.4884754-14.68514123 35.3570414-14.79854zm12.6566146 26.4757998c1.6745578-1.6828001 2.5118367-3.6747334 2.5118367-5.9757998 0-2.4126333-.8095238-4.4324583-2.4285714-6.059475s-3.6289796-2.440525-6.0297959-2.440525c-2.4008164 0-4.4107483.8135083-6.029796 2.440525-1.6745578 1.6270167-2.5118367 3.6468417-2.5118367 6.059475 0 2.1871753.8372789 4.1791086 2.5118367 5.9757998 1.6745579 1.6828001 3.6844898 2.5242002 6.029796 2.5242002 2.3453061 0 4.3274829-.8414001 5.9465306-2.5242002zm-12.1370589 52.7776981-1.2815282-.9486968c0-.4588935.398855-1.8938272 1.196565-4.3048011l12.7338588-39-23.0745876 3.6164609.2548896 3.873251c0-.1141289.4554971-.1997256 1.3664914-.2567901.9109942 0 1.623741.1723823 2.1382404.5171468.5121392.2306356.7965299.6039323.8531721 1.1198902 0 .8607225-.6549247 3.2134431-1.9647739 7.0581619l-8.1175252 24.1061729c-1.0242785 3.2716963-1.5080967 5.5388203-1.4514546 6.8013717.0566421 1.6643804.8826732 2.9839963 2.4780932 3.9588477 1.2532071.803658 2.8769482 1.205487 4.8712231 1.205487h.5982825c1.7653464-.0570645 3.8445846-.3875629 6.2377146-.9914952 2.3931299-.6039323 4.3590839-1.3933242 5.8978617-2.3681756 1.3098493-.803658 2.3919499-1.5359853 3.2463021-2.1969821.8543521-.6609968 1.3959925-1.1341564 1.6249211-1.4194788l.3433929-.3459533-2.8214861-3.3596708-2.9914125 2.0650205c-.79771.4588935-1.5104568.7454047-2.1382404.8595337z"
|
|
13
|
+
fill="#000"
|
|
14
|
+
fill-rule="nonzero"
|
|
15
|
+
/>
|
|
10
16
|
</g>
|
|
11
|
-
</svg>
|
|
12
|
-
`;
|
|
17
|
+
</svg> `;
|
|
@@ -95,5 +95,22 @@ export default async function ActionsHandlerService(options) {
|
|
|
95
95
|
window?.Sentry?.captureException(
|
|
96
96
|
`${sentryLogs.actionsHandlerService} - Error: ${error}`
|
|
97
97
|
);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Report it, don't just swallow it. A rejected fetch (offline, dropped
|
|
101
|
+
* connection) or a non-JSON body (a 405 returning HTML, which QA has
|
|
102
|
+
* been seeing) lands here, and calling neither `success` nor `error`
|
|
103
|
+
* leaves every caller waiting on a callback that never comes.
|
|
104
|
+
*
|
|
105
|
+
* For renew_loan that's not merely a missing modal: IABookActions
|
|
106
|
+
* clears its loanRenewInProgress guard from these callbacks, so a
|
|
107
|
+
* silent failure latched it on for good and every later renewal
|
|
108
|
+
* attempt became a no-op. Backgrounding a tab on a flaky mobile
|
|
109
|
+
* connection is exactly when this fires.
|
|
110
|
+
*/
|
|
111
|
+
log(`[IABookActions] ✗ ${option.action} threw`, error);
|
|
112
|
+
option?.error({
|
|
113
|
+
error: `Could not reach the lending service. Please check your connection and try again. (${error})`,
|
|
114
|
+
});
|
|
98
115
|
}
|
|
99
116
|
}
|
|
@@ -6,7 +6,11 @@ import log from '../log.js';
|
|
|
6
6
|
|
|
7
7
|
import ActionsHandlerService from './actions-handler-service.js';
|
|
8
8
|
import LoanAnanlytics from '../loan-analytics.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
analyticsCategories,
|
|
11
|
+
analyticsActions,
|
|
12
|
+
analyticsLabels,
|
|
13
|
+
} from '../../config/analytics-event-and-category.js';
|
|
10
14
|
import * as Cookies from '../doc-cookies.js';
|
|
11
15
|
|
|
12
16
|
/**
|
|
@@ -172,32 +176,53 @@ export default class ActionsHandler extends LitElement {
|
|
|
172
176
|
action,
|
|
173
177
|
identifier: this.identifier,
|
|
174
178
|
success: async data => {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
179
|
+
// Anything thrown in here is an unhandled rejection that dispatches
|
|
180
|
+
// NOTHING — and IABookActions only clears loanRenewInProgress when
|
|
181
|
+
// one of these two events arrives, so a throw here latches it on
|
|
182
|
+
// permanently (dead countdown, loan stuck "active"). Always exit
|
|
183
|
+
// through exactly one of the two dispatches below.
|
|
184
|
+
try {
|
|
185
|
+
log('RENEW_LOAN --- ', data, this.identifier);
|
|
186
|
+
const activeLoan = data.loan ? data.loan : undefined;
|
|
187
|
+
// Optional chaining: `data.loan` is genuinely absent on some
|
|
188
|
+
// failures, and reading `.renewal` off undefined threw here.
|
|
189
|
+
const isRenewal = activeLoan?.renewal;
|
|
190
|
+
|
|
191
|
+
if (activeLoan && isRenewal) {
|
|
192
|
+
// Await — loanAutoRenewed listeners read this same cache key
|
|
193
|
+
// immediately; dispatching before the write lands was a race.
|
|
194
|
+
await this.setBrowseTimeSession();
|
|
195
|
+
|
|
196
|
+
// Only record the renew analytics event once the renewal has
|
|
197
|
+
// actually succeeded — not merely attempted.
|
|
198
|
+
await this.loanAnanlytics?.storeLoanStatsCount(
|
|
199
|
+
this.identifier,
|
|
200
|
+
'autorenew'
|
|
201
|
+
);
|
|
202
|
+
const analyticsLabel =
|
|
203
|
+
renewType === 'auto'
|
|
204
|
+
? analyticsLabels.browseAutoRenew
|
|
205
|
+
: analyticsLabels.browseManualRenew;
|
|
206
|
+
this.loanAnanlytics?.sendEvent(
|
|
207
|
+
analyticsCategories.browse,
|
|
208
|
+
analyticsActions.browseRenew,
|
|
209
|
+
analyticsLabel,
|
|
210
|
+
this.identifier
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Dispatch the success outcome ONLY for a confirmed renewal.
|
|
214
|
+
// This used to fire on the failure path too, so a
|
|
215
|
+
// `{loan: {renewal: false}}` response reported an error AND a
|
|
216
|
+
// success: the error modal went up while handleLoanAutoRenewed
|
|
217
|
+
// simultaneously put a fresh countdown back on the bar.
|
|
218
|
+
this.dispatchEvent(
|
|
219
|
+
new CustomEvent('loanAutoRenewed', {
|
|
220
|
+
detail: { action, data: { ...data, loan: activeLoan } },
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
201
226
|
log('RENEW_LOAN ERROR --- ', {
|
|
202
227
|
action,
|
|
203
228
|
isRenewal,
|
|
@@ -213,14 +238,17 @@ export default class ActionsHandler extends LitElement {
|
|
|
213
238
|
error: true,
|
|
214
239
|
message: 'Loan renewal failed: no loan active.',
|
|
215
240
|
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
log('RENEW_LOAN THREW --- ', error);
|
|
243
|
+
window?.Sentry?.captureException(
|
|
244
|
+
`${sentryLogs.bookRenewFailed} - Exception: ${error}`
|
|
245
|
+
);
|
|
246
|
+
this.dispatchActionError(action, {
|
|
247
|
+
data,
|
|
248
|
+
error: true,
|
|
249
|
+
message: `Loan renewal failed: ${error}`,
|
|
250
|
+
});
|
|
216
251
|
}
|
|
217
|
-
|
|
218
|
-
// dispatch outcome of loan renewal
|
|
219
|
-
this.dispatchEvent(
|
|
220
|
-
new CustomEvent('loanAutoRenewed', {
|
|
221
|
-
detail: { action, data: { ...data, loan: activeLoan } },
|
|
222
|
-
})
|
|
223
|
-
);
|
|
224
252
|
},
|
|
225
253
|
error: data => {
|
|
226
254
|
this.dispatchActionError(action, data);
|
|
@@ -428,7 +456,8 @@ export default class ActionsHandler extends LitElement {
|
|
|
428
456
|
* @returns {void}
|
|
429
457
|
*/
|
|
430
458
|
setStickyAdminAccess(value) {
|
|
431
|
-
const domain =
|
|
459
|
+
const domain =
|
|
460
|
+
window.location.hostname === 'localhost' ? 'localhost' : '.archive.org';
|
|
432
461
|
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days from now
|
|
433
462
|
Cookies.setItem('sticky-admin-access', value, expires, '/', domain);
|
|
434
463
|
}
|
|
@@ -159,17 +159,6 @@ export default class LoanAnanlytics {
|
|
|
159
159
|
* @memberof LoanAnanlytics
|
|
160
160
|
*/
|
|
161
161
|
sendEvent(eventCategory, eventAction, label, extraParams) {
|
|
162
|
-
// log(
|
|
163
|
-
// 'eventCategory:-',
|
|
164
|
-
// eventCategory,
|
|
165
|
-
// '||\teventAction:-',
|
|
166
|
-
// eventAction,
|
|
167
|
-
// '||\tlabel:-',
|
|
168
|
-
// label,
|
|
169
|
-
// '||\textraParams:-',
|
|
170
|
-
// extraParams,
|
|
171
|
-
// );
|
|
172
|
-
|
|
173
162
|
window?.archive_analytics?.send_event_no_sampling(
|
|
174
163
|
eventCategory,
|
|
175
164
|
eventAction,
|
|
@@ -26,7 +26,7 @@ export class LoanRenewHelper {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
handleLoanRenew() {
|
|
30
30
|
try {
|
|
31
31
|
if (this.hasPageChanged) {
|
|
32
32
|
return this.pageChanged(); // user clicked on page
|
|
@@ -99,7 +99,7 @@ export class LoanRenewHelper {
|
|
|
99
99
|
} else if (pageChangedTime >= pageChangeTimeFrame) {
|
|
100
100
|
this.result = {
|
|
101
101
|
texts: '',
|
|
102
|
-
renewNow: true,
|
|
102
|
+
renewNow: true, // viewed in last time frame
|
|
103
103
|
renewType: 'auto',
|
|
104
104
|
};
|
|
105
105
|
}
|
|
@@ -28,8 +28,16 @@ export class LoanTokenPoller {
|
|
|
28
28
|
* the loan is genuinely valid. Retry a few times with backoff instead
|
|
29
29
|
* of giving up on the first attempt.
|
|
30
30
|
*/
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
31
|
+
this.maxTokenRetries = 3;
|
|
32
|
+
this.tokenRetryDelay = 1000; // ms, multiplied by attempt number
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle for a pending retry, so teardown can cancel it. It's a bare
|
|
36
|
+
* setTimeout rather than an IALendingIntervals entry, so without this
|
|
37
|
+
* a retry scheduled just before the loan is returned would still fire
|
|
38
|
+
* its create_token afterwards.
|
|
39
|
+
*/
|
|
40
|
+
this.retryTimeout = undefined;
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
43
|
* loan analytics instance
|
|
@@ -42,6 +50,8 @@ export class LoanTokenPoller {
|
|
|
42
50
|
|
|
43
51
|
disconnectedCallback() {
|
|
44
52
|
window?.IALendingIntervals?.clearTokenPoller();
|
|
53
|
+
clearTimeout(this.retryTimeout);
|
|
54
|
+
this.retryTimeout = undefined;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
async bookAccessed() {
|
|
@@ -123,19 +133,24 @@ export class LoanTokenPoller {
|
|
|
123
133
|
error: data?.error,
|
|
124
134
|
});
|
|
125
135
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
) {
|
|
131
|
-
const delay = this.
|
|
132
|
-
log(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
// The renewal write can be mid-propagation for an interval refresh too,
|
|
137
|
+
// not just the initial call — a routine tick landing moments after a
|
|
138
|
+
// renewal hits the same race. Retry both; isInitial only decides how a
|
|
139
|
+
// final failure is reported, not whether it's worth retrying.
|
|
140
|
+
if (isStaleLoanReadError && retryCount < this.maxTokenRetries) {
|
|
141
|
+
const delay = this.tokenRetryDelay * (retryCount + 1);
|
|
142
|
+
log(
|
|
143
|
+
'[LoanTokenPoller] retrying create_token after stale-loan-read error',
|
|
144
|
+
{
|
|
145
|
+
identifier: this.identifier,
|
|
146
|
+
nextRetryCount: retryCount + 1,
|
|
147
|
+
delay,
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
clearTimeout(this.retryTimeout);
|
|
151
|
+
this.retryTimeout = setTimeout(() => {
|
|
152
|
+
this.retryTimeout = undefined;
|
|
153
|
+
this.handleLoanTokenPoller(isInitial, retryCount + 1);
|
|
139
154
|
}, delay);
|
|
140
155
|
return;
|
|
141
156
|
}
|
|
@@ -146,7 +161,10 @@ export class LoanTokenPoller {
|
|
|
146
161
|
retryCount,
|
|
147
162
|
});
|
|
148
163
|
|
|
149
|
-
|
|
164
|
+
// isInitial rides along so the consumer can distinguish "the book won't
|
|
165
|
+
// open at all" from "a mid-read refresh blipped" — see
|
|
166
|
+
// IABookActions.handleLendingActionError.
|
|
167
|
+
this.errorCallback({ detail: { action, data, isInitial } });
|
|
150
168
|
|
|
151
169
|
// send error to Sentry
|
|
152
170
|
window?.Sentry?.captureMessage(
|
package/src/ia-book-actions.js
CHANGED
|
@@ -61,7 +61,6 @@ export default class IABookActions extends LitElement {
|
|
|
61
61
|
modal: { Object },
|
|
62
62
|
tokenDelay: { type: Number },
|
|
63
63
|
timerExecutionSeconds: { type: Number },
|
|
64
|
-
userActionGracePeriod: { type: Number },
|
|
65
64
|
localCache: { type: Object },
|
|
66
65
|
loanRenewTimeConfig: { type: Object },
|
|
67
66
|
loanRenewResult: { type: Object },
|
|
@@ -84,14 +83,6 @@ export default class IABookActions extends LitElement {
|
|
|
84
83
|
this.tokenDelay = 120; // in seconds
|
|
85
84
|
this.timerExecutionSeconds = 30;
|
|
86
85
|
|
|
87
|
-
/**
|
|
88
|
-
* How long (ms) after bindLoanRenewEvents() runs to ignore
|
|
89
|
-
* BookReader:userAction events — BookReader fires that event on its
|
|
90
|
-
* own init-time jump to the last-read page, not just on genuine
|
|
91
|
-
* interaction. @see bindLoanRenewEvents
|
|
92
|
-
*/
|
|
93
|
-
this.userActionGracePeriod = 2000;
|
|
94
|
-
|
|
95
86
|
// private props
|
|
96
87
|
this.postInitComplete = false;
|
|
97
88
|
this.primaryActions = [];
|
|
@@ -241,17 +232,58 @@ export default class IABookActions extends LitElement {
|
|
|
241
232
|
}
|
|
242
233
|
/** End SharedObserver resize handler */
|
|
243
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Recompute the action bar (title, buttons, colors, borrowType) from the
|
|
237
|
+
* current lendingStatus.
|
|
238
|
+
*
|
|
239
|
+
* @returns {boolean} false when there are no actions to apply, in which
|
|
240
|
+
* case callers should bail out rather than continue with stale state.
|
|
241
|
+
*/
|
|
242
|
+
applyLendingActions() {
|
|
243
|
+
this.lendingOptions = new GetLendingActions(
|
|
244
|
+
this.userid,
|
|
245
|
+
this.identifier,
|
|
246
|
+
this.lendingStatus,
|
|
247
|
+
this.bwbPurchaseUrl
|
|
248
|
+
);
|
|
249
|
+
const actions = this.lendingOptions.getCurrentLendingActions();
|
|
250
|
+
|
|
251
|
+
if (!actions) return false;
|
|
252
|
+
|
|
253
|
+
this.primaryTitle = actions.primaryTitle;
|
|
254
|
+
this.primaryActions = actions.primaryActions?.filter(action => {
|
|
255
|
+
return action != null;
|
|
256
|
+
});
|
|
257
|
+
this.primaryColor = actions.primaryColor;
|
|
258
|
+
this.secondaryActions = actions.secondaryActions?.filter(action => {
|
|
259
|
+
return action != null;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
this.borrowType = actions.borrowType ? actions.borrowType : null;
|
|
263
|
+
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
244
267
|
async setupLendingToolbarActions() {
|
|
245
268
|
const hasExpired =
|
|
246
269
|
'browsingExpired' in this.lendingStatus &&
|
|
247
270
|
this.lendingStatus?.browsingExpired;
|
|
248
271
|
if (hasExpired) {
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
272
|
+
// Per the ticket, auto-return must not visibly change anything — the
|
|
273
|
+
// action bar stays exactly as it looked while reading. But that only
|
|
274
|
+
// applies when there IS a rendered bar to preserve. On a fresh page
|
|
275
|
+
// load of an already-expired loan nothing has been computed yet, so
|
|
276
|
+
// skipping this would leave the patron staring at a blank bar with no
|
|
277
|
+
// way to re-borrow. In that case compute the normal (Borrow) actions
|
|
278
|
+
// before short-circuiting the rest of the lifecycle below.
|
|
279
|
+
if (this.primaryActions?.length) {
|
|
280
|
+
log('[IABookActions] browsing expired — leaving action bar untouched');
|
|
281
|
+
} else {
|
|
282
|
+
log(
|
|
283
|
+
'[IABookActions] browsing expired on first render — populating action bar'
|
|
284
|
+
);
|
|
285
|
+
this.applyLendingActions();
|
|
286
|
+
}
|
|
255
287
|
|
|
256
288
|
if (!this.tokenPoller) {
|
|
257
289
|
this.sentryCaptureMsg(sentryLogs.bookWasExpired);
|
|
@@ -271,26 +303,7 @@ export default class IABookActions extends LitElement {
|
|
|
271
303
|
return;
|
|
272
304
|
}
|
|
273
305
|
|
|
274
|
-
this.
|
|
275
|
-
this.userid,
|
|
276
|
-
this.identifier,
|
|
277
|
-
this.lendingStatus,
|
|
278
|
-
this.bwbPurchaseUrl
|
|
279
|
-
);
|
|
280
|
-
const actions = this.lendingOptions.getCurrentLendingActions();
|
|
281
|
-
|
|
282
|
-
if (!actions) return;
|
|
283
|
-
|
|
284
|
-
this.primaryTitle = actions.primaryTitle;
|
|
285
|
-
this.primaryActions = actions.primaryActions?.filter(action => {
|
|
286
|
-
return action != null;
|
|
287
|
-
});
|
|
288
|
-
this.primaryColor = actions.primaryColor;
|
|
289
|
-
this.secondaryActions = actions.secondaryActions?.filter(action => {
|
|
290
|
-
return action != null;
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
this.borrowType = actions.borrowType ? actions.borrowType : null;
|
|
306
|
+
if (!this.applyLendingActions()) return;
|
|
294
307
|
|
|
295
308
|
// Don't (re)start the countdown while a renewal is in flight — the
|
|
296
309
|
// optimistic browsingExpired flip in autoRenewExpiredLoan() (which
|
|
@@ -336,6 +349,32 @@ export default class IABookActions extends LitElement {
|
|
|
336
349
|
this.requestUpdate();
|
|
337
350
|
}
|
|
338
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Is this `BookReader:userAction` BookReader's own init-time jump to the
|
|
354
|
+
* patron's last-read page, rather than something the patron just did?
|
|
355
|
+
*
|
|
356
|
+
* BookReader.updateFirstIndex() fires `userAction` unconditionally, and
|
|
357
|
+
* init() reaches it via updateFromParams() — so a plain page refresh
|
|
358
|
+
* looks identical to a page turn. Auto-renewing off that would silently
|
|
359
|
+
* re-borrow a lapsed book with no patron input at all.
|
|
360
|
+
*
|
|
361
|
+
* BookReader.trigger() passes the BookReader instance as
|
|
362
|
+
* `event.detail.props`, and init() holds `init.initComplete === false`
|
|
363
|
+
* for exactly the span that contains that init-time jump, setting it to
|
|
364
|
+
* true immediately before firing PostInit. So the flag is an exact,
|
|
365
|
+
* timing-independent marker of "this came from init".
|
|
366
|
+
*
|
|
367
|
+
* Only an explicit `false` suppresses the event: a BookReader too old to
|
|
368
|
+
* expose the flag, or a synthetic event with no detail, must still be
|
|
369
|
+
* treated as a genuine interaction.
|
|
370
|
+
*
|
|
371
|
+
* @param {Event} event - the BookReader:userAction event
|
|
372
|
+
* @returns {boolean}
|
|
373
|
+
*/
|
|
374
|
+
isBookReaderInitAction(event) {
|
|
375
|
+
return event?.detail?.props?.init?.initComplete === false;
|
|
376
|
+
}
|
|
377
|
+
|
|
339
378
|
/**
|
|
340
379
|
* Bind 1 hour loan auto renew event,
|
|
341
380
|
* There are two events we want to use,
|
|
@@ -343,15 +382,13 @@ export default class IABookActions extends LitElement {
|
|
|
343
382
|
* 2. IABookActions:loanRenew - dispatched from timer-countdown component
|
|
344
383
|
*/
|
|
345
384
|
bindLoanRenewEvents() {
|
|
346
|
-
this.userActionReadyAt = Date.now() + this.userActionGracePeriod;
|
|
347
|
-
|
|
348
385
|
/**
|
|
349
386
|
* dispatched this event from bookreader page changed
|
|
350
387
|
*/
|
|
351
|
-
window.addEventListener('BookReader:userAction',
|
|
352
|
-
if (
|
|
388
|
+
window.addEventListener('BookReader:userAction', event => {
|
|
389
|
+
if (this.isBookReaderInitAction(event)) {
|
|
353
390
|
log(
|
|
354
|
-
'[IABookActions] BookReader:userAction ignored —
|
|
391
|
+
'[IABookActions] BookReader:userAction ignored — fired by BookReader init'
|
|
355
392
|
);
|
|
356
393
|
return;
|
|
357
394
|
}
|
|
@@ -392,7 +429,10 @@ export default class IABookActions extends LitElement {
|
|
|
392
429
|
return;
|
|
393
430
|
}
|
|
394
431
|
|
|
395
|
-
log(
|
|
432
|
+
log(
|
|
433
|
+
'[IABookActions] visibilitychange: tab foregrounded',
|
|
434
|
+
this.borrowType
|
|
435
|
+
);
|
|
396
436
|
|
|
397
437
|
try {
|
|
398
438
|
// Loan already expired while tab was hidden — try to silently renew
|
|
@@ -414,9 +454,16 @@ export default class IABookActions extends LitElement {
|
|
|
414
454
|
if (secondsLeft >= this.timerExecutionSeconds) {
|
|
415
455
|
this.loanStatusCheckInterval(Number(secondsLeft));
|
|
416
456
|
} else {
|
|
417
|
-
// Loan expired while user was away — try to silently renew
|
|
457
|
+
// Loan expired while user was away — try to silently renew.
|
|
458
|
+
// Only stop the stale intervals here; this used to call
|
|
459
|
+
// disconnectedCallback(), which was harmless back when the
|
|
460
|
+
// following modal navigated away, but the reading session now
|
|
461
|
+
// continues. Tearing down would drop the resize observer for
|
|
462
|
+
// good (it's only re-added on firstUpdated / sharedObserver
|
|
463
|
+
// change), leaving the bar unable to respond to viewport
|
|
464
|
+
// changes, and would report a bogus disconnect to Sentry.
|
|
465
|
+
window?.IALendingIntervals?.clearAll();
|
|
418
466
|
this.autoRenewExpiredLoan();
|
|
419
|
-
this.disconnectedCallback();
|
|
420
467
|
}
|
|
421
468
|
}
|
|
422
469
|
} catch (error) {
|
|
@@ -466,9 +513,12 @@ export default class IABookActions extends LitElement {
|
|
|
466
513
|
*/
|
|
467
514
|
autoRenewExpiredLoan() {
|
|
468
515
|
if (this.loanRenewInProgress) {
|
|
469
|
-
log(
|
|
470
|
-
|
|
471
|
-
|
|
516
|
+
log(
|
|
517
|
+
'[IABookActions] autoRenewExpiredLoan: skipped, renewal already in progress',
|
|
518
|
+
{
|
|
519
|
+
identifier: this.identifier,
|
|
520
|
+
}
|
|
521
|
+
);
|
|
472
522
|
return;
|
|
473
523
|
}
|
|
474
524
|
log('[IABookActions] autoRenewExpiredLoan: starting silent renewal', {
|
|
@@ -569,7 +619,8 @@ export default class IABookActions extends LitElement {
|
|
|
569
619
|
>
|
|
570
620
|
Okay
|
|
571
621
|
</button>
|
|
572
|
-
</div>
|
|
622
|
+
</div>
|
|
623
|
+
`;
|
|
573
624
|
|
|
574
625
|
this.modal.setAttribute('aria-live', 'assertive');
|
|
575
626
|
await this.modal?.showModal({ config, customModalContent });
|
|
@@ -602,9 +653,12 @@ export default class IABookActions extends LitElement {
|
|
|
602
653
|
// remove respected key:value for loan-renew
|
|
603
654
|
await this.localCache.delete(`${this.identifier}-loanTime`);
|
|
604
655
|
await this.localCache.delete(`${this.identifier}-pageChangedTime`);
|
|
605
|
-
log(
|
|
606
|
-
|
|
607
|
-
|
|
656
|
+
log(
|
|
657
|
+
'[IABookActions] browseHasExpired: cleared loanTime/pageChangedTime cache',
|
|
658
|
+
{
|
|
659
|
+
identifier: this.identifier,
|
|
660
|
+
}
|
|
661
|
+
);
|
|
608
662
|
|
|
609
663
|
// show message after browsed book is expired.
|
|
610
664
|
this.loanRenewResult.renewNow = false;
|
|
@@ -612,6 +666,10 @@ export default class IABookActions extends LitElement {
|
|
|
612
666
|
'This book has been returned due to inactivity.';
|
|
613
667
|
|
|
614
668
|
this.modal?.closeModal();
|
|
669
|
+
// We just closed whatever was on screen, including the warning modal —
|
|
670
|
+
// release the re-entrancy guard so a later pre-expiry window can show
|
|
671
|
+
// it again. Otherwise it stays latched for the life of the component.
|
|
672
|
+
this.warningModalOpen = false;
|
|
615
673
|
|
|
616
674
|
this.sentryCaptureMsg(sentryLogs.browseHasExpired);
|
|
617
675
|
}
|
|
@@ -726,10 +784,34 @@ export default class IABookActions extends LitElement {
|
|
|
726
784
|
*/
|
|
727
785
|
async handleLoanAutoRenewed({ detail }) {
|
|
728
786
|
const activeLoan = detail?.data?.loan;
|
|
729
|
-
|
|
730
|
-
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Defensive: ActionsHandler only dispatches this event for a CONFIRMED
|
|
790
|
+
* renewal, but treat anything else as a failure rather than a success.
|
|
791
|
+
*
|
|
792
|
+
* A `renewal: false` loan reaching the success path below is actively
|
|
793
|
+
* harmful: setBrowseTimeSession() never ran, so the loanTime read comes
|
|
794
|
+
* back undefined, and the NaN fallback then invents a full fresh hour —
|
|
795
|
+
* putting a brand-new countdown on screen alongside the "someone else
|
|
796
|
+
* has now borrowed it" modal.
|
|
797
|
+
*
|
|
798
|
+
* Clearing loanRenewInProgress here matters just as much. Leaving it set
|
|
799
|
+
* latches the component: autoLoanRenewChecker() and
|
|
800
|
+
* autoRenewExpiredLoan() both no-op on it, and neither the countdown nor
|
|
801
|
+
* the token poller will restart — the loan sits "active" forever with a
|
|
802
|
+
* dead timer.
|
|
803
|
+
*/
|
|
804
|
+
if (!activeLoan?.renewal) {
|
|
805
|
+
this.loanRenewInProgress = false;
|
|
731
806
|
this.recoveringFromLoanExpiry = false;
|
|
732
|
-
this.
|
|
807
|
+
this.warningModalOpen = false;
|
|
808
|
+
|
|
809
|
+
// dispatchActionError() has already reported this and shown the
|
|
810
|
+
// relevant modal; don't stack a second one on top.
|
|
811
|
+
log('[IABookActions] handleLoanAutoRenewed: not a confirmed renewal', {
|
|
812
|
+
identifier: this.identifier,
|
|
813
|
+
activeLoan,
|
|
814
|
+
});
|
|
733
815
|
return;
|
|
734
816
|
}
|
|
735
817
|
|
|
@@ -920,7 +1002,10 @@ export default class IABookActions extends LitElement {
|
|
|
920
1002
|
// show warning modal with remaining time to auto returned it.
|
|
921
1003
|
// once the patron has dismissed it, don't re-show it on every subsequent
|
|
922
1004
|
// tick — only an actual renewal (see handleLoanAutoRenewed) re-arms it.
|
|
923
|
-
if (
|
|
1005
|
+
if (
|
|
1006
|
+
this.loanRenewResult.renewNow === false &&
|
|
1007
|
+
!this.warningModalDismissed
|
|
1008
|
+
) {
|
|
924
1009
|
/**
|
|
925
1010
|
* so compensate for the 50 second buffer to handle above race conditions
|
|
926
1011
|
* let's reduce 1 min from warning texts and early return the book when 1 min left.
|
|
@@ -990,11 +1075,13 @@ export default class IABookActions extends LitElement {
|
|
|
990
1075
|
|
|
991
1076
|
const action = event?.detail?.action;
|
|
992
1077
|
const errorMsg = event?.detail?.data?.error;
|
|
1078
|
+
const isInitial = event?.detail?.isInitial === true;
|
|
993
1079
|
|
|
994
1080
|
log('[IABookActions] handleLendingActionError', {
|
|
995
1081
|
identifier: this.identifier,
|
|
996
1082
|
action,
|
|
997
1083
|
errorMsg,
|
|
1084
|
+
isInitial,
|
|
998
1085
|
loanRenewInProgress: this.loanRenewInProgress,
|
|
999
1086
|
recoveringFromLoanExpiry: this.recoveringFromLoanExpiry,
|
|
1000
1087
|
});
|
|
@@ -1006,21 +1093,38 @@ export default class IABookActions extends LitElement {
|
|
|
1006
1093
|
// token poller so it can be retried.
|
|
1007
1094
|
window?.IALendingIntervals?.clearTokenPoller();
|
|
1008
1095
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1096
|
+
/**
|
|
1097
|
+
* Only the INITIAL token matters to the patron: without it BookReader
|
|
1098
|
+
* can't be initialized at all, so the book genuinely won't open and
|
|
1099
|
+
* they need to know. A routine interval refresh failing is a
|
|
1100
|
+
* different thing — the patron is already reading, the pages they
|
|
1101
|
+
* have are still served, and LoanTokenPoller will try again on the
|
|
1102
|
+
* next tick. Interrupting a mid-read session with a blocking modal
|
|
1103
|
+
* over a transient blip (the recurring 405s QA is seeing, say) is
|
|
1104
|
+
* worse than the blip. This is why the original code suppressed
|
|
1105
|
+
* create_token errors outright; keep that for the interval case.
|
|
1106
|
+
*/
|
|
1107
|
+
if (!isInitial) {
|
|
1108
|
+
log(
|
|
1109
|
+
'[IABookActions] create_token failed on interval refresh — staying silent, poller will retry'
|
|
1110
|
+
);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// The loan itself is fine — just the token failed — but the action
|
|
1115
|
+
// bar must reflect that the book isn't openable right now rather
|
|
1116
|
+
// than silently doing nothing.
|
|
1012
1117
|
this.lendingStatus = {
|
|
1013
1118
|
...this.lendingStatus,
|
|
1014
1119
|
user_has_browsed: false,
|
|
1015
1120
|
available_to_browse: true,
|
|
1016
1121
|
};
|
|
1017
1122
|
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
// (refresh button + support email).
|
|
1123
|
+
// LoanTokenPoller has already exhausted its retries for the
|
|
1124
|
+
// transient datanode-propagation race by the time we get here (or
|
|
1125
|
+
// the error was never that), so this is a genuine failure.
|
|
1126
|
+
// showErrorModal has dedicated create_token messaging (refresh
|
|
1127
|
+
// button + support email).
|
|
1024
1128
|
if (errorMsg) this.showErrorModal(errorMsg, action);
|
|
1025
1129
|
} else if (action === 'renew_loan') {
|
|
1026
1130
|
window?.IALendingIntervals?.clearAll();
|
|
@@ -1056,9 +1160,15 @@ export default class IABookActions extends LitElement {
|
|
|
1056
1160
|
// update action bar state if book is not available to browse or borrow.
|
|
1057
1161
|
if (errorMsg.match(/not available to borrow/gm)) {
|
|
1058
1162
|
if (action === 'browse_book') {
|
|
1059
|
-
this.lendingStatus = {
|
|
1163
|
+
this.lendingStatus = {
|
|
1164
|
+
...this.lendingStatus,
|
|
1165
|
+
available_to_browse: false,
|
|
1166
|
+
};
|
|
1060
1167
|
} else if (action === 'borrow_book') {
|
|
1061
|
-
this.lendingStatus = {
|
|
1168
|
+
this.lendingStatus = {
|
|
1169
|
+
...this.lendingStatus,
|
|
1170
|
+
available_to_borrow: false,
|
|
1171
|
+
};
|
|
1062
1172
|
}
|
|
1063
1173
|
}
|
|
1064
1174
|
}
|
|
@@ -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';
|
|
@@ -89,7 +89,7 @@ describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
|
|
|
89
89
|
|
|
90
90
|
expect(errorCallbackSpy.called).to.be.false;
|
|
91
91
|
|
|
92
|
-
clock.tick(tokenPoller.
|
|
92
|
+
clock.tick(tokenPoller.tokenRetryDelay);
|
|
93
93
|
|
|
94
94
|
expect(handleLoanTokenPollerSpy.calledWith(true, 1)).to.be.true;
|
|
95
95
|
} finally {
|
|
@@ -104,19 +104,35 @@ describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
|
|
|
104
104
|
tokenPoller.handleTokenError(
|
|
105
105
|
staleLoanReadError,
|
|
106
106
|
true,
|
|
107
|
-
tokenPoller.
|
|
107
|
+
tokenPoller.maxTokenRetries
|
|
108
108
|
);
|
|
109
109
|
|
|
110
110
|
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
it('
|
|
113
|
+
it('retries a routine (non-initial) poll on the same error too', () => {
|
|
114
|
+
// A routine tick landing moments after a renewal hits the very same
|
|
115
|
+
// write-propagation race as the initial call, so it's equally worth
|
|
116
|
+
// retrying. isInitial only decides how a FINAL failure is reported.
|
|
114
117
|
const tokenPoller = makeTokenPoller();
|
|
118
|
+
const handleLoanTokenPollerSpy = Sinon.spy(
|
|
119
|
+
tokenPoller,
|
|
120
|
+
'handleLoanTokenPoller'
|
|
121
|
+
);
|
|
115
122
|
const errorCallbackSpy = Sinon.spy(tokenPoller, 'errorCallback');
|
|
123
|
+
const clock = Sinon.useFakeTimers();
|
|
116
124
|
|
|
117
|
-
|
|
125
|
+
try {
|
|
126
|
+
tokenPoller.handleTokenError(staleLoanReadError, false, 0);
|
|
118
127
|
|
|
119
|
-
|
|
128
|
+
expect(errorCallbackSpy.called).to.be.false;
|
|
129
|
+
|
|
130
|
+
clock.tick(tokenPoller.tokenRetryDelay);
|
|
131
|
+
|
|
132
|
+
expect(handleLoanTokenPollerSpy.calledWith(false, 1)).to.be.true;
|
|
133
|
+
} finally {
|
|
134
|
+
clock.restore();
|
|
135
|
+
}
|
|
120
136
|
});
|
|
121
137
|
|
|
122
138
|
it('does not retry a genuinely different error, even on the initial call', () => {
|
|
@@ -132,3 +148,53 @@ describe('handleTokenError retry (WEBDEV-8322 follow-up)', () => {
|
|
|
132
148
|
expect(errorCallbackSpy.calledOnce).to.be.true;
|
|
133
149
|
});
|
|
134
150
|
});
|
|
151
|
+
|
|
152
|
+
describe('LoanTokenPoller - WEBDEV-8322 review fixes', () => {
|
|
153
|
+
it('retries a stale-loan-read error on interval refreshes too, not just the initial call', async () => {
|
|
154
|
+
const poller = new LoanTokenPoller('foo', 'browsed', () => {}, () => {}, 120);
|
|
155
|
+
poller.disconnectedCallback(); // stop the real poller started in the ctor
|
|
156
|
+
|
|
157
|
+
const retrySpy = Sinon.stub(poller, 'handleLoanTokenPoller');
|
|
158
|
+
poller.tokenRetryDelay = 1;
|
|
159
|
+
|
|
160
|
+
poller.handleTokenError(
|
|
161
|
+
{ error: 'you do not currently have this book borrowed' },
|
|
162
|
+
false, // interval refresh
|
|
163
|
+
0
|
|
164
|
+
);
|
|
165
|
+
await aTimeout(30);
|
|
166
|
+
|
|
167
|
+
expect(retrySpy.calledOnce).to.be.true;
|
|
168
|
+
expect(retrySpy.firstCall.args).to.deep.equal([false, 1]);
|
|
169
|
+
poller.disconnectedCallback();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('passes isInitial through to the error callback', () => {
|
|
173
|
+
const errorCallback = Sinon.spy();
|
|
174
|
+
const poller = new LoanTokenPoller('foo', 'browsed', () => {}, errorCallback, 120);
|
|
175
|
+
poller.disconnectedCallback();
|
|
176
|
+
|
|
177
|
+
poller.handleTokenError({ error: 'something else went wrong' }, true, 0);
|
|
178
|
+
|
|
179
|
+
expect(errorCallback.calledOnce).to.be.true;
|
|
180
|
+
expect(errorCallback.firstCall.args[0].detail.isInitial).to.be.true;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('cancels a pending retry on teardown', async () => {
|
|
184
|
+
const poller = new LoanTokenPoller('foo', 'browsed', () => {}, () => {}, 120);
|
|
185
|
+
const retrySpy = Sinon.stub(poller, 'handleLoanTokenPoller');
|
|
186
|
+
poller.tokenRetryDelay = 20;
|
|
187
|
+
|
|
188
|
+
poller.handleTokenError(
|
|
189
|
+
{ error: 'you do not currently have this book borrowed' },
|
|
190
|
+
true,
|
|
191
|
+
0
|
|
192
|
+
);
|
|
193
|
+
expect(poller.retryTimeout).to.not.be.undefined;
|
|
194
|
+
|
|
195
|
+
poller.disconnectedCallback();
|
|
196
|
+
await aTimeout(60);
|
|
197
|
+
|
|
198
|
+
expect(retrySpy.called).to.be.false;
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -89,8 +89,6 @@ describe('<ia-book-actions>', () => {
|
|
|
89
89
|
identifier: 'foobar',
|
|
90
90
|
})
|
|
91
91
|
);
|
|
92
|
-
// Not testing the BookReader-init grace period here — bypass it.
|
|
93
|
-
el.userActionReadyAt = 0;
|
|
94
92
|
|
|
95
93
|
const spy = Sinon.spy(el, 'autoLoanRenewChecker');
|
|
96
94
|
el.lendingStatus = { ...el.lendingStatus, browsingExpired: false };
|
|
@@ -113,8 +111,6 @@ describe('<ia-book-actions>', () => {
|
|
|
113
111
|
lendingStatus: { user_has_browsed: true, browsingExpired: true },
|
|
114
112
|
})
|
|
115
113
|
);
|
|
116
|
-
// Not testing the BookReader-init grace period here — bypass it.
|
|
117
|
-
el.userActionReadyAt = 0;
|
|
118
114
|
|
|
119
115
|
const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
|
|
120
116
|
el.borrowType = 'browsed';
|
|
@@ -126,14 +122,17 @@ describe('<ia-book-actions>', () => {
|
|
|
126
122
|
expect(spy.calledOnce).to.be.true;
|
|
127
123
|
});
|
|
128
124
|
|
|
129
|
-
it('ignores BookReader:userAction fired
|
|
130
|
-
// Regression: BookReader.
|
|
131
|
-
//
|
|
132
|
-
//
|
|
125
|
+
it('ignores BookReader:userAction fired by BookReader init (WEBDEV-8322 follow-up)', async () => {
|
|
126
|
+
// Regression: BookReader.updateFirstIndex() fires 'userAction'
|
|
127
|
+
// unconditionally — including on BookReader's own init-time jump to
|
|
128
|
+
// the reader's last-read page (updateFromParams(), called from
|
|
133
129
|
// init()), which has nothing to do with the patron doing anything.
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
130
|
+
// Auto-renewing off that would silently re-borrow a lapsed book on a
|
|
131
|
+
// plain page refresh, before the patron did anything at all.
|
|
132
|
+
//
|
|
133
|
+
// BookReader.trigger() passes the BookReader instance as
|
|
134
|
+
// detail.props, and init() holds init.initComplete === false for
|
|
135
|
+
// exactly the span containing that jump.
|
|
137
136
|
const el = await fixture(
|
|
138
137
|
container({
|
|
139
138
|
userid: '@user1',
|
|
@@ -146,13 +145,60 @@ describe('<ia-book-actions>', () => {
|
|
|
146
145
|
|
|
147
146
|
const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
|
|
148
147
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
window.dispatchEvent(
|
|
149
|
+
new CustomEvent('BookReader:userAction', {
|
|
150
|
+
detail: { props: { init: { initComplete: false } } },
|
|
151
|
+
})
|
|
152
|
+
);
|
|
152
153
|
await el.updateComplete;
|
|
153
154
|
|
|
154
155
|
expect(spy.called).to.be.false;
|
|
155
156
|
});
|
|
157
|
+
|
|
158
|
+
it('honours BookReader:userAction once BookReader init has completed', async () => {
|
|
159
|
+
const el = await fixture(
|
|
160
|
+
container({
|
|
161
|
+
userid: '@user1',
|
|
162
|
+
identifier: 'foobar',
|
|
163
|
+
lendingStatus: { user_has_browsed: true, browsingExpired: true },
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
el.borrowType = 'browsed';
|
|
167
|
+
await el.updateComplete;
|
|
168
|
+
|
|
169
|
+
const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
|
|
170
|
+
|
|
171
|
+
window.dispatchEvent(
|
|
172
|
+
new CustomEvent('BookReader:userAction', {
|
|
173
|
+
detail: { props: { init: { initComplete: true } } },
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
await el.updateComplete;
|
|
177
|
+
|
|
178
|
+
expect(spy.calledOnce).to.be.true;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('treats a userAction with no BookReader detail as a genuine interaction', async () => {
|
|
182
|
+
// A BookReader too old to expose init.initComplete, or any synthetic
|
|
183
|
+
// dispatch, must not be silently swallowed — only an explicit false
|
|
184
|
+
// suppresses the event.
|
|
185
|
+
const el = await fixture(
|
|
186
|
+
container({
|
|
187
|
+
userid: '@user1',
|
|
188
|
+
identifier: 'foobar',
|
|
189
|
+
lendingStatus: { user_has_browsed: true, browsingExpired: true },
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
el.borrowType = 'browsed';
|
|
193
|
+
await el.updateComplete;
|
|
194
|
+
|
|
195
|
+
const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
|
|
196
|
+
|
|
197
|
+
window.dispatchEvent(new Event('BookReader:userAction'));
|
|
198
|
+
await el.updateComplete;
|
|
199
|
+
|
|
200
|
+
expect(spy.calledOnce).to.be.true;
|
|
201
|
+
});
|
|
156
202
|
});
|
|
157
203
|
|
|
158
204
|
describe('Primary Actions data', () => {
|
|
@@ -741,7 +787,7 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
741
787
|
);
|
|
742
788
|
collapsibleActionGroupEl.dispatchEvent(
|
|
743
789
|
new CustomEvent('loanAutoRenewed', {
|
|
744
|
-
detail: { data: { loan: { identifier: 'foobar' } } },
|
|
790
|
+
detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
|
|
745
791
|
})
|
|
746
792
|
);
|
|
747
793
|
|
|
@@ -792,7 +838,7 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
792
838
|
);
|
|
793
839
|
collapsibleActionGroupEl.dispatchEvent(
|
|
794
840
|
new CustomEvent('loanAutoRenewed', {
|
|
795
|
-
detail: { data: { loan: { identifier: 'foobar' } } },
|
|
841
|
+
detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
|
|
796
842
|
})
|
|
797
843
|
);
|
|
798
844
|
|
|
@@ -848,7 +894,7 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
848
894
|
);
|
|
849
895
|
collapsibleActionGroupEl.dispatchEvent(
|
|
850
896
|
new CustomEvent('loanAutoRenewed', {
|
|
851
|
-
detail: { data: { loan: { identifier: 'foobar' } } },
|
|
897
|
+
detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
|
|
852
898
|
})
|
|
853
899
|
);
|
|
854
900
|
|
|
@@ -884,7 +930,7 @@ describe('autoRenewExpiredLoan', () => {
|
|
|
884
930
|
);
|
|
885
931
|
collapsibleActionGroupEl.dispatchEvent(
|
|
886
932
|
new CustomEvent('loanAutoRenewed', {
|
|
887
|
-
detail: { data: { loan: { identifier: 'foobar' } } },
|
|
933
|
+
detail: { data: { loan: { identifier: 'foobar', renewal: true } } },
|
|
888
934
|
})
|
|
889
935
|
);
|
|
890
936
|
|
|
@@ -944,8 +990,6 @@ describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
|
|
|
944
990
|
);
|
|
945
991
|
await el.updateComplete;
|
|
946
992
|
expect(el.borrowType).to.equal('browsed');
|
|
947
|
-
// Not testing the BookReader-init grace period here — bypass it.
|
|
948
|
-
el.userActionReadyAt = 0;
|
|
949
993
|
|
|
950
994
|
el.lendingStatus = { ...baseStatus, browsingExpired: true };
|
|
951
995
|
await el.updateComplete;
|
|
@@ -987,8 +1031,6 @@ describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
|
|
|
987
1031
|
);
|
|
988
1032
|
await el.updateComplete;
|
|
989
1033
|
expect(el.borrowType).to.equal('browsed');
|
|
990
|
-
// Not testing the BookReader-init grace period here — bypass it.
|
|
991
|
-
el.userActionReadyAt = 0;
|
|
992
1034
|
|
|
993
1035
|
el.loanRenewInProgress = true;
|
|
994
1036
|
|
|
@@ -1062,9 +1104,11 @@ describe('handleLendingActionError - create_token failures must not stop the cou
|
|
|
1062
1104
|
it('shows the error modal for a create_token failure that reaches here (retries already exhausted)', async () => {
|
|
1063
1105
|
// By the time handleLendingActionError runs for a create_token
|
|
1064
1106
|
// failure, LoanTokenPoller has already retried and given up (see
|
|
1065
|
-
// loan-token-poller.js's handleTokenError)
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1107
|
+
// loan-token-poller.js's handleTokenError). For the INITIAL token
|
|
1108
|
+
// that's terminal — BookReader can't be initialized and the book
|
|
1109
|
+
// won't open — so the patron must be told rather than left staring
|
|
1110
|
+
// at nothing. (An interval refresh failing is handled separately and
|
|
1111
|
+
// stays silent; see the WEBDEV-8322 review fixes below.)
|
|
1068
1112
|
const el = await fixture(
|
|
1069
1113
|
container({
|
|
1070
1114
|
userid: '@user1',
|
|
@@ -1084,6 +1128,7 @@ describe('handleLendingActionError - create_token failures must not stop the cou
|
|
|
1084
1128
|
el.handleLendingActionError({
|
|
1085
1129
|
detail: {
|
|
1086
1130
|
action: 'create_token',
|
|
1131
|
+
isInitial: true,
|
|
1087
1132
|
data: { error: errorMsg },
|
|
1088
1133
|
},
|
|
1089
1134
|
});
|
|
@@ -1316,3 +1361,183 @@ describe('Shared Resize Observer', () => {
|
|
|
1316
1361
|
expect(component.sharedObserver).to.not.be.undefined;
|
|
1317
1362
|
});
|
|
1318
1363
|
});
|
|
1364
|
+
|
|
1365
|
+
describe('WEBDEV-8322 review fixes', () => {
|
|
1366
|
+
it('populates the action bar on a fresh load of an already-expired loan', async () => {
|
|
1367
|
+
// Regression: the hasExpired early-return was hoisted above the
|
|
1368
|
+
// GetLendingActions computation so auto-return wouldn't visibly change
|
|
1369
|
+
// the bar. That's right for the transition, but this method also runs
|
|
1370
|
+
// on the FIRST lendingStatus update, where there is no bar to preserve
|
|
1371
|
+
// — leaving the patron a blank bar and no way to re-borrow.
|
|
1372
|
+
const el = await fixture(
|
|
1373
|
+
container({
|
|
1374
|
+
userid: '@user1',
|
|
1375
|
+
identifier: 'fresh-expired',
|
|
1376
|
+
lendingStatus: {
|
|
1377
|
+
is_lendable: true,
|
|
1378
|
+
available_to_browse: true,
|
|
1379
|
+
available_to_borrow: true,
|
|
1380
|
+
user_has_browsed: true,
|
|
1381
|
+
browsingExpired: true,
|
|
1382
|
+
secondsLeftOnLoan: 0,
|
|
1383
|
+
},
|
|
1384
|
+
})
|
|
1385
|
+
);
|
|
1386
|
+
await el.updateComplete;
|
|
1387
|
+
|
|
1388
|
+
expect(el.primaryActions.length).to.be.greaterThan(0);
|
|
1389
|
+
expect(el.primaryActions.map(a => a.id)).to.include('browseBook');
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
it('still leaves a rendered action bar untouched when the loan auto-returns', async () => {
|
|
1393
|
+
const el = await fixture(
|
|
1394
|
+
container({
|
|
1395
|
+
userid: '@user1',
|
|
1396
|
+
identifier: 'auto-returned',
|
|
1397
|
+
lendingStatus: {
|
|
1398
|
+
is_lendable: true,
|
|
1399
|
+
available_to_browse: true,
|
|
1400
|
+
available_to_borrow: true,
|
|
1401
|
+
user_has_browsed: true,
|
|
1402
|
+
browsingExpired: false,
|
|
1403
|
+
secondsLeftOnLoan: 300,
|
|
1404
|
+
},
|
|
1405
|
+
})
|
|
1406
|
+
);
|
|
1407
|
+
await el.updateComplete;
|
|
1408
|
+
expect(el.primaryActions[0].text).to.equal('Return now');
|
|
1409
|
+
|
|
1410
|
+
el.lendingStatus = { ...el.lendingStatus, browsingExpired: true };
|
|
1411
|
+
await el.updateComplete;
|
|
1412
|
+
|
|
1413
|
+
// Auto-return must not visibly change the bar (the ticket's core ask).
|
|
1414
|
+
expect(el.primaryActions[0].text).to.equal('Return now');
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
it('does not treat a `renewal: false` response as a successful renewal', async () => {
|
|
1418
|
+
// Regression: handleLoanAutoRenewed saw a truthy `loan` and took the
|
|
1419
|
+
// success path. setBrowseTimeSession() never ran, so the loanTime read
|
|
1420
|
+
// was undefined -> NaN -> the fallback invented a full fresh hour,
|
|
1421
|
+
// putting a new countdown on screen next to the failure modal.
|
|
1422
|
+
const el = await fixture(
|
|
1423
|
+
container({
|
|
1424
|
+
userid: '@user1',
|
|
1425
|
+
identifier: 'not-renewed',
|
|
1426
|
+
lendingStatus: {
|
|
1427
|
+
is_lendable: true,
|
|
1428
|
+
available_to_browse: true,
|
|
1429
|
+
user_has_browsed: true,
|
|
1430
|
+
browsingExpired: true,
|
|
1431
|
+
secondsLeftOnLoan: 0,
|
|
1432
|
+
},
|
|
1433
|
+
})
|
|
1434
|
+
);
|
|
1435
|
+
await el.updateComplete;
|
|
1436
|
+
el.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
|
|
1437
|
+
el.loanRenewInProgress = true;
|
|
1438
|
+
await el.updateComplete;
|
|
1439
|
+
|
|
1440
|
+
await el.handleLoanAutoRenewed({
|
|
1441
|
+
detail: {
|
|
1442
|
+
action: 'renew_loan',
|
|
1443
|
+
data: { success: true, loan: { renewal: false } },
|
|
1444
|
+
},
|
|
1445
|
+
});
|
|
1446
|
+
await el.updateComplete;
|
|
1447
|
+
|
|
1448
|
+
expect(el.lendingStatus.secondsLeftOnLoan).to.not.equal(
|
|
1449
|
+
el.loanRenewTimeConfig.loanTotalTime
|
|
1450
|
+
);
|
|
1451
|
+
expect(el.loanRenewInProgress).to.be.false;
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it('clears loanRenewInProgress when the renewal response carries no loan', async () => {
|
|
1455
|
+
// Regression: the !activeLoan branch cleared recoveringFromLoanExpiry
|
|
1456
|
+
// but left loanRenewInProgress set, latching the component — every
|
|
1457
|
+
// later renewal attempt no-ops and neither the countdown nor the token
|
|
1458
|
+
// poller ever restarts.
|
|
1459
|
+
const el = await fixture(
|
|
1460
|
+
container({
|
|
1461
|
+
userid: '@user1',
|
|
1462
|
+
identifier: 'no-loan',
|
|
1463
|
+
lendingStatus: { user_has_browsed: true, browsingExpired: true },
|
|
1464
|
+
})
|
|
1465
|
+
);
|
|
1466
|
+
await el.updateComplete;
|
|
1467
|
+
|
|
1468
|
+
el.autoRenewExpiredLoan();
|
|
1469
|
+
expect(el.loanRenewInProgress).to.be.true;
|
|
1470
|
+
|
|
1471
|
+
await el.handleLoanAutoRenewed({
|
|
1472
|
+
detail: { action: 'renew_loan', data: { success: true } },
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
expect(el.loanRenewInProgress).to.be.false;
|
|
1476
|
+
expect(el.recoveringFromLoanExpiry).to.be.false;
|
|
1477
|
+
|
|
1478
|
+
// and the component is usable again, not latched
|
|
1479
|
+
el.autoRenewExpiredLoan();
|
|
1480
|
+
expect(el.loanRenewInProgress).to.be.true;
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
it('stays silent when an interval create_token refresh fails', async () => {
|
|
1484
|
+
const el = await fixture(
|
|
1485
|
+
container({
|
|
1486
|
+
userid: '@user1',
|
|
1487
|
+
identifier: 'token-blip',
|
|
1488
|
+
lendingStatus: {
|
|
1489
|
+
is_lendable: true,
|
|
1490
|
+
available_to_browse: true,
|
|
1491
|
+
user_has_browsed: true,
|
|
1492
|
+
browsingExpired: false,
|
|
1493
|
+
secondsLeftOnLoan: 300,
|
|
1494
|
+
},
|
|
1495
|
+
})
|
|
1496
|
+
);
|
|
1497
|
+
await el.updateComplete;
|
|
1498
|
+
const spy = Sinon.spy(el, 'showErrorModal');
|
|
1499
|
+
|
|
1500
|
+
el.handleLendingActionError({
|
|
1501
|
+
detail: {
|
|
1502
|
+
action: 'create_token',
|
|
1503
|
+
isInitial: false,
|
|
1504
|
+
data: { error: 'loan token not found. please try again later.' },
|
|
1505
|
+
},
|
|
1506
|
+
});
|
|
1507
|
+
await el.updateComplete;
|
|
1508
|
+
|
|
1509
|
+
expect(spy.called).to.be.false;
|
|
1510
|
+
// the patron is still reading — don't knock them out of the loan
|
|
1511
|
+
expect(el.lendingStatus.user_has_browsed).to.be.true;
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it('surfaces a failed initial create_token, which blocks opening the book', async () => {
|
|
1515
|
+
const el = await fixture(
|
|
1516
|
+
container({
|
|
1517
|
+
userid: '@user1',
|
|
1518
|
+
identifier: 'token-initial',
|
|
1519
|
+
lendingStatus: {
|
|
1520
|
+
is_lendable: true,
|
|
1521
|
+
available_to_browse: true,
|
|
1522
|
+
user_has_browsed: true,
|
|
1523
|
+
browsingExpired: false,
|
|
1524
|
+
secondsLeftOnLoan: 300,
|
|
1525
|
+
},
|
|
1526
|
+
})
|
|
1527
|
+
);
|
|
1528
|
+
await el.updateComplete;
|
|
1529
|
+
const spy = Sinon.spy(el, 'showErrorModal');
|
|
1530
|
+
|
|
1531
|
+
el.handleLendingActionError({
|
|
1532
|
+
detail: {
|
|
1533
|
+
action: 'create_token',
|
|
1534
|
+
isInitial: true,
|
|
1535
|
+
data: { error: 'loan token not found. please try again later.' },
|
|
1536
|
+
},
|
|
1537
|
+
});
|
|
1538
|
+
await el.updateComplete;
|
|
1539
|
+
|
|
1540
|
+
expect(spy.calledOnce).to.be.true;
|
|
1541
|
+
expect(el.lendingStatus.user_has_browsed).to.be.false;
|
|
1542
|
+
});
|
|
1543
|
+
});
|