Finance-Accounts/ar-aging-app/backend/tests/test_fx_service.py

140 lines
5.7 KiB
Python

"""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()