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; expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture'); expect(body).not.toHaveProperty('amount'); expect(typeof body.merchantReference).toBe('string'); expect(body.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 { await context.addCookies([ { name: 'webSessionID', value: FAKE_SESSION_ID, domain: 'localhost', path: '/', }, ]); // 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 => { page.route('**/api/v2/storefront/payments/intents', (route: Route) => { const body = route.request().postDataJSON(); resolve(body); route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ qrId: 'qr_e2e_fixture', nspkurl: 'https://example.com/pay/e2e', qrTTL: 5, }), }); }); }); } async function acceptTermsAndCheckout(page: Page): Promise { await page.waitForLoadState('networkidle'); // #terms-checkbox is a custom-styled input (zero-size native element, a //