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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,29 +505,713 @@ 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);
485
518
 
486
519
  expect(spy.calledOnce).to.be.true;
487
520
  });
488
521
 
489
- it('when book is already expired', async () => {
522
+ it('when loan expired while tab was hidden (browsingExpired false, loanTime past)', async () => {
490
523
  const el = await fixture(
491
524
  container({
492
525
  userid: '@user1',
493
526
  identifier: 'foobar',
494
527
  lendingStatus: {
495
528
  user_has_browsed: true,
529
+ browsingExpired: false,
496
530
  },
497
531
  })
498
532
  );
499
533
  await el.updateComplete;
500
534
 
501
- const spy = Sinon.spy(el, 'browseHasExpired');
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');
502
543
  document.dispatchEvent(new Event('visibilitychange'));
544
+ await aTimeout(100);
503
545
 
504
546
  expect(spy.calledOnce).to.be.true;
505
547
  });
548
+
549
+ it('when loan already marked expired (browsingExpired true)', async () => {
550
+ const el = await fixture(
551
+ container({
552
+ userid: '@user1',
553
+ identifier: 'foobar',
554
+ lendingStatus: {
555
+ user_has_browsed: true,
556
+ browsingExpired: true,
557
+ },
558
+ })
559
+ );
560
+ await el.updateComplete;
561
+
562
+ el.borrowType = 'browsed';
563
+
564
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
565
+ document.dispatchEvent(new Event('visibilitychange'));
566
+ await aTimeout(100);
567
+
568
+ expect(spy.calledOnce).to.be.true;
569
+ });
570
+ });
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('does not reset postInitComplete until renewal is confirmed, not on the optimistic flip (WEBDEV-8322 follow-up)', async () => {
693
+ // Regression: resetting postInitComplete (which lets BookReader
694
+ // re-initialize via create_token) as soon as autoRenewExpiredLoan()
695
+ // optimistically flips browsingExpired would let create_token race
696
+ // ahead of renew_loan's response, reading the still-expired loan
697
+ // record and minting a bad access cookie — breaking page images with
698
+ // CORS/auth errors until the next natural token refresh.
699
+ const el = await fixture(
700
+ container({
701
+ userid: '@user1',
702
+ identifier: 'foobar',
703
+ lendingStatus: {
704
+ user_has_browsed: true,
705
+ browsingExpired: true,
706
+ },
707
+ })
708
+ );
709
+ await el.updateComplete;
710
+ el.postInitComplete = true;
711
+ el.lendingBarPostInit = Sinon.spy();
712
+
713
+ await el.localCache.set({
714
+ key: 'foobar-loanTime',
715
+ value: new Date(new Date().getTime() + 3600 * 1000),
716
+ ttl: 3600,
717
+ });
718
+
719
+ el.autoRenewExpiredLoan();
720
+ await el.updateComplete;
721
+
722
+ // Still true right after the optimistic flip — must not reset early.
723
+ expect(el.postInitComplete).to.be.true;
724
+ expect(el.lendingBarPostInit.called).to.be.false;
725
+
726
+ await aTimeout(50);
727
+
728
+ const collapsibleActionGroupEl = el.shadowRoot.querySelector(
729
+ 'collapsible-action-group'
730
+ );
731
+ collapsibleActionGroupEl.dispatchEvent(
732
+ new CustomEvent('loanAutoRenewed', {
733
+ detail: { data: { loan: { identifier: 'foobar' } } },
734
+ })
735
+ );
736
+
737
+ await aTimeout(200);
738
+ await el.updateComplete;
739
+
740
+ // The confirmed renewal must have forced a fresh create_token cycle
741
+ // that re-ran lendingBarPostInit() (BookReader re-init) — postInitComplete
742
+ // itself ends up true again once that cycle completes, so we assert on
743
+ // the re-init actually having fired instead of the transient flag.
744
+ expect(el.lendingBarPostInit.calledOnce).to.be.true;
745
+ expect(el.recoveringFromLoanExpiry).to.be.false;
746
+ });
747
+
748
+ it('does not start the token poller (create_token) on the optimistic flip, only after renewal is confirmed', async () => {
749
+ // Regression: even without re-running lendingBarPostInit early, the
750
+ // token poller restart in setupLendingToolbarActions() was ungated —
751
+ // it fired on the optimistic lendingStatus flip too, calling
752
+ // create_token against the still-expired loan. When that failed,
753
+ // handleLendingActionError cleared ALL lending intervals (including
754
+ // the countdown timer) and flipped user_has_browsed false, sometimes
755
+ // permanently killing the countdown if it lost the race against the
756
+ // real renewal confirming afterward.
757
+ const el = await fixture(
758
+ container({
759
+ userid: '@user1',
760
+ identifier: 'foobar',
761
+ lendingStatus: {
762
+ user_has_browsed: true,
763
+ browsingExpired: true,
764
+ },
765
+ })
766
+ );
767
+ await el.updateComplete;
768
+
769
+ await el.localCache.set({
770
+ key: 'foobar-loanTime',
771
+ value: new Date(new Date().getTime() + 3600 * 1000),
772
+ ttl: 3600,
773
+ });
774
+
775
+ const startTokenPollerSpy = Sinon.spy(el, 'startLoanTokenPoller');
776
+
777
+ el.autoRenewExpiredLoan();
778
+ await el.updateComplete;
779
+ await aTimeout(150); // let the 100ms token-poller-restart check run
780
+
781
+ expect(startTokenPollerSpy.called).to.be.false;
782
+
783
+ await aTimeout(50);
784
+ const collapsibleActionGroupEl = el.shadowRoot.querySelector(
785
+ 'collapsible-action-group'
786
+ );
787
+ collapsibleActionGroupEl.dispatchEvent(
788
+ new CustomEvent('loanAutoRenewed', {
789
+ detail: { data: { loan: { identifier: 'foobar' } } },
790
+ })
791
+ );
792
+
793
+ await aTimeout(200);
794
+ await el.updateComplete;
795
+
796
+ expect(startTokenPollerSpy.calledOnce).to.be.true;
797
+ });
798
+
799
+ it('does not touch postInitComplete on a routine (non-expiry) renewal', async () => {
800
+ // A background top-up renewal (loan never actually lapsed) never
801
+ // interrupted BookReader, so it must not force a re-init.
802
+ const el = await fixture(
803
+ container({
804
+ userid: '@user1',
805
+ identifier: 'foobar',
806
+ lendingStatus: { user_has_browsed: true, browsingExpired: false },
807
+ })
808
+ );
809
+ await el.updateComplete;
810
+ el.postInitComplete = true;
811
+ el.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
812
+ el.lendingBarPostInit = Sinon.spy();
813
+
814
+ await el.localCache.set({
815
+ key: 'foobar-loanTime',
816
+ value: new Date(new Date().getTime() + 3600 * 1000),
817
+ ttl: 3600,
818
+ });
819
+
820
+ const collapsibleActionGroupEl = el.shadowRoot.querySelector(
821
+ 'collapsible-action-group'
822
+ );
823
+ collapsibleActionGroupEl.dispatchEvent(
824
+ new CustomEvent('loanAutoRenewed', {
825
+ detail: { data: { loan: { identifier: 'foobar' } } },
826
+ })
827
+ );
828
+
829
+ await aTimeout(100);
830
+ await el.updateComplete;
831
+
832
+ expect(el.lendingBarPostInit.called).to.be.false;
833
+ });
834
+
835
+ it('clears loanRenewInProgress and shows unavailable modal on renew_loan failure', async () => {
836
+ const el = await fixture(
837
+ container({
838
+ userid: '@user1',
839
+ identifier: 'foobar',
840
+ lendingStatus: {
841
+ user_has_browsed: true,
842
+ browsingExpired: true,
843
+ },
844
+ })
845
+ );
846
+ await el.updateComplete;
847
+
848
+ const showModalSpy = Sinon.spy(el, 'showLoanUnavailableModal');
849
+
850
+ el.autoRenewExpiredLoan();
851
+ expect(el.loanRenewInProgress).to.be.true;
852
+
853
+ el.handleLendingActionError({
854
+ detail: {
855
+ action: 'renew_loan',
856
+ data: { error: 'book is not available' },
857
+ },
858
+ });
859
+
860
+ expect(el.loanRenewInProgress).to.be.false;
861
+ expect(showModalSpy.calledOnce).to.be.true;
862
+ });
863
+ });
864
+
865
+ describe('BookReader:userAction race regression (WEBDEV-8322)', () => {
866
+ it('does not let autoLoanRenewChecker clobber an in-flight expired-loan renewal', async () => {
867
+ // Get borrowType derived as 'browsed' first, same as the
868
+ // "Expiring book cancels interval" test above — hasExpired renders
869
+ // leave borrowType untouched, so it must already be 'browsed' before
870
+ // the loan is marked expired.
871
+ const baseStatus = {
872
+ is_lendable: true,
873
+ user_has_browsed: true,
874
+ browsingExpired: false,
875
+ };
876
+ const el = await fixture(
877
+ container({
878
+ userid: '@user1',
879
+ identifier: 'foobar',
880
+ lendingStatus: baseStatus,
881
+ })
882
+ );
883
+ await el.updateComplete;
884
+ expect(el.borrowType).to.equal('browsed');
885
+
886
+ el.lendingStatus = { ...baseStatus, browsingExpired: true };
887
+ await el.updateComplete;
888
+
889
+ const autoLoanRenewCheckerSpy = Sinon.spy(el, 'autoLoanRenewChecker');
890
+
891
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
892
+
893
+ // autoRenewExpiredLoan()'s setTimeout(0) sets renewNow=true. If the old
894
+ // bug were present, the still-live lendingStatus.browsingExpired check
895
+ // would (wrongly) also let autoLoanRenewChecker(true) run — which reads
896
+ // the already-deleted loanTime cache key and overwrites renewNow back
897
+ // to false. Give both paths plenty of time to resolve.
898
+ await aTimeout(300);
899
+
900
+ expect(autoLoanRenewCheckerSpy.called).to.be.false;
901
+ expect(el.loanRenewResult.renewNow).to.be.true;
902
+ });
903
+
904
+ it('ignores repeated BookReader:userAction events firing in quick succession (e.g. from a scroll)', async () => {
905
+ // Regression: a single scroll gesture can fire BookReader:userAction
906
+ // several times in rapid succession. autoLoanRenewChecker() had no
907
+ // guard against concurrent/repeated invocation, unlike
908
+ // autoRenewExpiredLoan() — each event spun up its own LoanRenewHelper,
909
+ // racing to overwrite this.loanRenewResult and potentially
910
+ // re-triggering renew_loan/create_token while a prior call from an
911
+ // earlier event was still in flight or had just landed.
912
+ const baseStatus = {
913
+ is_lendable: true,
914
+ user_has_browsed: true,
915
+ browsingExpired: false,
916
+ };
917
+ const el = await fixture(
918
+ container({
919
+ userid: '@user1',
920
+ identifier: 'foobar',
921
+ lendingStatus: baseStatus,
922
+ })
923
+ );
924
+ await el.updateComplete;
925
+ expect(el.borrowType).to.equal('browsed');
926
+
927
+ el.loanRenewInProgress = true;
928
+
929
+ const loanRenewHelperSpy = Sinon.spy(el, 'autoLoanRenewChecker');
930
+
931
+ // Simulate a scroll firing the event several times back to back.
932
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
933
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
934
+ window.dispatchEvent(new CustomEvent('BookReader:userAction'));
935
+ await aTimeout(50);
936
+
937
+ // The method is still called each time (the guard is inside it), but
938
+ // must no-op instead of spinning up a new LoanRenewHelper each time.
939
+ expect(loanRenewHelperSpy.callCount).to.equal(3);
940
+ expect(el.loanRenewHelper).to.be.undefined;
941
+ });
942
+ });
943
+
944
+ describe('handleLendingActionError - loanRenewInProgress reset', () => {
945
+ it('clears loanRenewInProgress on any renew_loan failure, not just "not available"', async () => {
946
+ const el = await fixture(
947
+ container({
948
+ userid: '@user1',
949
+ identifier: 'foobar',
950
+ lendingStatus: { user_has_browsed: true, browsingExpired: true },
951
+ })
952
+ );
953
+ await el.updateComplete;
954
+
955
+ el.loanRenewInProgress = true;
956
+
957
+ el.handleLendingActionError({
958
+ detail: {
959
+ action: 'renew_loan',
960
+ data: { error: 'some other unrelated failure' },
961
+ },
962
+ });
963
+
964
+ expect(el.loanRenewInProgress).to.be.false;
965
+ });
966
+ });
967
+
968
+ describe('handleLendingActionError - create_token failures must not stop the countdown (WEBDEV-8322 follow-up)', () => {
969
+ it('does not clear the reading countdown on a create_token failure', async () => {
970
+ const el = await fixture(
971
+ container({
972
+ userid: '@user1',
973
+ identifier: 'foobar',
974
+ lendingStatus: {
975
+ user_has_browsed: true,
976
+ browsingExpired: false,
977
+ secondsLeftOnLoan: 100,
978
+ },
979
+ })
980
+ );
981
+ await el.updateComplete;
982
+ expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
983
+
984
+ el.handleLendingActionError({
985
+ detail: {
986
+ action: 'create_token',
987
+ data: { error: 'loan token not found. please try again later.' },
988
+ },
989
+ });
990
+
991
+ // The countdown must keep running — a token refresh hiccup says
992
+ // nothing about how much time is left on the loan itself.
993
+ expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
994
+ });
995
+
996
+ it('shows the error modal for a create_token failure that reaches here (retries already exhausted)', async () => {
997
+ // By the time handleLendingActionError runs for a create_token
998
+ // failure, LoanTokenPoller has already retried and given up (see
999
+ // loan-token-poller.js's handleTokenError) — this is a genuine
1000
+ // failure, so the patron must be told instead of it happening
1001
+ // silently.
1002
+ const el = await fixture(
1003
+ container({
1004
+ userid: '@user1',
1005
+ identifier: 'foobar',
1006
+ lendingStatus: {
1007
+ user_has_browsed: true,
1008
+ browsingExpired: false,
1009
+ secondsLeftOnLoan: 100,
1010
+ },
1011
+ })
1012
+ );
1013
+ await el.updateComplete;
1014
+
1015
+ const showErrorModalSpy = Sinon.spy(el, 'showErrorModal');
1016
+ const errorMsg = 'loan token not found. please try again later.';
1017
+
1018
+ el.handleLendingActionError({
1019
+ detail: {
1020
+ action: 'create_token',
1021
+ data: { error: errorMsg },
1022
+ },
1023
+ });
1024
+
1025
+ expect(showErrorModalSpy.calledOnceWith(errorMsg, 'create_token')).to.be
1026
+ .true;
1027
+ expect(el.lendingStatus.user_has_browsed).to.be.false;
1028
+ });
1029
+
1030
+ it('still clears everything on a renew_loan failure', async () => {
1031
+ const el = await fixture(
1032
+ container({
1033
+ userid: '@user1',
1034
+ identifier: 'foobar',
1035
+ lendingStatus: {
1036
+ user_has_browsed: true,
1037
+ browsingExpired: false,
1038
+ secondsLeftOnLoan: 100,
1039
+ },
1040
+ })
1041
+ );
1042
+ await el.updateComplete;
1043
+ expect(window.IALendingIntervals.timerCountdown).to.not.equal(0);
1044
+
1045
+ el.handleLendingActionError({
1046
+ detail: {
1047
+ action: 'renew_loan',
1048
+ data: { error: 'some other unrelated failure' },
1049
+ },
1050
+ });
1051
+
1052
+ expect(window.IALendingIntervals.timerCountdown).to.equal(0);
1053
+ });
1054
+
1055
+ it('shows the same unavailable-modal-and-refresh path for any renew_loan failure, regardless of message text', async () => {
1056
+ // Regression: branching on errorMsg.match(/not available/) to decide
1057
+ // how to recover was fragile — the server can return any message
1058
+ // (e.g. a lending limit hit), and guessing which lendingStatus fields
1059
+ // are now accurate client-side is error-prone. Every renew_loan
1060
+ // failure now shows the real error and refreshes the page on
1061
+ // dismissal (showLoanUnavailableModal's Okay button), rather than
1062
+ // trying to patch up state without reloading from the server.
1063
+ const el = await fixture(
1064
+ container({
1065
+ userid: '@user1',
1066
+ identifier: 'foobar',
1067
+ lendingStatus: {
1068
+ is_lendable: true,
1069
+ user_has_browsed: true,
1070
+ browsingExpired: false,
1071
+ available_to_browse: false,
1072
+ secondsLeftOnLoan: 100,
1073
+ },
1074
+ })
1075
+ );
1076
+ await el.updateComplete;
1077
+
1078
+ const showUnavailableSpy = Sinon.spy(el, 'showLoanUnavailableModal');
1079
+ const lendingLimitMsg =
1080
+ 'Your account has hit a lending limit. Please try again later or contact info@archive.org.';
1081
+
1082
+ el.handleLendingActionError({
1083
+ detail: {
1084
+ action: 'renew_loan',
1085
+ data: { error: lendingLimitMsg },
1086
+ },
1087
+ });
1088
+ await el.updateComplete;
1089
+
1090
+ expect(showUnavailableSpy.calledOnceWith(lendingLimitMsg)).to.be.true;
1091
+ });
1092
+
1093
+ it('updates lendingStatus immediately on a renew_loan failure — Borrow state, timer cleared', async () => {
1094
+ // The modal's Okay button refreshes the page, but that shouldn't be
1095
+ // the only thing correcting the UI — while the modal is still open,
1096
+ // the action bar must already show Borrow instead of "Return now"
1097
+ // with a stale countdown value from before the failed renewal.
1098
+ const el = await fixture(
1099
+ container({
1100
+ userid: '@user1',
1101
+ identifier: 'foobar',
1102
+ lendingStatus: {
1103
+ is_lendable: true,
1104
+ user_has_browsed: true,
1105
+ browsingExpired: false,
1106
+ available_to_browse: false,
1107
+ secondsLeftOnLoan: 100,
1108
+ },
1109
+ })
1110
+ );
1111
+ await el.updateComplete;
1112
+
1113
+ el.handleLendingActionError({
1114
+ detail: {
1115
+ action: 'renew_loan',
1116
+ data: {
1117
+ error:
1118
+ 'Your account has hit a lending limit. Please try again later or contact info@archive.org.',
1119
+ },
1120
+ },
1121
+ });
1122
+ await el.updateComplete;
1123
+
1124
+ expect(el.lendingStatus.user_has_browsed).to.be.false;
1125
+ expect(el.lendingStatus.available_to_browse).to.be.true;
1126
+ expect(el.lendingStatus.secondsLeftOnLoan).to.equal(0);
1127
+ expect(el.primaryActions[0].text).to.not.equal('Return now');
1128
+ });
1129
+
1130
+ it('also shows the unavailable modal for the "not available" message, with its own text', async () => {
1131
+ const el = await fixture(
1132
+ container({
1133
+ userid: '@user1',
1134
+ identifier: 'foobar',
1135
+ lendingStatus: {
1136
+ is_lendable: true,
1137
+ user_has_browsed: true,
1138
+ browsingExpired: false,
1139
+ secondsLeftOnLoan: 100,
1140
+ },
1141
+ })
1142
+ );
1143
+ await el.updateComplete;
1144
+
1145
+ const showUnavailableSpy = Sinon.spy(el, 'showLoanUnavailableModal');
1146
+ const notAvailableMsg = 'This book is not available to borrow at this time.';
1147
+
1148
+ el.handleLendingActionError({
1149
+ detail: {
1150
+ action: 'renew_loan',
1151
+ data: { error: notAvailableMsg },
1152
+ },
1153
+ });
1154
+ await el.updateComplete;
1155
+
1156
+ expect(showUnavailableSpy.calledOnceWith(notAvailableMsg)).to.be.true;
1157
+ });
1158
+ });
1159
+
1160
+ describe('showWarningModal', () => {
1161
+ it('shows a headline, one Okay button, and an info-icon help link; Okay dismisses without renewing', async () => {
1162
+ const el = await fixture(
1163
+ container({
1164
+ userid: '@user1',
1165
+ identifier: 'foobar',
1166
+ lendingStatus: { user_has_browsed: true, secondsLeftOnLoan: 100 },
1167
+ })
1168
+ );
1169
+ await el.updateComplete;
1170
+
1171
+ await el.showWarningModal();
1172
+
1173
+ const modalManagerEl = document.body.querySelector('modal-manager');
1174
+ const modalTemplateEl =
1175
+ modalManagerEl.shadowRoot.querySelector('modal-template');
1176
+ const headline =
1177
+ modalTemplateEl.shadowRoot.querySelector('.headline')?.textContent;
1178
+ expect(headline).to.contain('Are you still there?');
1179
+
1180
+ const helpLink = modalTemplateEl.shadowRoot.querySelector(
1181
+ 'a[href="https://help.archive.org/help/borrowing-from-the-lending-library"]'
1182
+ );
1183
+ expect(helpLink).to.exist;
1184
+
1185
+ const buttons = modalManagerEl.shadowRoot.querySelectorAll(
1186
+ '#book-action-bar-custom-buttons button'
1187
+ );
1188
+ expect(buttons.length).to.equal(1);
1189
+ expect(buttons[0].textContent).to.contain('Okay');
1190
+
1191
+ buttons[0].click();
1192
+
1193
+ expect(el.warningModalOpen).to.be.false;
1194
+ expect(el.warningModalDismissed).to.be.true;
1195
+ expect(el.loanRenewResult.renewNow).to.be.false;
1196
+ });
1197
+
1198
+ it('does not reopen while already open', async () => {
1199
+ const el = await fixture(
1200
+ container({
1201
+ userid: '@user1',
1202
+ identifier: 'foobar',
1203
+ lendingStatus: { user_has_browsed: true, secondsLeftOnLoan: 100 },
1204
+ })
1205
+ );
1206
+ await el.updateComplete;
1207
+
1208
+ await el.showWarningModal();
1209
+ const showModalSpy = Sinon.spy(el.modal, 'showModal');
1210
+
1211
+ await el.showWarningModal();
1212
+
1213
+ expect(showModalSpy.called).to.be.false;
1214
+ });
506
1215
  });
507
1216
 
508
1217
  describe('Shared Resize Observer', () => {