@internetarchive/ia-book-actions 1.4.3-canary.0 → 1.4.3

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.
@@ -12,6 +12,10 @@ on:
12
12
  - synchronize
13
13
  - closed
14
14
 
15
+ permissions:
16
+ contents: write
17
+ pull-requests: write
18
+
15
19
  concurrency: preview-${{ github.ref }}
16
20
 
17
21
  env:
package/package.json CHANGED
@@ -3,11 +3,11 @@
3
3
  "description": "Bookreader actions for borrowable book",
4
4
  "repository": {
5
5
  "type": "git",
6
- "url": "git+https://github.com/internetarchive/iaux-book-actions.git"
6
+ "url": "git+https://github.com/internetarchive/iaux-account-settings.git"
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.3-canary.0",
10
+ "version": "1.4.3",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -409,6 +409,7 @@ export default class ActionsHandler extends LitElement {
409
409
  */
410
410
  setStickyAdminAccess(value) {
411
411
  const domain = window.location.hostname === 'localhost' ? 'localhost' : '.archive.org';
412
- Cookies.setItem('sticky-admin-access', value, '', '/', domain);
412
+ const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days from now
413
+ Cookies.setItem('sticky-admin-access', value, expires, '/', domain);
413
414
  }
414
415
  }
@@ -150,7 +150,7 @@ export default class LoanAnanlytics {
150
150
  }
151
151
 
152
152
  /**
153
- * responsible to sent events to GA
153
+ * reponsible to sent events to GA
154
154
  *
155
155
  * @param {*} eventCategory
156
156
  * @param {*} eventAction
@@ -27,7 +27,7 @@ export class LoanRenewHelper {
27
27
  };
28
28
  }
29
29
 
30
- async handleLoanRenew() {
30
+ handleLoanRenew() {
31
31
  try {
32
32
  if (this.hasPageChanged) {
33
33
  return this.pageChanged(); // user clicked on page
@@ -98,10 +98,6 @@ export default class IABookActions extends LitElement {
98
98
  */
99
99
  this.returnNow = false;
100
100
 
101
- this.loanRenewInProgress = false;
102
-
103
- this.warningModalOpen = false;
104
-
105
101
  /**
106
102
  * contains one hour auto-loan-renew time configuration
107
103
  * defaults to 1 hour config
@@ -145,6 +141,7 @@ export default class IABookActions extends LitElement {
145
141
  * @param {string} msg
146
142
  */
147
143
  sentryCaptureMsg(msg) {
144
+ log(window?.Sentry);
148
145
  window?.Sentry?.captureMessage(msg);
149
146
  }
150
147
 
@@ -236,7 +233,7 @@ export default class IABookActions extends LitElement {
236
233
  'browsingExpired' in this.lendingStatus &&
237
234
  this.lendingStatus?.browsingExpired;
238
235
  if (hasExpired) {
239
- log('[IABookActions] browsing expired — showing expired state');
236
+ log('setupLendingToolbarActions > hasExpired --- ');
240
237
 
241
238
  if (!this.tokenPoller) {
242
239
  this.sentryCaptureMsg(sentryLogs.bookWasExpired);
@@ -293,11 +290,7 @@ export default class IABookActions extends LitElement {
293
290
  * dispatched this event from bookreader page changed
294
291
  */
295
292
  window.addEventListener('BookReader:userAction', () => {
296
- // If the loan expired while the tab stayed visible, auto-renew on page turn
297
- if (this.lendingStatus.browsingExpired) {
298
- this.autoRenewExpiredLoan();
299
- }
300
-
293
+ log('IABookActions:BookReader:userAction');
301
294
  if (this.borrowType === 'browsed') {
302
295
  this.autoLoanRenewChecker(true);
303
296
  }
@@ -310,17 +303,17 @@ export default class IABookActions extends LitElement {
310
303
  */
311
304
  document.addEventListener('visibilitychange', async () => {
312
305
  if (!document.hidden) {
313
- log('[IABookActions] visibilitychange:', this.borrowType);
314
-
315
- // Loan already expired while tab was hidden — try to silently renew
316
- if (this.lendingStatus.browsingExpired === true) {
317
- this.autoRenewExpiredLoan();
318
- return;
319
- }
320
-
321
- if (this.borrowType !== 'browsed') return;
322
-
323
- if (this.lendingStatus.browsingExpired === false) {
306
+ log(
307
+ 'visibilitychange event execute:------------------ ',
308
+ new Date().getMinutes(),
309
+ new Date().getSeconds(),
310
+ this.borrowType
311
+ );
312
+
313
+ if (
314
+ this.borrowType === 'browsed' &&
315
+ this.lendingStatus.browsingExpired === false
316
+ ) {
324
317
  const loanTime = await this.localCache.get(
325
318
  `${this.identifier}-loanTime`
326
319
  );
@@ -331,8 +324,7 @@ export default class IABookActions extends LitElement {
331
324
  if (secondsLeft >= this.timerExecutionSeconds) {
332
325
  this.loanStatusCheckInterval(Number(secondsLeft));
333
326
  } else {
334
- // Loan expired while user was away — try to silently renew
335
- this.autoRenewExpiredLoan();
327
+ this.browseHasExpired();
336
328
  this.disconnectedCallback();
337
329
  }
338
330
  }
@@ -358,36 +350,6 @@ export default class IABookActions extends LitElement {
358
350
  this.loanRenewResult = this.loanRenewHelper.result;
359
351
  }
360
352
 
361
- /**
362
- * Attempt to automatically renew a browse loan that has expired.
363
- * Called on visibilitychange (user returns to tab) or on page turn when
364
- * the loan has expired but the book may still be available.
365
- *
366
- * Reuses the same renew_loan path as the "Keep reading" warning modal button.
367
- * On success: handleLoanAutoRenewed() resets the timer seamlessly.
368
- * On error: handleLendingActionError() shows showLoanUnavailableModal().
369
- */
370
- autoRenewExpiredLoan() {
371
- if (this.loanRenewInProgress) return;
372
- this.loanRenewInProgress = true;
373
-
374
- this.modal?.closeModal();
375
-
376
- // Optimistically flip browsingExpired back to false so the action bar
377
- // stays red (patronIsReadingAction) while the renew_loan request is in
378
- // flight, instead of showing the blue "Borrow" state.
379
- this.lendingStatus = {
380
- ...this.lendingStatus,
381
- browsingExpired: false,
382
- secondsLeftOnLoan: this.loanRenewTimeConfig.loanTotalTime,
383
- };
384
-
385
- // Defer renewNow until after lendingStatus timer setup completes.
386
- setTimeout(() => {
387
- this.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
388
- }, 0);
389
- }
390
-
391
353
  /**
392
354
  * Required as sibling on page
393
355
  * @returns HTMLElement
@@ -403,34 +365,29 @@ export default class IABookActions extends LitElement {
403
365
  * Show the waring modal to ask user if they are still reading
404
366
  */
405
367
  async showWarningModal() {
406
- if (this.warningModalOpen) return;
407
- this.warningModalOpen = true;
408
-
409
- log('[IABookActions] showWarningModal');
410
-
411
- // Capture texts and secondsLeft before resetting loanRenewResult
412
- const {
413
- texts: warningTexts,
414
- secondsLeft: rawSecondsLeft,
415
- } = this.loanRenewResult;
416
- let secondsLeft = rawSecondsLeft;
417
- if (secondsLeft === undefined || secondsLeft <= 0) {
368
+ log('****** showWarningModal ******');
369
+ // clear modal
370
+ this.modal.customModalContent = nothing;
371
+ this.modal?.closeModal();
372
+ this.loanRenewResult = { texts: '', renewNow: false };
373
+
374
+ // if secondsLeft < 60, consider it 1 minute
375
+ let { secondsLeft } = this.loanRenewResult;
376
+ if (secondsLeft === undefined) {
418
377
  secondsLeft = this.lendingStatus.secondsLeftOnLoan;
419
378
  } else {
420
379
  secondsLeft = secondsLeft > 60 ? secondsLeft : 60;
421
380
  }
422
381
 
423
- // clear modal and reset renew state
424
- this.modal.customModalContent = nothing;
425
- this.modal?.closeModal();
426
- this.loanRenewResult = { texts: '', renewNow: false };
427
-
428
382
  const config = new ModalConfig({
429
383
  headline: 'Are you still reading?',
430
384
  headerColor: '#194880',
431
385
  showCloseButton: false,
432
386
  closeOnBackdropClick: false,
433
- message: this.loanRenewHelper?.getMessageTexts(warningTexts, secondsLeft),
387
+ message: this.loanRenewHelper?.getMessageTexts(
388
+ this.loanRenewResult.texts,
389
+ secondsLeft
390
+ ),
434
391
  });
435
392
 
436
393
  const customModalContent = html`<br />
@@ -527,7 +484,6 @@ export default class IABookActions extends LitElement {
527
484
 
528
485
  /**
529
486
  * Show modal when book is auto returned
530
- * @deprecated
531
487
  */
532
488
  async showExpiredModal() {
533
489
  const config = new ModalConfig({
@@ -557,7 +513,7 @@ export default class IABookActions extends LitElement {
557
513
  * Execute when loan is expired
558
514
  */
559
515
  async browseHasExpired() {
560
- log('[IABookActions] browseHasExpired');
516
+ log('BrowseHasExpired ---');
561
517
  window?.IALendingIntervals?.clearAll();
562
518
 
563
519
  const currStatus = {
@@ -576,52 +532,31 @@ export default class IABookActions extends LitElement {
576
532
  this.loanRenewResult.texts =
577
533
  'This book has been returned due to inactivity.';
578
534
 
579
- this.modal?.closeModal();
535
+ await this.showExpiredModal();
580
536
 
581
537
  this.sentryCaptureMsg(sentryLogs.browseHasExpired);
582
538
  }
583
539
 
584
- /**
585
- * Show modal when the book can no longer be automatically renewed
586
- * because another patron has checked it out.
587
- */
588
- async showLoanUnavailableModal() {
589
- const config = new ModalConfig({
590
- headline: '',
591
- showCloseButton: false,
592
- closeOnBackdropClick: false,
593
- headerColor: '#194880',
594
- message:
595
- 'Another patron is borrowing this book. Please check back later.',
596
- });
597
-
598
- const customModalContent = html`<br />
599
- <div style="text-align: center">
600
- <button
601
- style="${modalButtonStyle.iaButton} ${modalButtonStyle.renew}"
602
- @click=${() => URLHelper.goToUrl(this.returnUrl, true)}
603
- >
604
- Okay
605
- </button>
606
- </div>`;
607
-
608
- await this.modal?.showModal({ config, customModalContent });
609
- }
610
-
611
540
  async startBrowseTimer() {
612
541
  window?.IALendingIntervals?.clearBrowseExpireTimeout();
613
542
 
614
- const {
615
- browsingExpired,
616
- user_has_browsed,
617
- secondsLeftOnLoan,
618
- } = this.lendingStatus;
543
+ const { browsingExpired, user_has_browsed, secondsLeftOnLoan } =
544
+ this.lendingStatus;
619
545
 
620
546
  if (!user_has_browsed || browsingExpired) {
547
+ log('startBrowseTimer --- !user_has_browsed || browsingExpired', {
548
+ user_has_browsed,
549
+ browsingExpired,
550
+ secondsLeftOnLoan,
551
+ });
621
552
  return;
622
553
  }
623
554
 
624
555
  window.IALendingIntervals.browseExpireTimeout = setTimeout(() => {
556
+ log(
557
+ 'startBrowseTimer > browseExpireTimeout --- will expire loan',
558
+ secondsLeftOnLoan
559
+ );
625
560
  this.browseHasExpired();
626
561
  }, secondsLeftOnLoan * 1000);
627
562
  }
@@ -703,9 +638,12 @@ export default class IABookActions extends LitElement {
703
638
 
704
639
  // number of seconds left in current loan
705
640
  const secondsLeft = Math.round((loanTime - new Date()) / 1000);
706
- log('[IABookActions] handleLoanAutoRenewed', {
707
- secondsLeft,
641
+ log(loanTime, secondsLeft);
642
+
643
+ log('IABookActions: handleLoanAutoRenewed --- ', {
708
644
  ajaxResponse: detail?.data,
645
+ loanRenewResult: this.loanRenewResult,
646
+ secondsLeftOnLoan: secondsLeft,
709
647
  });
710
648
 
711
649
  const currStatus = {
@@ -722,9 +660,6 @@ export default class IABookActions extends LitElement {
722
660
  this.modal.customModalContent = nothing;
723
661
  this.sentryCaptureMsg(sentryLogs.bookHasRenewed);
724
662
  }
725
-
726
- this.warningModalOpen = false;
727
- this.loanRenewInProgress = false;
728
663
  }
729
664
 
730
665
  /**
@@ -760,19 +695,29 @@ export default class IABookActions extends LitElement {
760
695
  // re-sync timer if gone off because of background window
761
696
  // side effect: updates this.lendingStatus & kicks off lifecycle,
762
697
  // if really updated, escape from here
763
- const resyncd = this.reSyncTimerIfGoneOff(secondsLeft);
698
+ const resyncd = await this.reSyncTimerIfGoneOff(secondsLeft);
764
699
 
765
700
  if (resyncd.hasSynced) {
766
701
  secondsLeft = resyncd.whatShouldLeft;
767
- log('[IABookActions] startTimerCountdown: timer re-synced', {
702
+ log('startTimerCountdown --- stale, timer has resyncd', {
768
703
  secondsLeft,
769
704
  });
770
705
  }
771
706
 
772
- log('[IABookActions] startTimerCountdown', {
773
- secondsLeft,
774
- loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
775
- });
707
+ log(
708
+ 'startTimerCountdown --- countdown still valid. continue...',
709
+ {
710
+ resyncd,
711
+ secondsLeft,
712
+ loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
713
+ },
714
+ 'time: ',
715
+ new Date().getMinutes(),
716
+ ':',
717
+ new Date().getSeconds(),
718
+ ', timerDelay: ',
719
+ this.timerExecutionSeconds
720
+ );
776
721
 
777
722
  /**
778
723
  * execute from last 10th minutes to 0th minute
@@ -797,7 +742,7 @@ export default class IABookActions extends LitElement {
797
742
  * @param {number} timerSecondsLeft - actual seconds left get from setInterval
798
743
  * @returns {Object} { hasSynced: [boolean], whatShouldLeft: [number] }
799
744
  */
800
- reSyncTimerIfGoneOff(timerSecondsLeft) {
745
+ async reSyncTimerIfGoneOff(timerSecondsLeft) {
801
746
  const currentTime = new Date();
802
747
 
803
748
  // current time - loan time
@@ -807,11 +752,27 @@ export default class IABookActions extends LitElement {
807
752
  const secondsShouldLeft =
808
753
  this.lendingStatus.secondsLeftOnLoan - diffInSeconds;
809
754
 
755
+ log('currentTime: ', currentTime);
756
+ log('timeWhenTimerStart: ', this.timeWhenTimerStart);
757
+ log('diffInSeconds: ', diffInSeconds);
758
+ log('secondsShouldLeft: ', secondsShouldLeft);
759
+ log(
760
+ 'this.lendingStatus.secondsLeftOnLoan: ',
761
+ this.lendingStatus.secondsLeftOnLoan
762
+ );
763
+
810
764
  // convert in minutes
811
765
  const whatIsleft = Math.round(timerSecondsLeft);
812
766
  const whatShouldLeft = Math.round(secondsShouldLeft);
813
767
  const timerElSeconds = this.timerCountdownEl.secondsLeftOnLoan || 0;
814
768
 
769
+ log(`reSyncTimerIfGoneOff?`, {
770
+ whatIsleft,
771
+ whatShouldLeft,
772
+ timerElSeconds,
773
+ timeLeftInMin: Math.ceil(timerSecondsLeft / 60),
774
+ });
775
+
815
776
  if (timerElSeconds !== whatShouldLeft || whatIsleft !== whatShouldLeft) {
816
777
  // set lending status with new time to update decrementor
817
778
  const currStatus = {
@@ -822,9 +783,7 @@ export default class IABookActions extends LitElement {
822
783
  }
823
784
 
824
785
  if (whatIsleft !== whatShouldLeft) {
825
- log(
826
- `[IABookActions] reSyncTimerIfGoneOff ${whatIsleft} - ${whatShouldLeft}: re-syncing timer`
827
- );
786
+ log(`reSyncTimerIfGoneOff --- let's re-sync.`);
828
787
  return { hasSynced: true, whatShouldLeft };
829
788
  }
830
789
 
@@ -840,6 +799,10 @@ export default class IABookActions extends LitElement {
840
799
  * @memberof IABookActions
841
800
  */
842
801
  async loanRenewAttempt(secondsLeft) {
802
+ log('loanRenewAttempt ---', {
803
+ secondsLeft,
804
+ loanRenewResult: this.loanRenewResult,
805
+ });
843
806
  let loanSecondsLeft = secondsLeft;
844
807
  /**
845
808
  * auto-renew is not possible in last seconds (let say 50 second) because,
@@ -849,7 +812,7 @@ export default class IABookActions extends LitElement {
849
812
  * so if seconds left is < 50, just expire the loan
850
813
  */
851
814
  if (loanSecondsLeft < 50) {
852
- log('[IABookActions] loanRenewAttempt: < 50s left, expiring loan');
815
+ log('loanRenewAttempt --- loanSecondsLeft < 50, will expire');
853
816
  await this.browseHasExpired();
854
817
  return;
855
818
  }
@@ -931,17 +894,9 @@ export default class IABookActions extends LitElement {
931
894
  const action = event?.detail?.action;
932
895
  const errorMsg = event?.detail?.data?.error;
933
896
 
934
- // For renew_loan failures after auto-renew, show the patron-facing message
935
- if (
936
- errorMsg &&
937
- action === 'renew_loan' &&
938
- errorMsg.match(/not available/)
939
- ) {
940
- this.loanRenewInProgress = false;
941
- this.showLoanUnavailableModal();
942
- } else if (errorMsg && action !== 'create_token') {
897
+ // template not show create_token errors
898
+ if (errorMsg && action !== 'create_token')
943
899
  this.showErrorModal(errorMsg, action);
944
- }
945
900
 
946
901
  // if error related to loan token
947
902
  // - set user_has_browsed to `false`
@@ -0,0 +1,91 @@
1
+ import { expect, fixture } from '@open-wc/testing';
2
+ import sinon from 'sinon';
3
+
4
+ import ActionsHandler from '../../../src/core/services/actions-handler/actions-handler.js';
5
+
6
+ // Define a temporary tag for this element for testing
7
+ const TEST_TAG = 'ia-actions-handler-test';
8
+ if (!customElements.get(TEST_TAG)) {
9
+ customElements.define(TEST_TAG, ActionsHandler);
10
+ }
11
+
12
+ describe('ActionsHandler#setStickyAdminAccess', () => {
13
+ let clock;
14
+ let lastSetCookie;
15
+ let cookieOverridden = false;
16
+ let actionsHandlerFixture;
17
+
18
+ beforeEach(async () => {
19
+ // Intercept document.cookie writes to capture the full cookie string
20
+ Object.defineProperty(document, 'cookie', {
21
+ configurable: true,
22
+ get() {
23
+ return '';
24
+ },
25
+ set(value) {
26
+ lastSetCookie = value;
27
+ },
28
+ });
29
+ cookieOverridden = true;
30
+
31
+ // Create the element via fixture (portable across browsers)
32
+ actionsHandlerFixture = await fixture(`<${TEST_TAG}></${TEST_TAG}>`);
33
+ });
34
+
35
+ afterEach(() => {
36
+ sinon.restore();
37
+ if (clock) {
38
+ clock.restore();
39
+ clock = undefined;
40
+ }
41
+ // restore document.cookie
42
+ if (cookieOverridden) {
43
+ try {
44
+ // remove our instance-level override
45
+ // eslint-disable-next-line no-param-reassign
46
+ delete document.cookie;
47
+ } catch (e) {
48
+ // ignore
49
+ }
50
+ cookieOverridden = false;
51
+ }
52
+ lastSetCookie = undefined;
53
+ });
54
+
55
+ it('sets the sticky-admin-access cookie with correct domain, path, and 30-day expiration', async () => {
56
+ // Arrange: fix time and stub cookie writer
57
+ const now = new Date('2025-01-01T00:00:00.000Z');
58
+ clock = sinon.useFakeTimers({ now: now.getTime() });
59
+
60
+ // Calculate expected values based on current environment
61
+ const expectedDomain = window.location.hostname === 'localhost' ? 'localhost' : '.archive.org';
62
+ const expectedExpires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
63
+
64
+ // Act: call the method on the element created in beforeEach
65
+ actionsHandlerFixture.setStickyAdminAccess(true);
66
+
67
+ // Assert: verify the composed cookie string
68
+ expect(lastSetCookie).to.be.a('string');
69
+ // Name and value
70
+ expect(lastSetCookie).to.contain(`${encodeURIComponent('sticky-admin-access')}=${encodeURIComponent('true')}`);
71
+ // Domain
72
+ expect(lastSetCookie).to.contain(`domain=${expectedDomain}`);
73
+ // Path
74
+ expect(lastSetCookie).to.contain('path=/');
75
+ // Expiration close to 30 days from now
76
+ const match = /expires=([^;]+)/.exec(lastSetCookie);
77
+ expect(match).to.not.equal(null);
78
+ const expiresStr = match && match[1];
79
+ const parsed = expiresStr ? new Date(expiresStr) : null;
80
+ expect(parsed).to.be.instanceOf(Date);
81
+ expect(parsed && parsed.getTime()).to.equal(expectedExpires.getTime());
82
+ });
83
+
84
+ it('sets the cookie value to false when disabling', async () => {
85
+ // Act
86
+ actionsHandlerFixture.setStickyAdminAccess(false);
87
+
88
+ expect(lastSetCookie).to.be.a('string');
89
+ expect(lastSetCookie).to.contain(`${encodeURIComponent('sticky-admin-access')}=${encodeURIComponent('false')}`);
90
+ });
91
+ });
@@ -82,7 +82,7 @@ describe('<ia-book-actions>', () => {
82
82
  expect(el.disableActionGroup).to.be.true;
83
83
  });
84
84
 
85
- it('handles `BookReader:userAction` event when loan is active', async () => {
85
+ it('handles `BookReader:userAction` event', async () => {
86
86
  const el = await fixture(
87
87
  container({
88
88
  userid: '@user1',
@@ -91,28 +91,6 @@ describe('<ia-book-actions>', () => {
91
91
  );
92
92
 
93
93
  const spy = Sinon.spy(el, 'autoLoanRenewChecker');
94
- el.lendingStatus = { ...el.lendingStatus, browsingExpired: false };
95
- await el.updateComplete;
96
- // Set borrowType after the update so setupLendingToolbarActions() doesn't overwrite it
97
- el.borrowType = 'browsed';
98
-
99
- window.dispatchEvent(new Event('BookReader:userAction'));
100
- await el.updateComplete;
101
-
102
- expect(spy.calledOnce).to.be.true;
103
- expect(spy.calledWith(true)).to.be.true;
104
- });
105
-
106
- it('handles `BookReader:userAction` event when loan has expired', async () => {
107
- const el = await fixture(
108
- container({
109
- userid: '@user1',
110
- identifier: 'foobar',
111
- lendingStatus: { user_has_browsed: true, browsingExpired: true },
112
- })
113
- );
114
-
115
- const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
116
94
  el.borrowType = 'browsed';
117
95
  await el.updateComplete;
118
96
 
@@ -120,6 +98,7 @@ describe('<ia-book-actions>', () => {
120
98
  await el.updateComplete;
121
99
 
122
100
  expect(spy.calledOnce).to.be.true;
101
+ expect(spy.calledWith(true)).to.be.true;
123
102
  });
124
103
  });
125
104
 
@@ -488,7 +467,7 @@ describe('Visibility change API for document', async () => {
488
467
  });
489
468
  });
490
469
 
491
- it('when book is not expired and loan time is valid', async () => {
470
+ it('when book is not expired', async () => {
492
471
  const el = await fixture(
493
472
  container({
494
473
  userid: '@user1',
@@ -501,220 +480,31 @@ describe('Visibility change API for document', async () => {
501
480
  );
502
481
  await el.updateComplete;
503
482
 
504
- // Set a valid loan time well into the future
505
- await el.localCache.set({
506
- key: 'foobar-loanTime',
507
- value: new Date(new Date().getTime() + 3600 * 1000),
508
- ttl: 3600,
509
- });
510
-
511
483
  const spy = Sinon.spy(el, 'loanStatusCheckInterval');
512
484
  document.dispatchEvent(new Event('visibilitychange'));
513
- await aTimeout(100);
514
-
515
- expect(spy.calledOnce).to.be.true;
516
- });
517
-
518
- it('when loan expired while tab was hidden (browsingExpired false, loanTime past)', async () => {
519
- const el = await fixture(
520
- container({
521
- userid: '@user1',
522
- identifier: 'foobar',
523
- lendingStatus: {
524
- user_has_browsed: true,
525
- browsingExpired: false,
526
- },
527
- })
528
- );
529
- await el.updateComplete;
530
-
531
- // Set a loan time already in the past
532
- await el.localCache.set({
533
- key: 'foobar-loanTime',
534
- value: new Date(new Date().getTime() - 10 * 1000),
535
- ttl: 3600,
536
- });
537
-
538
- const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
539
- document.dispatchEvent(new Event('visibilitychange'));
540
- await aTimeout(100);
541
485
 
542
486
  expect(spy.calledOnce).to.be.true;
543
487
  });
544
488
 
545
- it('when loan already marked expired (browsingExpired true)', async () => {
489
+ it('when book is already expired', async () => {
546
490
  const el = await fixture(
547
491
  container({
548
492
  userid: '@user1',
549
493
  identifier: 'foobar',
550
494
  lendingStatus: {
551
495
  user_has_browsed: true,
552
- browsingExpired: true,
553
496
  },
554
497
  })
555
498
  );
556
499
  await el.updateComplete;
557
500
 
558
- el.borrowType = 'browsed';
559
-
560
- const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
501
+ const spy = Sinon.spy(el, 'browseHasExpired');
561
502
  document.dispatchEvent(new Event('visibilitychange'));
562
- await aTimeout(100);
563
503
 
564
504
  expect(spy.calledOnce).to.be.true;
565
505
  });
566
506
  });
567
507
 
568
- describe('autoRenewExpiredLoan', () => {
569
- it('optimistically resets browsingExpired so the button stays red', async () => {
570
- const el = await fixture(
571
- container({
572
- userid: '@user1',
573
- identifier: 'foobar',
574
- lendingStatus: {
575
- user_has_browsed: true,
576
- available_to_browse: true,
577
- browsingExpired: true,
578
- },
579
- })
580
- );
581
- await el.updateComplete;
582
-
583
- // Before: browsingExpired true → borrow1HrAction → primary color is 'primary' (blue)
584
- expect(el.primaryColor).to.equal('primary');
585
-
586
- el.autoRenewExpiredLoan();
587
- await el.updateComplete;
588
-
589
- // After: browsingExpired flipped to false → patronIsReadingAction → 'danger' (red)
590
- expect(el.lendingStatus.browsingExpired).to.be.false;
591
- expect(el.primaryColor).to.equal('danger');
592
- });
593
-
594
- it('sets loanRenewInProgress and triggers renewNow after timeout', async () => {
595
- const el = await fixture(
596
- container({
597
- userid: '@user1',
598
- identifier: 'foobar',
599
- lendingStatus: {
600
- user_has_browsed: true,
601
- browsingExpired: true,
602
- },
603
- })
604
- );
605
- await el.updateComplete;
606
-
607
- el.autoRenewExpiredLoan();
608
-
609
- expect(el.loanRenewInProgress).to.be.true;
610
-
611
- // renewNow is set in a setTimeout — flush the macrotask queue
612
- await aTimeout(50);
613
- expect(el.loanRenewResult.renewNow).to.be.true;
614
- expect(el.loanRenewResult.renewType).to.equal('auto');
615
- });
616
-
617
- it('ignores subsequent calls while a renewal is already in progress', async () => {
618
- const el = await fixture(
619
- container({
620
- userid: '@user1',
621
- identifier: 'foobar',
622
- lendingStatus: {
623
- user_has_browsed: true,
624
- browsingExpired: true,
625
- },
626
- })
627
- );
628
- await el.updateComplete;
629
-
630
- // Spy *after* fixture so the handler is the same instance
631
- const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
632
-
633
- el.autoRenewExpiredLoan(); // first call — proceeds
634
- el.autoRenewExpiredLoan(); // second call — should be a no-op
635
-
636
- // The spy counts both invocations but the internal guard stops the second
637
- expect(spy.callCount).to.equal(2);
638
- // loanRenewInProgress is still true (not double-set or cleared)
639
- expect(el.loanRenewInProgress).to.be.true;
640
-
641
- // Only one setTimeout fires (the second call returned early)
642
- await aTimeout(50);
643
- expect(el.loanRenewResult.renewNow).to.be.true;
644
- });
645
-
646
- it('clears loanRenewInProgress after a successful loanAutoRenewed event', async () => {
647
- const el = await fixture(
648
- container({
649
- userid: '@user1',
650
- identifier: 'foobar',
651
- lendingStatus: {
652
- user_has_browsed: true,
653
- browsingExpired: false,
654
- secondsLeftOnLoan: 100,
655
- },
656
- })
657
- );
658
- await el.updateComplete;
659
-
660
- await el.localCache.set({
661
- key: 'foobar-loanTime',
662
- value: new Date(new Date().getTime() + 3600 * 1000),
663
- ttl: 3600,
664
- });
665
-
666
- el.autoRenewExpiredLoan();
667
- expect(el.loanRenewInProgress).to.be.true;
668
-
669
- // Wait for the deferred renewNow to be set
670
- await aTimeout(50);
671
-
672
- // Simulate the renew_loan success response
673
- const collapsibleActionGroupEl = el.shadowRoot.querySelector(
674
- 'collapsible-action-group'
675
- );
676
- collapsibleActionGroupEl.dispatchEvent(
677
- new CustomEvent('loanAutoRenewed', {
678
- detail: { data: { loan: { identifier: 'foobar' } } },
679
- })
680
- );
681
-
682
- await aTimeout(100);
683
- await el.updateComplete;
684
-
685
- expect(el.loanRenewInProgress).to.be.false;
686
- });
687
-
688
- it('clears loanRenewInProgress and shows unavailable modal on renew_loan failure', async () => {
689
- const el = await fixture(
690
- container({
691
- userid: '@user1',
692
- identifier: 'foobar',
693
- lendingStatus: {
694
- user_has_browsed: true,
695
- browsingExpired: true,
696
- },
697
- })
698
- );
699
- await el.updateComplete;
700
-
701
- const showModalSpy = Sinon.spy(el, 'showLoanUnavailableModal');
702
-
703
- el.autoRenewExpiredLoan();
704
- expect(el.loanRenewInProgress).to.be.true;
705
-
706
- el.handleLendingActionError({
707
- detail: {
708
- action: 'renew_loan',
709
- data: { error: 'book is not available' },
710
- },
711
- });
712
-
713
- expect(el.loanRenewInProgress).to.be.false;
714
- expect(showModalSpy.calledOnce).to.be.true;
715
- });
716
- });
717
-
718
508
  describe('Shared Resize Observer', () => {
719
509
  it('can receive a Shared Resize Observer', async () => {
720
510
  const sharedObserverStub = new SharedResizeObserver();