fix: checkout double-click created two sessions; F59/F62 E2E coverage
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

E2E found a real, pre-existing bug, not a test artifact: isCheckoutDisabled
only checked terms/auth/delivery-selection, never whether a checkout was
already in flight. A double-click (or any rapid repeat click) fired two
handler calls before showPaymentPopup's change detection had a chance to
cover the button, producing two separate POST /api/v2/storefront/checkout
requests for one click.

Fixed with checkoutInFlight, set synchronously at the top of checkout()
before anything async happens, checked in isCheckoutDisabled. Released in
both closePaymentPopup() (every retry/close path routes through it) and
setPaymentError() directly, since the popup can stay open to show an error
rather than closing - relying on only one of those would leave a failed
attempt unable to retry.

Track Q coverage (F59, F62):

- admin-dev-bypass.spec.ts - proves ?devBypassAdmin=true (already shipped
  in app.ts, gated by @marketplaces/auth's isDevMode() check at runtime)
  actually gets an E2E run into the admin shell without a Telegram login.
  This was the missing piece behind Q2's note that past "verified live"
  admin claims were code-inspection only.
- checkout-idempotent-click.spec.ts - the frontend-testable half of Q5
  ("repeat webhook and double-click create exactly one order"). The
  webhook-idempotency half is a backend contract
  (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §6.3) this suite can't exercise
  without a live backend.

One own test bug fixed en route, not shipped: the idempotency test's first
draft waited on label[for="terms-checkbox"], which does not exist in the
markup (the checkbox and its text share a plain clickable wrapper, no
label/for). checkout-request-shape.spec.ts already had the correct fallback
(dispatchEvent('click') on the input directly) for exactly this reason -
this test just hadn't copied it.

Verified: 237/237 unit tests, arch:check clean, 7/7 E2E, production build
succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 21:57:09 +04:00
parent 90bd05aa98
commit e5ed1c96e5
3 changed files with 130 additions and 1 deletions

View File

@@ -189,13 +189,30 @@ export class CartComponent implements OnDestroy {
convertTotal(amountInBaseCurrency: number): number {
return this.currencyRates.convert(amountInBaseCurrency, this.currencyRates.baseCurrency, this.currentCurrency);
}
get isCheckoutDisabled(): boolean { return !this.termsAccepted || !this.isAuthenticated() || !this.allRequiredDeliveriesSelected(); }
/**
* A double-click (or any rapid repeat click) on the checkout button fires
* two synchronous handler calls before showPaymentPopup's change detection
* has a chance to cover the button - found via E2E
* (checkout-idempotent-click.spec.ts), which caught two real
* POST /api/v2/storefront/checkout requests from one double-click.
* checkoutInFlight closes that window: it is set before anything async
* happens, so the second call sees it and bails immediately.
*/
private readonly checkoutInFlight = signal(false);
get isCheckoutDisabled(): boolean {
return !this.termsAccepted || !this.isAuthenticated() || !this.allRequiredDeliveriesSelected() || this.checkoutInFlight();
}
selectDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
this.cartService.setSelectedDelivery(itemID, selectedDelivery);
}
checkout(paymentMethod: PaymentMethod): void {
if (this.checkoutInFlight()) {
return;
}
if (!this.allRequiredDeliveriesSelected()) {
this.notifications.show(this.i18n.t('cart.deliveryRequired'), 'warning');
return;
@@ -205,6 +222,8 @@ export class CartComponent implements OnDestroy {
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
return;
}
this.checkoutInFlight.set(true);
this.analytics.track('checkout_started', { itemCount: this.items().length });
this.openPaymentPopup(paymentMethod);
}
@@ -248,6 +267,9 @@ export class CartComponent implements OnDestroy {
clearTimeout(this.closeTimeout);
this.closeTimeout = undefined;
}
// Every retry/close path routes through here - release the checkout
// button so a shopper who dismisses an error can actually try again.
this.checkoutInFlight.set(false);
}
retryPayment(): void {
@@ -446,6 +468,10 @@ export class CartComponent implements OnDestroy {
clearTimeout(this.closeTimeout);
this.closeTimeout = undefined;
}
// The popup may stay open to show the error rather than closing, so
// this can't rely on closePaymentPopup() being called - release the
// checkout button here too, or a failed attempt locks retry out forever.
this.checkoutInFlight.set(false);
}
/**