import { 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. */ test.describe('currency switch', () => { 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 pipe is `pure: false` and re-evaluates on the next change-detection // cycle; give the DOM a moment to actually repaint rather than reading // stale text off a signal that hasn't propagated yet. await expect .poll(async () => (await priceLocator.textContent()) ?? '') .not.toContain(before.currency); 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 switchCurrency(page: import('@playwright/test').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] }; }