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

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
  });
@@ -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', async () => {
85
+ it('handles `BookReader:userAction` event when loan is active', async () => {
86
86
  const el = await fixture(
87
87
  container({
88
88
  userid: '@user1',
@@ -91,8 +91,10 @@ describe('<ia-book-actions>', () => {
91
91
  );
92
92
 
93
93
  const spy = Sinon.spy(el, 'autoLoanRenewChecker');
94
- el.borrowType = 'browsed';
94
+ el.lendingStatus = { ...el.lendingStatus, browsingExpired: false };
95
95
  await el.updateComplete;
96
+ // Set borrowType after the update so setupLendingToolbarActions() doesn't overwrite it
97
+ el.borrowType = 'browsed';
96
98
 
97
99
  window.dispatchEvent(new Event('BookReader:userAction'));
98
100
  await el.updateComplete;
@@ -100,6 +102,25 @@ describe('<ia-book-actions>', () => {
100
102
  expect(spy.calledOnce).to.be.true;
101
103
  expect(spy.calledWith(true)).to.be.true;
102
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
+ el.borrowType = 'browsed';
117
+ await el.updateComplete;
118
+
119
+ window.dispatchEvent(new Event('BookReader:userAction'));
120
+ await el.updateComplete;
121
+
122
+ expect(spy.calledOnce).to.be.true;
123
+ });
103
124
  });
104
125
 
105
126
  describe('Primary Actions data', () => {
@@ -328,7 +349,9 @@ describe('Browsing expired status', () => {
328
349
  await aTimeout(1500); // wait for 1.5 second
329
350
  await el.updateComplete;
330
351
 
331
- expect(el.primaryActions[0].text).to.equal('Borrow');
352
+ // Auto-return must not visibly change the action bar — it should stay
353
+ // exactly as it looked while reading (see WEBDEV-8322).
354
+ expect(el.primaryActions[0].text).to.equal('Return now');
332
355
 
333
356
  expect(el.timerCountdownEl).to.exist;
334
357
 
@@ -371,7 +394,9 @@ describe('Browsing expired status', () => {
371
394
  await aTimeout(1500); // wait for 1.5 sec
372
395
 
373
396
  expect(eventReceived).to.equal(true);
374
- expect(el.primaryActions[0].text).to.equal('Borrow');
397
+ // Auto-return must not visibly change the action bar — it should stay
398
+ // exactly as it looked while reading (see WEBDEV-8322).
399
+ expect(el.primaryActions[0].text).to.equal('Return now');
375
400
  expect(el.tokenPoller.loanTokenInterval).to.equal(undefined);
376
401
  });
377
402
  });
@@ -467,7 +492,7 @@ describe('Visibility change API for document', async () => {
467
492
  });
468
493
  });
469
494
 
470
- it('when book is not expired', async () => {
495
+ it('when book is not expired and loan time is valid', async () => {
471
496
  const el = await fixture(
472
497
  container({
473
498
  userid: '@user1',
@@ -480,31 +505,341 @@ describe('Visibility change API for document', async () => {
480
505
  );
481
506
  await el.updateComplete;
482
507
 
508
+ // Set a valid loan time well into the future
509
+ await el.localCache.set({
510
+ key: 'foobar-loanTime',
511
+ value: new Date(new Date().getTime() + 3600 * 1000),
512
+ ttl: 3600,
513
+ });
514
+
483
515
  const spy = Sinon.spy(el, 'loanStatusCheckInterval');
484
516
  document.dispatchEvent(new Event('visibilitychange'));
517
+ await aTimeout(100);
518
+
519
+ expect(spy.calledOnce).to.be.true;
520
+ });
521
+
522
+ it('when loan expired while tab was hidden (browsingExpired false, loanTime past)', async () => {
523
+ const el = await fixture(
524
+ container({
525
+ userid: '@user1',
526
+ identifier: 'foobar',
527
+ lendingStatus: {
528
+ user_has_browsed: true,
529
+ browsingExpired: false,
530
+ },
531
+ })
532
+ );
533
+ await el.updateComplete;
534
+
535
+ // Set a loan time already in the past
536
+ await el.localCache.set({
537
+ key: 'foobar-loanTime',
538
+ value: new Date(new Date().getTime() - 10 * 1000),
539
+ ttl: 3600,
540
+ });
541
+
542
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
543
+ document.dispatchEvent(new Event('visibilitychange'));
544
+ await aTimeout(100);
485
545
 
486
546
  expect(spy.calledOnce).to.be.true;
487
547
  });
488
548
 
489
- it('when book is already expired', async () => {
549
+ it('when loan already marked expired (browsingExpired true)', async () => {
490
550
  const el = await fixture(
491
551
  container({
492
552
  userid: '@user1',
493
553
  identifier: 'foobar',
494
554
  lendingStatus: {
495
555
  user_has_browsed: true,
556
+ browsingExpired: true,
496
557
  },
497
558
  })
498
559
  );
499
560
  await el.updateComplete;
500
561
 
501
- const spy = Sinon.spy(el, 'browseHasExpired');
562
+ el.borrowType = 'browsed';
563
+
564
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
502
565
  document.dispatchEvent(new Event('visibilitychange'));
566
+ await aTimeout(100);
503
567
 
504
568
  expect(spy.calledOnce).to.be.true;
505
569
  });
506
570
  });
507
571
 
572
+ describe('autoRenewExpiredLoan', () => {
573
+ it('optimistically resets browsingExpired so the button stays red', async () => {
574
+ const el = await fixture(
575
+ container({
576
+ userid: '@user1',
577
+ identifier: 'foobar',
578
+ lendingStatus: {
579
+ user_has_browsed: true,
580
+ available_to_browse: true,
581
+ browsingExpired: true,
582
+ },
583
+ })
584
+ );
585
+ await el.updateComplete;
586
+
587
+ // Before: browsingExpired true → borrow1HrAction → primary color is 'primary' (blue)
588
+ expect(el.primaryColor).to.equal('primary');
589
+
590
+ el.autoRenewExpiredLoan();
591
+ await el.updateComplete;
592
+
593
+ // After: browsingExpired flipped to false → patronIsReadingAction → 'danger' (red)
594
+ expect(el.lendingStatus.browsingExpired).to.be.false;
595
+ expect(el.primaryColor).to.equal('danger');
596
+ });
597
+
598
+ it('sets loanRenewInProgress and triggers renewNow after timeout', async () => {
599
+ const el = await fixture(
600
+ container({
601
+ userid: '@user1',
602
+ identifier: 'foobar',
603
+ lendingStatus: {
604
+ user_has_browsed: true,
605
+ browsingExpired: true,
606
+ },
607
+ })
608
+ );
609
+ await el.updateComplete;
610
+
611
+ el.autoRenewExpiredLoan();
612
+
613
+ expect(el.loanRenewInProgress).to.be.true;
614
+
615
+ // renewNow is set in a setTimeout — flush the macrotask queue
616
+ await aTimeout(50);
617
+ expect(el.loanRenewResult.renewNow).to.be.true;
618
+ expect(el.loanRenewResult.renewType).to.equal('auto');
619
+ });
620
+
621
+ it('ignores subsequent calls while a renewal is already in progress', async () => {
622
+ const el = await fixture(
623
+ container({
624
+ userid: '@user1',
625
+ identifier: 'foobar',
626
+ lendingStatus: {
627
+ user_has_browsed: true,
628
+ browsingExpired: true,
629
+ },
630
+ })
631
+ );
632
+ await el.updateComplete;
633
+
634
+ // Spy *after* fixture so the handler is the same instance
635
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
636
+
637
+ el.autoRenewExpiredLoan(); // first call — proceeds
638
+ el.autoRenewExpiredLoan(); // second call — should be a no-op
639
+
640
+ // The spy counts both invocations but the internal guard stops the second
641
+ expect(spy.callCount).to.equal(2);
642
+ // loanRenewInProgress is still true (not double-set or cleared)
643
+ expect(el.loanRenewInProgress).to.be.true;
644
+
645
+ // Only one setTimeout fires (the second call returned early)
646
+ await aTimeout(50);
647
+ expect(el.loanRenewResult.renewNow).to.be.true;
648
+ });
649
+
650
+ it('clears loanRenewInProgress after a successful loanAutoRenewed event', async () => {
651
+ const el = await fixture(
652
+ container({
653
+ userid: '@user1',
654
+ identifier: 'foobar',
655
+ lendingStatus: {
656
+ user_has_browsed: true,
657
+ browsingExpired: false,
658
+ secondsLeftOnLoan: 100,
659
+ },
660
+ })
661
+ );
662
+ await el.updateComplete;
663
+
664
+ await el.localCache.set({
665
+ key: 'foobar-loanTime',
666
+ value: new Date(new Date().getTime() + 3600 * 1000),
667
+ ttl: 3600,
668
+ });
669
+
670
+ el.autoRenewExpiredLoan();
671
+ expect(el.loanRenewInProgress).to.be.true;
672
+
673
+ // Wait for the deferred renewNow to be set
674
+ await aTimeout(50);
675
+
676
+ // Simulate the renew_loan success response
677
+ const collapsibleActionGroupEl = el.shadowRoot.querySelector(
678
+ 'collapsible-action-group'
679
+ );
680
+ collapsibleActionGroupEl.dispatchEvent(
681
+ new CustomEvent('loanAutoRenewed', {
682
+ detail: { data: { loan: { identifier: 'foobar' } } },
683
+ })
684
+ );
685
+
686
+ await aTimeout(100);
687
+ await el.updateComplete;
688
+
689
+ expect(el.loanRenewInProgress).to.be.false;
690
+ });
691
+
692
+ it('clears loanRenewInProgress and shows unavailable modal on renew_loan failure', async () => {
693
+ const el = await fixture(
694
+ container({
695
+ userid: '@user1',
696
+ identifier: 'foobar',
697
+ lendingStatus: {
698
+ user_has_browsed: true,
699
+ browsingExpired: true,
700
+ },
701
+ })
702
+ );
703
+ await el.updateComplete;
704
+
705
+ const showModalSpy = Sinon.spy(el, 'showLoanUnavailableModal');
706
+
707
+ el.autoRenewExpiredLoan();
708
+ expect(el.loanRenewInProgress).to.be.true;
709
+
710
+ el.handleLendingActionError({
711
+ detail: {
712
+ action: 'renew_loan',
713
+ data: { error: 'book is not available' },
714
+ },
715
+ });
716
+
717
+ expect(el.loanRenewInProgress).to.be.false;
718
+ expect(showModalSpy.calledOnce).to.be.true;
719
+ });
720
+ });
721
+
722
+ describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
723
+ it('does not let autoLoanRenewChecker clobber an in-flight expired-loan renewal', async () => {
724
+ // Get borrowType derived as 'browsed' first, same as the
725
+ // "Expiring book cancels interval" test above — hasExpired renders
726
+ // leave borrowType untouched, so it must already be 'browsed' before
727
+ // the loan is marked expired.
728
+ const baseStatus = {
729
+ is_lendable: true,
730
+ user_has_browsed: true,
731
+ browsingExpired: false,
732
+ };
733
+ const el = await fixture(
734
+ container({
735
+ userid: '@user1',
736
+ identifier: 'foobar',
737
+ lendingStatus: baseStatus,
738
+ })
739
+ );
740
+ await el.updateComplete;
741
+ expect(el.borrowType).to.equal('browsed');
742
+
743
+ el.lendingStatus = { ...baseStatus, browsingExpired: true };
744
+ await el.updateComplete;
745
+
746
+ const autoLoanRenewCheckerSpy = Sinon.spy(el, 'autoLoanRenewChecker');
747
+
748
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
749
+
750
+ // autoRenewExpiredLoan()'s setTimeout(0) sets renewNow=true. If the old
751
+ // bug were present, the still-live lendingStatus.browsingExpired check
752
+ // would (wrongly) also let autoLoanRenewChecker(true) run — which reads
753
+ // the already-deleted loanTime cache key and overwrites renewNow back
754
+ // to false. Give both paths plenty of time to resolve.
755
+ await aTimeout(300);
756
+
757
+ expect(autoLoanRenewCheckerSpy.called).to.be.false;
758
+ expect(el.loanRenewResult.renewNow).to.be.true;
759
+ });
760
+ });
761
+
762
+ describe('handleLendingActionError - loanRenewInProgress reset', () => {
763
+ it('clears loanRenewInProgress on any renew_loan failure, not just "not available"', async () => {
764
+ const el = await fixture(
765
+ container({
766
+ userid: '@user1',
767
+ identifier: 'foobar',
768
+ lendingStatus: { user_has_browsed: true, browsingExpired: true },
769
+ })
770
+ );
771
+ await el.updateComplete;
772
+
773
+ el.loanRenewInProgress = true;
774
+
775
+ el.handleLendingActionError({
776
+ detail: {
777
+ action: 'renew_loan',
778
+ data: { error: 'some other unrelated failure' },
779
+ },
780
+ });
781
+
782
+ expect(el.loanRenewInProgress).to.be.false;
783
+ });
784
+ });
785
+
786
+ describe('showWarningModal', () => {
787
+ it('shows a headline, one Okay button, and an info-icon help link; Okay dismisses without renewing', async () => {
788
+ const el = await fixture(
789
+ container({
790
+ userid: '@user1',
791
+ identifier: 'foobar',
792
+ lendingStatus: { user_has_browsed: true, secondsLeftOnLoan: 100 },
793
+ })
794
+ );
795
+ await el.updateComplete;
796
+
797
+ await el.showWarningModal();
798
+
799
+ const modalManagerEl = document.body.querySelector('modal-manager');
800
+ const modalTemplateEl =
801
+ modalManagerEl.shadowRoot.querySelector('modal-template');
802
+ const headline =
803
+ modalTemplateEl.shadowRoot.querySelector('.headline')?.textContent;
804
+ expect(headline).to.contain('Are you still there?');
805
+
806
+ const helpLink = modalTemplateEl.shadowRoot.querySelector(
807
+ 'a[href="https://help.archive.org/help/borrowing-from-the-lending-library"]'
808
+ );
809
+ expect(helpLink).to.exist;
810
+
811
+ const buttons = modalManagerEl.shadowRoot.querySelectorAll(
812
+ '#book-action-bar-custom-buttons button'
813
+ );
814
+ expect(buttons.length).to.equal(1);
815
+ expect(buttons[0].textContent).to.contain('Okay');
816
+
817
+ buttons[0].click();
818
+
819
+ expect(el.warningModalOpen).to.be.false;
820
+ expect(el.warningModalDismissed).to.be.true;
821
+ expect(el.loanRenewResult.renewNow).to.be.false;
822
+ });
823
+
824
+ it('does not reopen while already open', async () => {
825
+ const el = await fixture(
826
+ container({
827
+ userid: '@user1',
828
+ identifier: 'foobar',
829
+ lendingStatus: { user_has_browsed: true, secondsLeftOnLoan: 100 },
830
+ })
831
+ );
832
+ await el.updateComplete;
833
+
834
+ await el.showWarningModal();
835
+ const showModalSpy = Sinon.spy(el.modal, 'showModal');
836
+
837
+ await el.showWarningModal();
838
+
839
+ expect(showModalSpy.called).to.be.false;
840
+ });
841
+ });
842
+
508
843
  describe('Shared Resize Observer', () => {
509
844
  it('can receive a Shared Resize Observer', async () => {
510
845
  const sharedObserverStub = new SharedResizeObserver();