import { Page, Route, expect, test } from '@playwright/test'; /** * Track Q Q4: currency switch must recalculate by FX quote. Explicitly, * "160 RUB" must not become "160 USD" - the number has to change, not just * the label next to it. * * Written before the checkout money-truth rewrite (frontend backlog F10-F16, * which deletes CurrencyRatesService's client-side float math and switches * checkout to a server-computed total per * docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5). This test exists so * that rewrite has something to break loudly if it silently stops converting. * * This session has no live backend to run against, so GET * /api/v2/pricing/fx-quote is intercepted with a response shaped exactly per * contract §3.1. That exercises the REAL code path - FxQuoteApiGateway, * CurrencyRatesService, the currencyConvert pipe - rather than switching the * whole app into mock mode, which would test a different (mock) gateway * instead of the one actually shipped. */ /** Rate relative to RUB, only what this test needs. */ const MOCK_RATE: Record = { USD: 0.0108, EUR: 0.0092, AMD: 4.31 }; test.describe('currency switch', () => { test.beforeEach(async ({ page }) => { await mockFxQuoteEndpoint(page); }); test('switching currency changes the displayed price value, not just its label', async ({ page }) => { await page.goto('/'); await page.waitForLoadState('networkidle'); const priceLocator = page.locator('.current-price, .original-price').first(); await expect(priceLocator).toBeVisible({ timeout: 15_000 }); const before = await readPrice(priceLocator); expect(before.value, 'a price must be a real positive number before switching').toBeGreaterThan(0); await switchCurrency(page, before.currency === 'USD' ? 'RUB' : 'USD'); // The currency LABEL flips synchronously (a signal write), but the rate // itself arrives from the mocked network call asynchronously - polling // only the label races ahead of the actual conversion and passes before // the number has caught up. Poll the parsed numeric value instead, since // that is what this test exists to guard. await expect .poll(async () => (await readPrice(priceLocator)).value, { message: 'price value never diverged from the pre-switch amount', }) .not.toBeCloseTo(before.value, 2); const after = await readPrice(priceLocator); expect(after.currency, 'the currency label must actually change').not.toBe(before.currency); // The literal regression this test exists to catch: a rate of 1 disguised // as a real conversion. RUB->USD or USD->RUB is never a 1:1 rate. expect(after.value, `${before.value} ${before.currency} must not equal ${after.value} ${after.currency}`).not.toBeCloseTo(before.value, 2); }); test('an out-of-range rate must not silently pass as valid', async ({ page }) => { // Guards the specific bad-data class this suite exists to catch: a // conversion that returns something implausible (zero, negative, or // absurdly large) instead of erroring visibly. await page.goto('/'); const priceLocator = page.locator('.current-price, .original-price').first(); await expect(priceLocator).toBeVisible({ timeout: 15_000 }); const { value } = await readPrice(priceLocator); expect(value).toBeGreaterThan(0); expect(value).toBeLessThan(100_000_000); }); }); async function mockFxQuoteEndpoint(page: Page): Promise { await page.route('**/api/v2/pricing/fx-quote**', (route: Route) => { const url = new URL(route.request().url()); const base = url.searchParams.get('base') ?? 'RUB'; const quote = url.searchParams.get('quote') ?? 'USD'; const rate = MOCK_RATE[quote] ?? 1; const now = new Date(); route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ quoteId: `fxq_e2e_${base}_${quote}_${now.getTime()}`, base, quote, rate, source: 'e2e-fixture', observedAt: now.toISOString(), expiresAt: new Date(now.getTime() + 5 * 60 * 1000).toISOString(), }), }); }); } async function switchCurrency(page: Page, targetCode: string): Promise { // The page renders more than one language-selector instance (desktop/mobile // variants share the same markup) - scoping to the dropdown that actually // carries the "open" class avoids clicking an option in a hidden duplicate, // which is silently a no-op rather than a failure. const trigger = page.locator('.currency-button:visible').first(); await trigger.click(); const openDropdown = page.locator('.currency-dropdown.open').first(); await expect(openDropdown).toBeVisible(); await openDropdown.locator('.currency-option', { hasText: targetCode }).first().click(); } async function readPrice(locator: import('@playwright/test').Locator): Promise<{ value: number; currency: string }> { const text = (await locator.textContent()) ?? ''; // Matches "1 234.56 USD" / "1234.56 ₽" shapes the price templates render. const match = text.replace(/\s/g, '').match(/([\d.,]+)([A-Z]{3}|\D+)$/); if (!match) { throw new Error(`could not parse price text: "${text}"`); } const value = Number(match[1].replace(/,/g, '')); return { value, currency: match[2] }; }