- Backend: validate display.currency against 10 supported ISO 4217 codes (USD, GBP, EUR, CAD, AUD, JPY, CHF, MXN, BRL, INR); return 400 on unsupported code with a clear message listing accepted values - Frontend: useCurrency composable fetches rates from open.er-api.com with 1-hour module-level cache and in-flight deduplication; falls back to USD display on network failure - Preferences store: adds display.currency with localStorage fallback for anonymous users and localStorage-to-DB migration for newly logged-in users - ListingCard: price and market price now convert from USD using live rates, showing USD synchronously while rates load then updating reactively - Settings UI: currency selector dropdown in Appearance section using theme-aware CSS classes; available to all users (anon via localStorage, logged-in via DB preference) - Tests: 6 Python tests for the PATCH /api/preferences currency endpoint (including ordering-safe fixture using patch.object on _LOCAL_SNIPE_DB); 14 Vitest tests for convertFromUSD, formatPrice, and formatPriceUSD
76 lines
3 KiB
Python
76 lines
3 KiB
Python
"""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
|