Some checks failed
Architecture Governance / architecture (push) Has been cancelled
F10-F12 of the frontend backlog. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.
Removed the failure mode §5 of that contract exists to close: rates were
typed once by an admin into Settings, persisted to localStorage, seeded from
a hardcoded DEFAULT_RATES table (USD: 0.011, AMD: 4.3) that never updated and
drifted from market. Nothing recorded which rate produced a displayed price
or when.
- currency-rates.service.ts now fetches through FX_QUOTE_GATEWAY instead of
reading admin-typed/localStorage numbers. Stays
synchronous at the call site (getRate/convert) -
rewriting every consuming template to `| async`
is a separate, larger change (F13, not this
commit). Before a quote has loaded for a pair,
getRate returns 1 rather than a fabricated
market rate; isRateReady() lets a caller that
cares distinguish the two. ensureFreshQuote()
added for checkout to await before charging,
per contract §3.2's stale-quote policy.
- language.service.ts setCurrency() now triggers a quote fetch instead
of just flipping the display signal.
- cart.component.ts openPaymentPopup() awaits ensureFreshQuote()
before computing the charged amount.
- admin-settings-page.* currency-rate editor deleted (F11) - card,
component state, and the three orphaned i18n
keys it was the only consumer of.
Two real bugs surfaced fixing this, neither cosmetic:
1. fx-quote-local.gateway.ts had CurrencyRatesService.convert() as its rate
source. That is now circular - CurrencyRatesService depends on
FX_QUOTE_GATEWAY, and under useMockData:true this gateway IS
FX_QUOTE_GATEWAY. Would have recursed the moment mock FX data was
exercised. Fixed by giving the local gateway its own static mock table -
the correct home for those numbers now: explicitly labelled dev/mock data,
only wired in behind useMockData, never presented as a live rate.
2. currency-convert.pipe.ts memoized its result on (amount, from, to) alone.
That was already latently wrong - rates could change via the old
setRate() without the pipe re-evaluating for an already-rendered price -
but never surfaced because rates never changed mid-session in practice.
Async quote loading made it concrete and reproducible: a price rendered
before its quote arrived stayed wrong forever, because none of the three
cached inputs ever changed again on their own. Fixed with a ratesVersion
counter on the service, bumped on every quote arrival, included in the
pipe's cache key.
Both found and fixed via the E2E suite (docs from the prior commit) actually
exercising the real code path: GET /api/v2/pricing/fx-quote intercepted with
a contract-shaped response rather than flipping the whole app into mock mode,
so the test runs the real FxQuoteApiGateway, not a stand-in for it.
Verified: 3/3 E2E green, 115/115 unit tests green, arch:check clean,
production build succeeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
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<string, number> = { 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<void> {
|
|
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<void> {
|
|
// 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] };
|
|
}
|