140 lines
5.7 KiB
Python
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()
|