checkout-request-shape.spec.ts and checkout-idempotent-click.spec.ts were
flagged known-failing pending investigation; dda0a3d found and fixed the
actual cause (circular DI in apiHeadersInterceptor). Update the comments
and README so they no longer point at an unresolved mystery.
202 lines
7.4 KiB
TypeScript
202 lines
7.4 KiB
TypeScript
import { Page, Route, expect, test } from '@playwright/test';
|
|
|
|
/**
|
|
* Guards the specific contract this rewrite exists to enforce
|
|
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2): the amount actually charged
|
|
* must be computed server-side, never sent by the client. Before this
|
|
* rewrite, POST /cart carried a client-computed `amount` the backend was
|
|
* asked to trust.
|
|
*
|
|
* cart.component.ts has no unit spec (no src/app/pages/cart/*.spec.ts
|
|
* exists), so this E2E test is the only coverage the checkout request shape
|
|
* has. Scoped narrowly on purpose: cart state is seeded directly into
|
|
* localStorage and the customer session is faked via cookie + intercepted
|
|
* session-check, rather than driving a full add-to-cart-then-login UI
|
|
* journey - that journey is real product surface worth its own test, but
|
|
* would make this test about navigation, not about what it exists to prove.
|
|
*/
|
|
|
|
const FAKE_SESSION_ID = 'e2e-fake-session';
|
|
const FAKE_ITEM = {
|
|
categoryID: 1,
|
|
itemID: 4242,
|
|
name: 'E2E Test Item',
|
|
photos: null,
|
|
description: '',
|
|
currency: 'RUB',
|
|
price: 1000,
|
|
discount: 0,
|
|
rating: 0,
|
|
callbacks: null,
|
|
questions: null,
|
|
quantity: 2,
|
|
};
|
|
|
|
test.describe('checkout request shape', () => {
|
|
test.beforeEach(async ({ page, context }) => {
|
|
await seedCart(page);
|
|
await fakeCustomerSession(page, context);
|
|
await mockFxQuoteEndpoint(page);
|
|
});
|
|
|
|
test('checkout session request carries offers and qty, never amount or price', async ({ page }) => {
|
|
const checkoutRequest = interceptCheckoutSession(page);
|
|
|
|
await page.goto('/cart');
|
|
await acceptTermsAndCheckout(page);
|
|
|
|
const body = await checkoutRequest;
|
|
|
|
expect(body, 'must never send a client-computed amount').not.toHaveProperty('amount');
|
|
expect(body, 'must never send a client-computed price').not.toHaveProperty('price');
|
|
expect(Array.isArray(body.offers), 'must send an offers array').toBe(true);
|
|
expect(body.offers[0]).toMatchObject({ offerId: String(FAKE_ITEM.itemID), qty: FAKE_ITEM.quantity });
|
|
});
|
|
|
|
test('payment intent request references the checkout session id, not a raw amount', async ({ page }) => {
|
|
interceptCheckoutSession(page); // must resolve for the intent call to fire at all
|
|
const intentRequest = interceptPaymentIntent(page);
|
|
|
|
await page.goto('/cart');
|
|
await acceptTermsAndCheckout(page);
|
|
|
|
const body = await intentRequest;
|
|
|
|
// Payment creation now goes through @marketplaces/payment
|
|
// (MARKETPLACES_PAYMENT_GATEWAY -> POST {qrApiUrl}/api/v1/payments),
|
|
// not api.service.ts's superseded createPaymentIntent - see
|
|
// cart.component.ts's createPaymentIntent() comment.
|
|
expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture');
|
|
expect(body).not.toHaveProperty('amount');
|
|
const metadata = body.metadata as Record<string, string> | undefined;
|
|
expect(typeof metadata?.merchantReference).toBe('string');
|
|
expect((metadata?.merchantReference ?? '').length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
async function seedCart(page: Page): Promise<void> {
|
|
await page.addInitScript(item => {
|
|
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
|
|
}, FAKE_ITEM);
|
|
}
|
|
|
|
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
|
|
// Root-caused and fixed 2026-08-21 (see api-headers.interceptor.ts):
|
|
// apiHeadersInterceptor injected AuthService to attach a WebSessionID
|
|
// header, but AuthService's own constructor makes the exact
|
|
// GET /users/sessions/:id call this interceptor runs on, which threw
|
|
// NG0200 (circular dependency) mid-construction on every page load -
|
|
// swallowed silently, read as "session invalid," cookie cleared
|
|
// immediately. The { url } cookie form below is unrelated to that bug but
|
|
// is still the more correct form, so it stays.
|
|
await context.addCookies([
|
|
{
|
|
name: 'webSessionID',
|
|
value: FAKE_SESSION_ID,
|
|
url: 'http://localhost:4200',
|
|
},
|
|
]);
|
|
|
|
// Matches TelegramSessionApiService.normalizeWebSession's expected shape.
|
|
await page.route('**/users/sessions/**', route => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
sessionId: FAKE_SESSION_ID,
|
|
status: 'active',
|
|
username: 'e2e_user',
|
|
userId: 1,
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
|
|
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-fixture',
|
|
observedAt: new Date().toISOString(),
|
|
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
function interceptCheckoutSession(page: Page): Promise<Record<string, unknown>> {
|
|
return new Promise(resolve => {
|
|
page.route('**/api/v2/storefront/checkout', (route: Route) => {
|
|
const body = route.request().postDataJSON();
|
|
resolve(body);
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
checkoutSessionId: 'chk_e2e_fixture',
|
|
lines: [{
|
|
offerId: String(FAKE_ITEM.itemID),
|
|
qty: FAKE_ITEM.quantity,
|
|
unitPrice: { amountMinor: FAKE_ITEM.price * 100, currency: 'RUB' },
|
|
lineTotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
|
priceSnapshotId: 'snap_e2e',
|
|
}],
|
|
subtotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
|
discount: { amountMinor: 0, currency: 'RUB' },
|
|
delivery: { amountMinor: 0, currency: 'RUB' },
|
|
total: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
|
fxQuoteId: 'fxq_e2e',
|
|
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function interceptPaymentIntent(page: Page): Promise<Record<string, unknown>> {
|
|
return new Promise(resolve => {
|
|
// @marketplaces/payment: apiUrl (qrApiUrl with its trailing /api
|
|
// stripped, see app.config.ts) + default paymentsPath '/api/v1/payments'.
|
|
page.route('**/api/v1/payments', (route: Route) => {
|
|
const body = route.request().postDataJSON();
|
|
resolve(body);
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
paymentId: 'qr_e2e_fixture',
|
|
method: 'qr',
|
|
status: 'pending',
|
|
action: { type: 'qr', url: 'https://example.com/pay/e2e' },
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function acceptTermsAndCheckout(page: Page): Promise<void> {
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// #terms-checkbox is a custom-styled input (zero-size native element, a
|
|
// <label> renders the visible box) - .check() refuses on geometry even
|
|
// with force:true, so toggle it via its label the way a real user would.
|
|
const termsCheckbox = page.locator('#terms-checkbox');
|
|
if (await termsCheckbox.count() > 0) {
|
|
const label = page.locator('label[for="terms-checkbox"]');
|
|
if (await label.count() > 0) {
|
|
await label.click();
|
|
} else {
|
|
await termsCheckbox.dispatchEvent('click');
|
|
}
|
|
}
|
|
|
|
const qrButton = page.getByRole('button', { name: /qr/i }).first();
|
|
await qrButton.click();
|
|
}
|