snipe/tests/test_preferences_currency.py
pyr0ball dca3c3f50b feat(prefs): display.currency preference with live exchange rate conversion
- 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
2026-04-20 11:02:59 -07:00

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