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 | undefined; expect(typeof metadata?.merchantReference).toBe('string'); expect((metadata?.merchantReference ?? '').length).toBeGreaterThan(0); }); }); async function seedCart(page: Page): Promise { 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 { // 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 { 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> { 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> { 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 { await page.waitForLoadState('networkidle'); // #terms-checkbox is a custom-styled input (zero-size native element, a //