feat: poll pending purchase status

This commit is contained in:
2026-08-18 15:57:01 -03:00
parent c486131905
commit dd2082f40e
2 changed files with 159 additions and 9 deletions

View File

@@ -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();
}
});
});

View File

@@ -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<typeof setTimeout> | null = null;
private isDestroyed = false;
protected readonly isLoading = signal(true);
protected readonly status = signal<PurchaseStatusView>('pending');
@@ -57,7 +75,12 @@ export class PurchaseStatusPageComponent implements OnInit {
void this.loadStatus();
}
private async loadStatus(): Promise<void> {
ngOnDestroy(): void {
this.isDestroyed = true;
this.stopPolling();
}
private async loadStatus(isPolling = false): Promise<void> {
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;
}
}