From dd2082f40eaf64a8440bbaf4bcde74451f70cc62 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 18 Aug 2026 15:57:01 -0300 Subject: [PATCH] feat: poll pending purchase status --- .../purchase-status-page.component.spec.ts | 90 ++++++++++++++++++- .../purchase-status-page.component.ts | 78 ++++++++++++++-- 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts index 49c81e7..a41a839 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; -import { CheckoutService, PurchaseDetailResponse } from '../../../../core/services/checkout.service'; +import { + CheckoutService, + PurchaseDetailResponse, +} from '../../../../core/services/checkout.service'; import { Tenant } from '../../../../core/services/tenant.interface'; import { TenantService } from '../../../../core/services/tenant.service'; import { PurchaseStatusPageComponent } from './purchase-status-page.component'; @@ -130,4 +133,89 @@ describe('PurchaseStatusPageComponent', () => { openSpy.mockRestore(); }); + + it('polls every five seconds while the payment is pending and shows the confirmation', async () => { + vi.useFakeTimers(); + + try { + const checkoutService = { + getPurchase: vi + .fn() + .mockResolvedValueOnce({ status: 'pending_payment' } as PurchaseDetailResponse) + .mockResolvedValueOnce(purchase(true)), + withCustomLoading() { + return this; + }, + }; + + await TestBed.configureTestingModule({ + imports: [PurchaseStatusPageComponent], + providers: [ + { provide: CheckoutService, useValue: checkoutService }, + { provide: TenantService, useValue: { tenant: () => tenant } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } }, + }, + { provide: Router, useValue: { navigate: vi.fn(), navigateByUrl: vi.fn() } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(PurchaseStatusPageComponent); + fixture.detectChanges(); + await Promise.resolve(); + fixture.detectChanges(); + + expect(checkoutService.getPurchase).toHaveBeenCalledOnce(); + expect(fixture.nativeElement.textContent).toContain('ESTAMOS VERIFICANDO TU PAGO'); + + await vi.advanceTimersByTimeAsync(5_000); + fixture.detectChanges(); + + expect(checkoutService.getPurchase).toHaveBeenCalledTimes(2); + expect(fixture.nativeElement.textContent).toContain('COMPRA REALIZADA!'); + + await vi.advanceTimersByTimeAsync(10_000); + expect(checkoutService.getPurchase).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('stops polling when the page is destroyed', async () => { + vi.useFakeTimers(); + + try { + const checkoutService = { + getPurchase: vi.fn().mockResolvedValue({ status: 'pending_payment' }), + withCustomLoading() { + return this; + }, + }; + + await TestBed.configureTestingModule({ + imports: [PurchaseStatusPageComponent], + providers: [ + { provide: CheckoutService, useValue: checkoutService }, + { provide: TenantService, useValue: { tenant: () => tenant } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: convertToParamMap({ id: '42' }) } }, + }, + { provide: Router, useValue: { navigate: vi.fn(), navigateByUrl: vi.fn() } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(PurchaseStatusPageComponent); + fixture.detectChanges(); + await Promise.resolve(); + fixture.destroy(); + + await vi.advanceTimersByTimeAsync(5_000); + + expect(checkoutService.getPurchase).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts index a397a6d..2797027 100644 --- a/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts +++ b/src/app/features/store/pages/purchase-status-page/purchase-status-page.component.ts @@ -1,29 +1,47 @@ -import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + PLATFORM_ID, + computed, + inject, + signal, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; -import { CheckoutService, PurchaseStatusResponse } from '../../../../core/services/checkout.service'; +import { + CheckoutService, + PurchaseStatusResponse, +} from '../../../../core/services/checkout.service'; import { findMenu } from '../../../../core/services/menu.utils'; import { TenantService } from '../../../../core/services/tenant.service'; import { ButtonComponent } from '../../../../shared/components/button/button.component'; type PurchaseStatusView = 'approved' | 'pending' | 'rejected' | 'expired' | 'error'; +const PAYMENT_STATUS_POLL_INTERVAL_MS = 5_000; + @Component({ selector: 'app-purchase-status-page', standalone: true, imports: [ButtonComponent], templateUrl: './purchase-status-page.component.html', styleUrl: './purchase-status-page.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PurchaseStatusPageComponent implements OnInit { +export class PurchaseStatusPageComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly checkoutService = inject(CheckoutService); private readonly tenantService = inject(TenantService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private purchaseId: string | null = null; private tenantCode: string | null = null; + private pollingTimeout: ReturnType | null = null; + private isDestroyed = false; protected readonly isLoading = signal(true); protected readonly status = signal('pending'); @@ -57,7 +75,12 @@ export class PurchaseStatusPageComponent implements OnInit { void this.loadStatus(); } - private async loadStatus(): Promise { + ngOnDestroy(): void { + this.isDestroyed = true; + this.stopPolling(); + } + + private async loadStatus(isPolling = false): Promise { if (!this.purchaseId || !this.tenantCode) { return; } @@ -66,13 +89,52 @@ export class PurchaseStatusPageComponent implements OnInit { const purchase = await this.checkoutService .withCustomLoading() .getPurchase(this.tenantCode, this.purchaseId); - this.status.set(this.resolveStatus(purchase)); + + if (this.isDestroyed) { + return; + } + + const status = this.resolveStatus(purchase); + this.status.set(status); this.hasGeneratedTickets.set(purchase.has_generated_tickets === true); + + if (status === 'pending') { + this.schedulePolling(); + } else { + this.stopPolling(); + } } catch (error) { console.error('Failed to fetch purchase status:', error); - this.status.set('error'); + + if (!this.isDestroyed) { + if (isPolling) { + this.schedulePolling(); + } else { + this.status.set('error'); + } + } } finally { - this.isLoading.set(false); + if (!this.isDestroyed) { + this.isLoading.set(false); + } + } + } + + private schedulePolling(): void { + if (!this.isBrowser || this.isDestroyed || this.pollingTimeout) { + return; + } + + this.pollingTimeout = setTimeout(() => { + this.pollingTimeout = null; + void this.loadStatus(true); + }, PAYMENT_STATUS_POLL_INTERVAL_MS); + } + + private stopPolling(): void { + if (this.pollingTimeout) { + clearTimeout(this.pollingTimeout); + this.pollingTimeout = null; } }