diff --git a/e2e/checkout-request-shape.spec.ts b/e2e/checkout-request-shape.spec.ts new file mode 100644 index 0000000..d6916ca --- /dev/null +++ b/e2e/checkout-request-shape.spec.ts @@ -0,0 +1,186 @@ +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 + //