284 lines
12 KiB
Python
284 lines
12 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, "urls": []}
|
|
|
|
def fake_get(url: str) -> dict:
|
|
calls["n"] += 1
|
|
calls["urls"].append(url)
|
|
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()
|
|
|
|
|
|
def test_daily_fetch_defaults_to_the_transaction_span(fake_frankfurter):
|
|
"""No explicit range → the provider is asked for the span the files actually cover
|
|
(earliest dated transaction through month-end), so pre-month rows convert at their
|
|
own date's rate too."""
|
|
init_db()
|
|
with TestClient(app) as c:
|
|
sid = _session_with_fx(c, "fx daily span", "2029-06-30")
|
|
db = SessionLocal()
|
|
try:
|
|
db.add(models.Transaction(session_id=sid, marketplace="Germany",
|
|
posted_date=dt.date(2029, 5, 20), total=100.0))
|
|
db.add(models.Transaction(session_id=sid, marketplace="Germany",
|
|
posted_date=dt.date(2029, 6, 12), total=50.0))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
r = c.post(f"/api/sessions/{sid}/fx/fetch-daily", json={})
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["date_from"] == "2029-05-20" # earliest dated transaction
|
|
assert body["date_to"] == "2029-06-30" # through month-end
|
|
series_url = next(u for u in fake_frankfurter["urls"] if ".." in u)
|
|
assert "2029-05-20..2029-06-30" in series_url
|
|
|
|
|
|
def test_auto_seed_preserves_manual_daily_overrides(fake_frankfurter):
|
|
"""The automatic post-processing seed refreshes provider rows but never clobbers a
|
|
rate a person typed; only the explicit Fetch button replaces manual overrides."""
|
|
init_db()
|
|
with TestClient(app) as c:
|
|
sid = _session_with_fx(c, "fx auto manual", "2029-06-30")
|
|
db = SessionLocal()
|
|
try:
|
|
db.add(models.FxRateDaily(session_id=sid, marketplace="Germany",
|
|
rate_date=dt.date(2029, 6, 29), rate=2.0,
|
|
source="manual"))
|
|
db.commit()
|
|
s = db.get(models.Session, sid)
|
|
out = fx_service.auto_seed_daily_fx(db, s)
|
|
assert "error" not in out
|
|
rows = {r.rate_date: r for r in db.query(models.FxRateDaily).filter_by(
|
|
session_id=sid, marketplace="Germany")}
|
|
assert rows[dt.date(2029, 6, 29)].rate == 2.0 # manual kept
|
|
assert rows[dt.date(2029, 6, 29)].source == "manual"
|
|
assert rows[dt.date(2029, 6, 28)].rate == pytest.approx(1 / 0.86, abs=1e-6)
|
|
assert rows[dt.date(2029, 6, 28)].source == "frankfurter"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_auto_seed_is_advisory_when_the_provider_is_down(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 auto down", "2029-06-30")
|
|
db = SessionLocal()
|
|
try:
|
|
s = db.get(models.Session, sid)
|
|
out = fx_service.auto_seed_daily_fx(db, s) # must not raise
|
|
assert "provider down" in out["error"]
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_auto_seed_skips_usd_only_closings(monkeypatch):
|
|
"""A USD-only close has nothing to fetch — no HTTP request, no warning."""
|
|
init_db()
|
|
|
|
def no_network(url: str) -> dict:
|
|
raise AssertionError(f"unexpected FX fetch for a USD-only closing: {url}")
|
|
|
|
monkeypatch.setattr(fx_service, "_http_get_json", no_network)
|
|
with TestClient(app) as c:
|
|
sid = c.post("/api/sessions", json={"name": "fx usd only",
|
|
"month_end_date": "2029-06-30",
|
|
"allow_duplicate": True}).json()["id"]
|
|
db = SessionLocal()
|
|
try:
|
|
db.add(models.FxRate(session_id=sid, marketplace="USA", currency="USD",
|
|
rate=1.0, source="default"))
|
|
db.commit()
|
|
s = db.get(models.Session, sid)
|
|
out = fx_service.auto_seed_daily_fx(db, s)
|
|
assert out.get("skipped")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_processing_auto_seeds_daily_rates_from_the_api(tmp_path, monkeypatch):
|
|
"""End-to-end: processing fetches the provider's daily fixings for the file's span,
|
|
and the daily FX table converts each date at the rate effective on it — the exact
|
|
fixing when one exists, the previous banking day's fixing otherwise."""
|
|
init_db()
|
|
from app.services import jobs
|
|
from tests.test_multimarket import _make_dutch_file
|
|
monkeypatch.setattr(jobs, "FX_AUTO_DAILY", True)
|
|
|
|
urls: list[str] = []
|
|
|
|
def fake_get(url: str) -> dict:
|
|
urls.append(url)
|
|
assert ".." in url, "auto-seed must use a single series request"
|
|
return {"base": "USD", "rates": {
|
|
"2026-01-02": {"EUR": 0.8},
|
|
"2026-01-15": {"EUR": 0.9},
|
|
}}
|
|
|
|
monkeypatch.setattr(fx_service, "_http_get_json", fake_get)
|
|
|
|
with TestClient(app) as c:
|
|
sid = c.post("/api/sessions", json={
|
|
"name": "auto daily fx", "reporting_month": "2026-01",
|
|
"month_end_date": "2026-01-31", "clearing_lag_days": 2,
|
|
"allow_duplicate": True}).json()["id"]
|
|
path = tmp_path / "Netherlands Amazon Transactions January, 2026.xlsx"
|
|
_make_dutch_file(str(path))
|
|
with open(path, "rb") as fh:
|
|
assert c.post(f"/api/sessions/{sid}/files",
|
|
files={"files": (path.name, fh)}).status_code == 200
|
|
assert c.post(f"/api/sessions/{sid}/process").status_code == 200
|
|
assert c.get(f"/api/sessions/{sid}/status").json()["status"] in (
|
|
"processed", "blocked") # blocked = unconfirmed C5, fine
|
|
|
|
# One series request, widened to the whole reporting month.
|
|
assert any("2026-01-01..2026-01-31" in u for u in urls)
|
|
|
|
fxd = c.get(f"/api/sessions/{sid}/fx-daily?marketplace=Netherlands").json()
|
|
by_date = {r["date"]: r for r in fxd["rows"]}
|
|
# Jan 2 converts at Jan 2's fixing (1 USD = 0.80 EUR → 1.25 USD per EUR)…
|
|
assert by_date["2026-01-02"]["rate"] == pytest.approx(1.25, abs=1e-6)
|
|
assert by_date["2026-01-02"]["source"] == "frankfurter"
|
|
# …Jan 6 has no fixing, so the previous banking day's rate is in effect…
|
|
assert by_date["2026-01-06"]["rate"] == pytest.approx(1.25, abs=1e-6)
|
|
assert "2026-01-02" in by_date["2026-01-06"]["source"]
|
|
# …and Jan 20 carries Jan 15's fixing.
|
|
assert by_date["2026-01-20"]["rate"] == pytest.approx(1 / 0.9, abs=1e-6)
|
|
assert "2026-01-15" in by_date["2026-01-20"]["source"]
|