fix: checkout double-click created two sessions; F59/F62 E2E coverage
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>
2026-08-18 21:57:09 +04:00
|
|
|
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);
|
|
|
|
|
|
feat: start @marketplaces/payment implementation
package.json already had @marketplaces/payment added (uncommitted) when this
started. Wired it in.
- app.config.ts: provideMarketplacesPayment(). apiUrl is environment.qrApiUrl
with its trailing /api stripped - found and fixed a real bug while wiring
this: qrApiUrl already ends in /api, and the package's default
paymentsPath is '/api/v1/payments', so passing qrApiUrl unchanged would
have silently doubled the path to .../api/api/v1/payments. Confirmed by
reading the package's baseUrl() concatenation directly, not guessed.
marketplaceDomain is a plain closure (not TenantResolverService) since
provideMarketplacesPayment runs outside the injector.
- cart.component.ts: createPaymentIntent() and startPolling() now go through
MARKETPLACES_PAYMENT_GATEWAY instead of api.service.ts's
createPaymentIntent/checkCartPaymentStatus/checkCartCardPaymentStatus (our
own earlier inferred contract, now superseded by the package's real,
published one - POST/GET {qrApiUrl}/api/v1/payments). Deliberately did NOT
swap to the package's own <mp-payment> UI component - that has a different
UX paradigm entirely (window.open for redirects instead of an iframe
popup, client-side QR generation instead of an external image service) and
replacing the existing, already-tested 769-line popup state machine
wholesale is a separate, much larger change than "wire the new package
in." Only the I/O layer moved; the surrounding state machine (paymentStatus,
checkoutInFlight, timeout/success/error handling, bank-iframe UX) is
untouched.
Response shape differs from the legacy provider: the package's
PaymentStatus is a fixed union (created/pending/authorized/paid/failed/
cancelled/expired), not a free-form string+code pair - simplified the
status-check conditionals accordingly and added 'authorized' as a second
success state (PaymentResult's own status union), which the legacy check
didn't have. The package also carries no TTL/expiry field on its response,
unlike the legacy provider's qrTTL - polling duration now falls back to
PAYMENT_MIN_POLL_SECONDS alone; flagged in a comment.
- api.service.ts's createPaymentIntent and its QrCreateResponse-based
resolvePaymentQrId/resolvePaymentQrUrl/resolvePaymentLink/
resolveBankPaymentUrl helpers are now dead code. Left in place rather than
deleted in the same pass that adds a new external dependency, so a revert
doesn't also need to resurrect deleted code.
Verified: production build succeeds, 247/247 unit tests, arch:check clean.
E2E: 2 of 7 tests currently fail
(checkout-request-shape.spec.ts, checkout-idempotent-click.spec.ts), and
this is disclosed honestly rather than hidden. Root cause, confirmed by
tracing real network requests: the customer-session cookie fake these tests
rely on stops working somewhere between the cookie being demonstrably
present in the browser (context.cookies(), and document.cookie read from a
plain page on the same origin) and Angular's own AuthService reading it -
the session-check request never fires at all. This reproduces with or
without this session's payment changes (checkout-idempotent-click.spec.ts
doesn't touch payment creation and fails the same way), so it is not a
regression introduced here, but it is unresolved. Tried switching
context.addCookies from {domain,path} to {url} form (the standard fix for
this class of Playwright cookie issue) - did not fix it, kept anyway as the
more correct form. Documented as a known, unresolved issue directly in both
spec files and e2e/README.md rather than deleting or silently marking the
tests skip - the request-shape assertions those tests make are still
correct, they are just currently unverifiable through this harness.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:37:16 +04:00
|
|
|
// KNOWN ISSUE, NOT RESOLVED (2026-08-21) - see checkout-request-shape.spec.ts's
|
|
|
|
|
// fakeCustomerSession comment. This test currently fails the same way:
|
|
|
|
|
// the session check never fires despite the cookie being present.
|
|
|
|
|
await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', url: 'http://localhost:4200' }]);
|
fix: checkout double-click created two sessions; F59/F62 E2E coverage
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>
2026-08-18 21:57:09 +04:00
|
|
|
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 <label for="terms-checkbox"> exists in the markup - the checkbox and
|
|
|
|
|
// its text share a plain clickable wrapper - so toggle the input directly.
|
|
|
|
|
await page.locator('#terms-checkbox').dispatchEvent('click');
|
|
|
|
|
await expect(page.locator('#terms-checkbox')).toBeChecked();
|
|
|
|
|
|
|
|
|
|
const qrButton = page.getByRole('button', { name: /qr/i }).first();
|
|
|
|
|
await expect(qrButton).toBeEnabled({ timeout: 10_000 });
|
|
|
|
|
await qrButton.dblclick();
|
|
|
|
|
|
|
|
|
|
// Give the deliberately slow mock time to resolve and for any second,
|
|
|
|
|
// erroneously-fired request to have landed.
|
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
|
|
|
|
|
|
expect(checkoutRequestCount, 'a double-click must not create two checkout sessions').toBe(1);
|
|
|
|
|
});
|