diff --git a/tests/test_preferences_currency.py b/tests/test_preferences_currency.py new file mode 100644 index 0000000..ddb207f --- /dev/null +++ b/tests/test_preferences_currency.py @@ -0,0 +1,76 @@ +"""Tests for PATCH /api/preferences display.currency validation.""" +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path): + """TestClient with a patched local DB path. + + api.cloud_session._LOCAL_SNIPE_DB is set at module import time, so we + cannot rely on setting SNIPE_DB before import when other tests have already + triggered the module load. Patch the module-level variable directly so + the session dependency points at our fresh tmp DB for the duration of this + fixture. + """ + db_path = tmp_path / "snipe.db" + # Ensure the DB is initialised so the Store can create its tables. + import api.cloud_session as _cs + from circuitforge_core.db import get_connection, run_migrations + conn = get_connection(db_path) + run_migrations(conn, Path("app/db/migrations")) + conn.close() + + from api.main import app + with patch.object(_cs, "_LOCAL_SNIPE_DB", db_path): + yield TestClient(app, raise_server_exceptions=False) + + +def test_set_display_currency_valid(client): + """Accepted ISO 4217 codes are stored and returned.""" + for code in ("USD", "GBP", "EUR", "CAD", "AUD", "JPY", "CHF", "MXN", "BRL", "INR"): + resp = client.patch("/api/preferences", json={"path": "display.currency", "value": code}) + assert resp.status_code == 200, f"Expected 200 for {code}, got {resp.status_code}: {resp.text}" + data = resp.json() + assert data.get("display", {}).get("currency") == code + + +def test_set_display_currency_normalises_lowercase(client): + """Lowercase code is accepted and normalised to uppercase.""" + resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "eur"}) + assert resp.status_code == 200 + assert resp.json()["display"]["currency"] == "EUR" + + +def test_set_display_currency_unsupported_returns_400(client): + """Unsupported currency code returns 400 with a clear message.""" + resp = client.patch("/api/preferences", json={"path": "display.currency", "value": "XYZ"}) + assert resp.status_code == 400 + detail = resp.json().get("detail", "") + assert "XYZ" in detail + assert "Supported" in detail or "supported" in detail + + +def test_set_display_currency_empty_string_returns_400(client): + """Empty string is not a valid currency code.""" + resp = client.patch("/api/preferences", json={"path": "display.currency", "value": ""}) + assert resp.status_code == 400 + + +def test_set_display_currency_none_returns_400(client): + """None is not a valid currency code.""" + resp = client.patch("/api/preferences", json={"path": "display.currency", "value": None}) + assert resp.status_code == 400 + + +def test_other_preference_paths_unaffected(client): + """Unrelated preference paths still work normally after currency validation added.""" + resp = client.patch("/api/preferences", json={"path": "affiliate.opt_out", "value": True}) + assert resp.status_code == 200 + assert resp.json().get("affiliate", {}).get("opt_out") is True diff --git a/web/src/__tests__/useCurrency.test.ts b/web/src/__tests__/useCurrency.test.ts new file mode 100644 index 0000000..e0d08d9 --- /dev/null +++ b/web/src/__tests__/useCurrency.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Reset module-level cache and fetch mock between tests +beforeEach(async () => { + vi.restoreAllMocks() + // Reset module-level cache so each test starts clean + const mod = await import('../composables/useCurrency') + mod._resetCacheForTest() +}) + +const MOCK_RATES: Record = { + USD: 1, + GBP: 0.79, + EUR: 0.92, + JPY: 151.5, + CAD: 1.36, +} + +function mockFetchSuccess(rates = MOCK_RATES) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ rates }), + })) +} + +function mockFetchFailure() { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) +} + +describe('convertFromUSD', () => { + it('returns the same amount for USD (no conversion)', async () => { + mockFetchSuccess() + const { convertFromUSD } = await import('../composables/useCurrency') + const result = await convertFromUSD(100, 'USD') + expect(result).toBe(100) + // fetch should not be called for USD passthrough + expect(fetch).not.toHaveBeenCalled() + }) + + it('converts USD to GBP using fetched rates', async () => { + mockFetchSuccess() + const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + const result = await convertFromUSD(100, 'GBP') + expect(result).toBeCloseTo(79, 1) + }) + + it('converts USD to JPY using fetched rates', async () => { + mockFetchSuccess() + const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + const result = await convertFromUSD(10, 'JPY') + expect(result).toBeCloseTo(1515, 1) + }) + + it('returns the original amount when rates are unavailable (network failure)', async () => { + mockFetchFailure() + const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + const result = await convertFromUSD(100, 'EUR') + expect(result).toBe(100) + }) + + it('returns the original amount when the currency code is unknown', async () => { + mockFetchSuccess({ USD: 1, EUR: 0.92 }) // no XYZ rate + const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + const result = await convertFromUSD(50, 'XYZ') + expect(result).toBe(50) + }) + + it('only calls fetch once when called concurrently (deduplication)', async () => { + mockFetchSuccess() + const { convertFromUSD, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + await Promise.all([ + convertFromUSD(100, 'GBP'), + convertFromUSD(200, 'EUR'), + convertFromUSD(50, 'CAD'), + ]) + expect((fetch as ReturnType).mock.calls.length).toBe(1) + }) +}) + +describe('formatPrice', () => { + it('formats USD amount with dollar sign', async () => { + mockFetchSuccess() + const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + const result = await formatPrice(99.99, 'USD') + expect(result).toMatch(/^\$99\.99$|^\$100$/) // Intl rounding may vary + expect(result).toContain('$') + }) + + it('formats GBP amount with correct symbol', async () => { + mockFetchSuccess() + const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + const result = await formatPrice(100, 'GBP') + // GBP 79 — expect pound sign or "GBP" prefix + expect(result).toMatch(/[£]|GBP/) + }) + + it('formats JPY without decimal places (Intl rounds to zero decimals)', async () => { + mockFetchSuccess() + const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + const result = await formatPrice(10, 'JPY') + // 10 * 151.5 = 1515 JPY — no decimal places for JPY + expect(result).toMatch(/¥1,515|JPY.*1,515|¥1515/) + }) + + it('falls back gracefully on network failure, showing USD', async () => { + mockFetchFailure() + const { formatPrice, _resetCacheForTest } = await import('../composables/useCurrency') + _resetCacheForTest() + // With failed rates, conversion returns original amount and uses Intl with target currency + // This may throw if Intl doesn't know EUR — but the function should not throw + const result = await formatPrice(50, 'EUR') + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) +}) + +describe('formatPriceUSD', () => { + it('formats a USD amount synchronously', async () => { + const { formatPriceUSD } = await import('../composables/useCurrency') + const result = formatPriceUSD(1234.5) + // Intl output varies by runtime locale data; check structure not exact string + expect(result).toContain('$') + expect(result).toContain('1,234') + }) + + it('formats zero as a USD string', async () => { + const { formatPriceUSD } = await import('../composables/useCurrency') + const result = formatPriceUSD(0) + expect(result).toContain('$') + expect(result).toMatch(/\$0/) + }) +}) diff --git a/web/src/components/ListingCard.vue b/web/src/components/ListingCard.vue index 579e241..5b01348 100644 --- a/web/src/components/ListingCard.vue +++ b/web/src/components/ListingCard.vue @@ -189,15 +189,18 @@