Compare commits
2 Commits
4247a7f83f
...
9344f2702c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9344f2702c | ||
|
|
e5ed1c96e5 |
27
e2e/admin-dev-bypass.spec.ts
Normal file
27
e2e/admin-dev-bypass.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
e2e/checkout-idempotent-click.spec.ts
Normal file
76
e2e/checkout-idempotent-click.spec.ts
Normal file
@@ -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 <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);
|
||||||
|
});
|
||||||
@@ -189,13 +189,30 @@ export class CartComponent implements OnDestroy {
|
|||||||
convertTotal(amountInBaseCurrency: number): number {
|
convertTotal(amountInBaseCurrency: number): number {
|
||||||
return this.currencyRates.convert(amountInBaseCurrency, this.currencyRates.baseCurrency, this.currentCurrency);
|
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 {
|
selectDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
|
||||||
this.cartService.setSelectedDelivery(itemID, selectedDelivery);
|
this.cartService.setSelectedDelivery(itemID, selectedDelivery);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkout(paymentMethod: PaymentMethod): void {
|
checkout(paymentMethod: PaymentMethod): void {
|
||||||
|
if (this.checkoutInFlight()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.allRequiredDeliveriesSelected()) {
|
if (!this.allRequiredDeliveriesSelected()) {
|
||||||
this.notifications.show(this.i18n.t('cart.deliveryRequired'), 'warning');
|
this.notifications.show(this.i18n.t('cart.deliveryRequired'), 'warning');
|
||||||
return;
|
return;
|
||||||
@@ -205,6 +222,8 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
|
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.checkoutInFlight.set(true);
|
||||||
this.analytics.track('checkout_started', { itemCount: this.items().length });
|
this.analytics.track('checkout_started', { itemCount: this.items().length });
|
||||||
this.openPaymentPopup(paymentMethod);
|
this.openPaymentPopup(paymentMethod);
|
||||||
}
|
}
|
||||||
@@ -248,6 +267,9 @@ export class CartComponent implements OnDestroy {
|
|||||||
clearTimeout(this.closeTimeout);
|
clearTimeout(this.closeTimeout);
|
||||||
this.closeTimeout = undefined;
|
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 {
|
retryPayment(): void {
|
||||||
@@ -446,6 +468,10 @@ export class CartComponent implements OnDestroy {
|
|||||||
clearTimeout(this.closeTimeout);
|
clearTimeout(this.closeTimeout);
|
||||||
this.closeTimeout = undefined;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user