"""FX rate service: orientation (the #1 risk), confirmation withdrawal, caching, failure. The provider is mocked — no network in tests. Frankfurter with base=USD returns LOCAL per USD; the app stores USD per LOCAL (usd = local * rate), so the service must invert.""" from __future__ import annotations import datetime as dt import pytest from fastapi.testclient import TestClient from app.api.main import app from app.db import models from app.db.database import SessionLocal, init_db from app.services import fx_service @pytest.fixture() def fake_frankfurter(monkeypatch): """Replace the HTTP layer with a fixture: 1 USD = 0.85 EUR on 2029-06-29 (Friday).""" calls = {"n": 0} def fake_get(url: str) -> dict: calls["n"] += 1 if ".." in url: # time-series request return {"base": "USD", "rates": { "2029-06-28": {"EUR": 0.86}, "2029-06-29": {"EUR": 0.85}, }} return {"base": "USD", "date": "2029-06-29", "rates": {"EUR": 0.85}} monkeypatch.setattr(fx_service, "_http_get_json", fake_get) return calls def _session_with_fx(c, name: str, month_end: str) -> int: sid = c.post("/api/sessions", json={"name": name, "month_end_date": month_end, "allow_duplicate": True}).json()["id"] db = SessionLocal() try: db.add(models.FxRate(session_id=sid, marketplace="Germany", currency="EUR", rate=1.185665, source="default (Jan-26 workbook)", confirmed_by="Old Confirmer", confirmed_month="2026-01")) db.add(models.FxRate(session_id=sid, marketplace="USA", currency="USD", rate=1.0, source="default")) db.commit() finally: db.close() return sid def test_fetch_inverts_to_usd_per_local_and_clears_confirmation(fake_frankfurter): init_db() with TestClient(app) as c: sid = _session_with_fx(c, "fx orient", "2029-06-30") r = c.post(f"/api/sessions/{sid}/fx/fetch") assert r.status_code == 200, r.text body = r.json() de = next(u for u in body["updated"] if u["marketplace"] == "Germany") # 1 USD = 0.85 EUR -> 1 EUR = 1/0.85 USD. The inverse (0.85) would mis-state # every EUR receivable — this assertion pins the orientation. assert de["rate"] == pytest.approx(1 / 0.85, abs=1e-6) usa = next(u for u in body["updated"] if u["marketplace"] == "USA") assert usa["rate"] == 1.0 assert "frankfurter" in body["source"] assert "2029-06-29" in body["source"] # the provider's banking day rows = c.get(f"/api/sessions/{sid}/fx").json() de_row = next(x for x in rows if x["marketplace"] == "Germany") assert "frankfurter" in de_row["source"] db = SessionLocal() try: fx = db.query(models.FxRate).filter_by(session_id=sid, marketplace="Germany").first() # A fetched rate is a suggestion: the old confirmation no longer applies (C5). assert fx.confirmed_by == "" and fx.confirmed_month == "" finally: db.close() def test_second_fetch_is_served_from_cache(fake_frankfurter): init_db() with TestClient(app) as c: sid = _session_with_fx(c, "fx cache", "2029-06-30") assert c.post(f"/api/sessions/{sid}/fx/fetch").status_code == 200 n_after_first = fake_frankfurter["n"] r = c.post(f"/api/sessions/{sid}/fx/fetch") assert r.status_code == 200 assert fake_frankfurter["n"] == n_after_first # no second HTTP call assert "(cached)" in r.json()["source"] def test_provider_failure_is_a_502_never_a_silent_default(monkeypatch): init_db() def boom(url: str) -> dict: raise fx_service.FxProviderError("provider down") monkeypatch.setattr(fx_service, "_http_get_json", boom) with TestClient(app) as c: sid = _session_with_fx(c, "fx down", "2029-08-31") r = c.post(f"/api/sessions/{sid}/fx/fetch") assert r.status_code == 502 assert "manually" in r.json()["detail"] # The stored rate is untouched — not overwritten with anything. de = next(x for x in c.get(f"/api/sessions/{sid}/fx").json() if x["marketplace"] == "Germany") assert de["rate"] == 1.185665 def test_fetch_without_processing_explains_the_precondition(): init_db() with TestClient(app) as c: sid = c.post("/api/sessions", json={"name": "fx bare", "month_end_date": "2029-09-30", "allow_duplicate": True}).json()["id"] r = c.post(f"/api/sessions/{sid}/fx/fetch") assert r.status_code == 502 assert "process" in r.json()["detail"].lower() def test_daily_fetch_fills_fx_rates_daily(fake_frankfurter): init_db() with TestClient(app) as c: sid = _session_with_fx(c, "fx daily", "2029-06-30") r = c.post(f"/api/sessions/{sid}/fx/fetch-daily", json={"date_from": "2029-06-28", "date_to": "2029-06-29"}) assert r.status_code == 200, r.text assert r.json()["saved"] == 2 # two banking days, EUR only db = SessionLocal() try: rows = db.query(models.FxRateDaily).filter_by( session_id=sid, marketplace="Germany").all() by_date = {row.rate_date: row for row in rows} assert by_date[dt.date(2029, 6, 29)].rate == pytest.approx(1 / 0.85, abs=1e-6) assert by_date[dt.date(2029, 6, 28)].rate == pytest.approx(1 / 0.86, abs=1e-6) assert all(row.source == "frankfurter" for row in rows) finally: db.close()