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

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 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-account-settings.git"
6
+ "url": "git+https://github.com/internetarchive/iaux-book-actions.git"
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.2",
10
+ "version": "1.4.3-canary.0",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -31,7 +31,6 @@
31
31
  "@internetarchive/local-cache": "^0.2.1",
32
32
  "@internetarchive/modal-manager": "^2.0.0",
33
33
  "@internetarchive/shared-resize-observer": "^0.2.0",
34
- "install-deps": "^1.1.0",
35
34
  "lit": "^2.8.0 || ^3.3.2"
36
35
  },
37
36
  "devDependencies": {
@@ -150,7 +150,7 @@ export default class LoanAnanlytics {
150
150
  }
151
151
 
152
152
  /**
153
- * reponsible to sent events to GA
153
+ * responsible 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
- handleLoanRenew() {
30
+ async handleLoanRenew() {
31
31
  try {
32
32
  if (this.hasPageChanged) {
33
33
  return this.pageChanged(); // user clicked on page
@@ -98,6 +98,10 @@ 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
+
101
105
  /**
102
106
  * contains one hour auto-loan-renew time configuration
103
107
  * defaults to 1 hour config
@@ -141,7 +145,6 @@ export default class IABookActions extends LitElement {
141
145
  * @param {string} msg
142
146
  */
143
147
  sentryCaptureMsg(msg) {
144
- log(window?.Sentry);
145
148
  window?.Sentry?.captureMessage(msg);
146
149
  }
147
150
 
@@ -233,7 +236,7 @@ export default class IABookActions extends LitElement {
233
236
  'browsingExpired' in this.lendingStatus &&
234
237
  this.lendingStatus?.browsingExpired;
235
238
  if (hasExpired) {
236
- log('setupLendingToolbarActions > hasExpired --- ');
239
+ log('[IABookActions] browsing expired — showing expired state');
237
240
 
238
241
  if (!this.tokenPoller) {
239
242
  this.sentryCaptureMsg(sentryLogs.bookWasExpired);
@@ -290,7 +293,11 @@ export default class IABookActions extends LitElement {
290
293
  * dispatched this event from bookreader page changed
291
294
  */
292
295
  window.addEventListener('BookReader:userAction', () => {
293
- log('IABookActions: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
+
294
301
  if (this.borrowType === 'browsed') {
295
302
  this.autoLoanRenewChecker(true);
296
303
  }
@@ -303,17 +310,17 @@ export default class IABookActions extends LitElement {
303
310
  */
304
311
  document.addEventListener('visibilitychange', async () => {
305
312
  if (!document.hidden) {
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
- ) {
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) {
317
324
  const loanTime = await this.localCache.get(
318
325
  `${this.identifier}-loanTime`
319
326
  );
@@ -324,7 +331,8 @@ export default class IABookActions extends LitElement {
324
331
  if (secondsLeft >= this.timerExecutionSeconds) {
325
332
  this.loanStatusCheckInterval(Number(secondsLeft));
326
333
  } else {
327
- this.browseHasExpired();
334
+ // Loan expired while user was away — try to silently renew
335
+ this.autoRenewExpiredLoan();
328
336
  this.disconnectedCallback();
329
337
  }
330
338
  }
@@ -350,6 +358,36 @@ export default class IABookActions extends LitElement {
350
358
  this.loanRenewResult = this.loanRenewHelper.result;
351
359
  }
352
360
 
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
+
353
391
  /**
354
392
  * Required as sibling on page
355
393
  * @returns HTMLElement
@@ -365,29 +403,34 @@ export default class IABookActions extends LitElement {
365
403
  * Show the waring modal to ask user if they are still reading
366
404
  */
367
405
  async showWarningModal() {
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) {
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) {
377
418
  secondsLeft = this.lendingStatus.secondsLeftOnLoan;
378
419
  } else {
379
420
  secondsLeft = secondsLeft > 60 ? secondsLeft : 60;
380
421
  }
381
422
 
423
+ // clear modal and reset renew state
424
+ this.modal.customModalContent = nothing;
425
+ this.modal?.closeModal();
426
+ this.loanRenewResult = { texts: '', renewNow: false };
427
+
382
428
  const config = new ModalConfig({
383
429
  headline: 'Are you still reading?',
384
430
  headerColor: '#194880',
385
431
  showCloseButton: false,
386
432
  closeOnBackdropClick: false,
387
- message: this.loanRenewHelper?.getMessageTexts(
388
- this.loanRenewResult.texts,
389
- secondsLeft
390
- ),
433
+ message: this.loanRenewHelper?.getMessageTexts(warningTexts, secondsLeft),
391
434
  });
392
435
 
393
436
  const customModalContent = html`<br />
@@ -484,6 +527,7 @@ export default class IABookActions extends LitElement {
484
527
 
485
528
  /**
486
529
  * Show modal when book is auto returned
530
+ * @deprecated
487
531
  */
488
532
  async showExpiredModal() {
489
533
  const config = new ModalConfig({
@@ -513,7 +557,7 @@ export default class IABookActions extends LitElement {
513
557
  * Execute when loan is expired
514
558
  */
515
559
  async browseHasExpired() {
516
- log('BrowseHasExpired ---');
560
+ log('[IABookActions] browseHasExpired');
517
561
  window?.IALendingIntervals?.clearAll();
518
562
 
519
563
  const currStatus = {
@@ -532,31 +576,52 @@ export default class IABookActions extends LitElement {
532
576
  this.loanRenewResult.texts =
533
577
  'This book has been returned due to inactivity.';
534
578
 
535
- await this.showExpiredModal();
579
+ this.modal?.closeModal();
536
580
 
537
581
  this.sentryCaptureMsg(sentryLogs.browseHasExpired);
538
582
  }
539
583
 
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
+
540
611
  async startBrowseTimer() {
541
612
  window?.IALendingIntervals?.clearBrowseExpireTimeout();
542
613
 
543
- const { browsingExpired, user_has_browsed, secondsLeftOnLoan } =
544
- this.lendingStatus;
614
+ const {
615
+ browsingExpired,
616
+ user_has_browsed,
617
+ secondsLeftOnLoan,
618
+ } = this.lendingStatus;
545
619
 
546
620
  if (!user_has_browsed || browsingExpired) {
547
- log('startBrowseTimer --- !user_has_browsed || browsingExpired', {
548
- user_has_browsed,
549
- browsingExpired,
550
- secondsLeftOnLoan,
551
- });
552
621
  return;
553
622
  }
554
623
 
555
624
  window.IALendingIntervals.browseExpireTimeout = setTimeout(() => {
556
- log(
557
- 'startBrowseTimer > browseExpireTimeout --- will expire loan',
558
- secondsLeftOnLoan
559
- );
560
625
  this.browseHasExpired();
561
626
  }, secondsLeftOnLoan * 1000);
562
627
  }
@@ -638,12 +703,9 @@ export default class IABookActions extends LitElement {
638
703
 
639
704
  // number of seconds left in current loan
640
705
  const secondsLeft = Math.round((loanTime - new Date()) / 1000);
641
- log(loanTime, secondsLeft);
642
-
643
- log('IABookActions: handleLoanAutoRenewed --- ', {
706
+ log('[IABookActions] handleLoanAutoRenewed', {
707
+ secondsLeft,
644
708
  ajaxResponse: detail?.data,
645
- loanRenewResult: this.loanRenewResult,
646
- secondsLeftOnLoan: secondsLeft,
647
709
  });
648
710
 
649
711
  const currStatus = {
@@ -660,6 +722,9 @@ export default class IABookActions extends LitElement {
660
722
  this.modal.customModalContent = nothing;
661
723
  this.sentryCaptureMsg(sentryLogs.bookHasRenewed);
662
724
  }
725
+
726
+ this.warningModalOpen = false;
727
+ this.loanRenewInProgress = false;
663
728
  }
664
729
 
665
730
  /**
@@ -695,29 +760,19 @@ export default class IABookActions extends LitElement {
695
760
  // re-sync timer if gone off because of background window
696
761
  // side effect: updates this.lendingStatus & kicks off lifecycle,
697
762
  // if really updated, escape from here
698
- const resyncd = await this.reSyncTimerIfGoneOff(secondsLeft);
763
+ const resyncd = this.reSyncTimerIfGoneOff(secondsLeft);
699
764
 
700
765
  if (resyncd.hasSynced) {
701
766
  secondsLeft = resyncd.whatShouldLeft;
702
- log('startTimerCountdown --- stale, timer has resyncd', {
767
+ log('[IABookActions] startTimerCountdown: timer re-synced', {
703
768
  secondsLeft,
704
769
  });
705
770
  }
706
771
 
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
- );
772
+ log('[IABookActions] startTimerCountdown', {
773
+ secondsLeft,
774
+ loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
775
+ });
721
776
 
722
777
  /**
723
778
  * execute from last 10th minutes to 0th minute
@@ -742,7 +797,7 @@ export default class IABookActions extends LitElement {
742
797
  * @param {number} timerSecondsLeft - actual seconds left get from setInterval
743
798
  * @returns {Object} { hasSynced: [boolean], whatShouldLeft: [number] }
744
799
  */
745
- async reSyncTimerIfGoneOff(timerSecondsLeft) {
800
+ reSyncTimerIfGoneOff(timerSecondsLeft) {
746
801
  const currentTime = new Date();
747
802
 
748
803
  // current time - loan time
@@ -752,27 +807,11 @@ export default class IABookActions extends LitElement {
752
807
  const secondsShouldLeft =
753
808
  this.lendingStatus.secondsLeftOnLoan - diffInSeconds;
754
809
 
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
-
764
810
  // convert in minutes
765
811
  const whatIsleft = Math.round(timerSecondsLeft);
766
812
  const whatShouldLeft = Math.round(secondsShouldLeft);
767
813
  const timerElSeconds = this.timerCountdownEl.secondsLeftOnLoan || 0;
768
814
 
769
- log(`reSyncTimerIfGoneOff?`, {
770
- whatIsleft,
771
- whatShouldLeft,
772
- timerElSeconds,
773
- timeLeftInMin: Math.ceil(timerSecondsLeft / 60),
774
- });
775
-
776
815
  if (timerElSeconds !== whatShouldLeft || whatIsleft !== whatShouldLeft) {
777
816
  // set lending status with new time to update decrementor
778
817
  const currStatus = {
@@ -783,7 +822,9 @@ export default class IABookActions extends LitElement {
783
822
  }
784
823
 
785
824
  if (whatIsleft !== whatShouldLeft) {
786
- log(`reSyncTimerIfGoneOff --- let's re-sync.`);
825
+ log(
826
+ `[IABookActions] reSyncTimerIfGoneOff ${whatIsleft} - ${whatShouldLeft}: re-syncing timer`
827
+ );
787
828
  return { hasSynced: true, whatShouldLeft };
788
829
  }
789
830
 
@@ -799,10 +840,6 @@ export default class IABookActions extends LitElement {
799
840
  * @memberof IABookActions
800
841
  */
801
842
  async loanRenewAttempt(secondsLeft) {
802
- log('loanRenewAttempt ---', {
803
- secondsLeft,
804
- loanRenewResult: this.loanRenewResult,
805
- });
806
843
  let loanSecondsLeft = secondsLeft;
807
844
  /**
808
845
  * auto-renew is not possible in last seconds (let say 50 second) because,
@@ -812,7 +849,7 @@ export default class IABookActions extends LitElement {
812
849
  * so if seconds left is < 50, just expire the loan
813
850
  */
814
851
  if (loanSecondsLeft < 50) {
815
- log('loanRenewAttempt --- loanSecondsLeft < 50, will expire');
852
+ log('[IABookActions] loanRenewAttempt: < 50s left, expiring loan');
816
853
  await this.browseHasExpired();
817
854
  return;
818
855
  }
@@ -894,9 +931,17 @@ export default class IABookActions extends LitElement {
894
931
  const action = event?.detail?.action;
895
932
  const errorMsg = event?.detail?.data?.error;
896
933
 
897
- // template not show create_token errors
898
- if (errorMsg && action !== 'create_token')
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') {
899
943
  this.showErrorModal(errorMsg, action);
944
+ }
900
945
 
901
946
  // if error related to loan token
902
947
  // - set user_has_browsed to `false`
@@ -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', () => {
@@ -467,7 +488,7 @@ describe('Visibility change API for document', async () => {
467
488
  });
468
489
  });
469
490
 
470
- it('when book is not expired', async () => {
491
+ it('when book is not expired and loan time is valid', async () => {
471
492
  const el = await fixture(
472
493
  container({
473
494
  userid: '@user1',
@@ -480,31 +501,220 @@ describe('Visibility change API for document', async () => {
480
501
  );
481
502
  await el.updateComplete;
482
503
 
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
+
483
511
  const spy = Sinon.spy(el, 'loanStatusCheckInterval');
484
512
  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);
485
541
 
486
542
  expect(spy.calledOnce).to.be.true;
487
543
  });
488
544
 
489
- it('when book is already expired', async () => {
545
+ it('when loan already marked expired (browsingExpired true)', async () => {
490
546
  const el = await fixture(
491
547
  container({
492
548
  userid: '@user1',
493
549
  identifier: 'foobar',
494
550
  lendingStatus: {
495
551
  user_has_browsed: true,
552
+ browsingExpired: true,
496
553
  },
497
554
  })
498
555
  );
499
556
  await el.updateComplete;
500
557
 
501
- const spy = Sinon.spy(el, 'browseHasExpired');
558
+ el.borrowType = 'browsed';
559
+
560
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
502
561
  document.dispatchEvent(new Event('visibilitychange'));
562
+ await aTimeout(100);
503
563
 
504
564
  expect(spy.calledOnce).to.be.true;
505
565
  });
506
566
  });
507
567
 
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
+
508
718
  describe('Shared Resize Observer', () => {
509
719
  it('can receive a Shared Resize Observer', async () => {
510
720
  const sharedObserverStub = new SharedResizeObserver();
@@ -1,16 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(yarn outdated *)",
5
- "Bash(gh pr *)",
6
- "Bash(git log *)",
7
- "mcp__plugin_atlassian_atlassian__getAccessibleAtlassianResources",
8
- "Bash(npm view *)",
9
- "mcp__plugin_atlassian_atlassian__searchJiraIssuesUsingJql",
10
- "Bash(xargs -I {} basename {})",
11
- "Bash(npm info *)",
12
- "mcp__plugin_atlassian_atlassian__getJiraIssue",
13
- "mcp__plugin_atlassian_atlassian__getJiraProjectIssueTypesMetadata"
14
- ]
15
- }
16
- }