From e5ed1c96e50e7657c5ba8510fcb8b04b1f1de611 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 18 Aug 2026 21:57:09 +0400 Subject: [PATCH] fix: checkout double-click created two sessions; F59/F62 E2E coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/admin-dev-bypass.spec.ts | 27 ++++++++++ e2e/checkout-idempotent-click.spec.ts | 76 +++++++++++++++++++++++++++ src/app/pages/cart/cart.component.ts | 28 +++++++++- 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 e2e/admin-dev-bypass.spec.ts create mode 100644 e2e/checkout-idempotent-click.spec.ts diff --git a/e2e/admin-dev-bypass.spec.ts b/e2e/admin-dev-bypass.spec.ts new file mode 100644 index 0000000..d47230a --- /dev/null +++ b/e2e/admin-dev-bypass.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; + +/** + * Track Q Q2 / frontend backlog F59: past "verified live" admin claims were + * code-inspection only, because /backoffice needs a real Telegram login this + * suite cannot perform. ?devBypassAdmin=true (src/app/app.ts, gated by + * Angular's isDevMode() at runtime in @marketplaces/auth's + * AdminAuthService.devBypassLogin - not just build-time, and a no-op in any + * production build) is the existing, already-shipped answer - this test just + * proves it actually gets an E2E run into the admin shell. + */ +test.describe('admin dev bypass', () => { + test('?devBypassAdmin=true reaches the admin shell without a Telegram login', async ({ page }) => { + await page.goto('/?devBypassAdmin=true'); + await page.waitForLoadState('networkidle'); + + // The bypass alone doesn't navigate anywhere - it only activates the + // session, so the admin surface has to be reached directly afterwards. + await page.goto('/admin/dashboard'); + await page.waitForLoadState('networkidle'); + + // A real Telegram-gated admin route would redirect to a login dialog; + // reaching dashboard content is the actual proof the bypass worked. + await expect(page).not.toHaveURL(/login/i); + await expect(page.locator('body')).not.toContainText(/scan.*qr|log in with telegram/i); + }); +}); diff --git a/e2e/checkout-idempotent-click.spec.ts b/e2e/checkout-idempotent-click.spec.ts new file mode 100644 index 0000000..f3f58d6 --- /dev/null +++ b/e2e/checkout-idempotent-click.spec.ts @@ -0,0 +1,76 @@ +import { Page, Route, expect, test } from '@playwright/test'; + +/** + * Track Q Q5 / frontend backlog F62: "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, provider + providerEventId as + * the dedup key) this suite cannot exercise without a live backend. This + * test covers the half that IS frontend-testable: a double-click on the + * checkout button must not fire two checkout-session requests. + */ + +const FAKE_ITEM = { + categoryID: 1, itemID: 5151, name: 'Idempotency Test Item', photos: null, + description: '', currency: 'RUB', price: 500, discount: 0, rating: 0, + callbacks: null, questions: null, quantity: 1, +}; + +test('double-clicking checkout sends exactly one checkout-session request', async ({ page, context }) => { + await page.addInitScript(item => { + window.localStorage.setItem('marketplace_cart', JSON.stringify([item])); + }, FAKE_ITEM); + + await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', domain: 'localhost', path: '/' }]); + await page.route('**/users/sessions/**', route => + route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ sessionId: 'e2e-fake-session', status: 'active', username: 'e2e_user', userId: 1 }), + }), + ); + await page.route('**/api/v2/pricing/fx-quote**', route => + route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ quoteId: 'fxq_e2e', base: 'RUB', quote: 'RUB', rate: 1, source: 'e2e', observedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 300000).toISOString() }), + }), + ); + + let checkoutRequestCount = 0; + await page.route('**/api/v2/storefront/checkout', async (route: Route) => { + checkoutRequestCount += 1; + // Deliberately slow, so a real double-click's second event has to land + // while the first request is still in flight - the exact race this test + // exists to catch. + await new Promise(resolve => setTimeout(resolve, 300)); + route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ + checkoutSessionId: 'chk_e2e_idempotent', + lines: [{ offerId: String(FAKE_ITEM.itemID), qty: 1, unitPrice: { amountMinor: 50000, currency: 'RUB' }, lineTotal: { amountMinor: 50000, currency: 'RUB' }, priceSnapshotId: 'snap_e2e' }], + subtotal: { amountMinor: 50000, currency: 'RUB' }, discount: { amountMinor: 0, currency: 'RUB' }, + delivery: { amountMinor: 0, currency: 'RUB' }, total: { amountMinor: 50000, currency: 'RUB' }, + fxQuoteId: 'fxq_e2e', expiresAt: new Date(Date.now() + 300000).toISOString(), + }), + }); + }); + await page.route('**/api/v2/storefront/payments/intents', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ qrId: 'qr_e2e', nspkurl: 'https://example.com/pay', qrTTL: 5 }) }), + ); + + await page.goto('/cart'); + await page.waitForLoadState('networkidle'); + + // No