restockiq / eval /test_api.py
RV302001's picture
UI v2 (self-explanatory) + API tests + caching + lifespan
0b9787b
Raw
History Blame Contribute Delete
4.87 kB
"""API contract tests — run against the real precomputed artifacts.
Covers every route: response shape, parameter validation, 404s, cross-endpoint
consistency (the recommendation endpoint must agree with the raw forecast it is
derived from), and action-list invariants.
"""
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.main import app
KNOWN = ("CA_1", "FOODS_3_090")
@pytest.fixture(scope="module")
def client():
# context manager triggers the startup event that loads the parquet artifacts
with TestClient(app) as c:
yield c
class TestHealth:
def test_health(self, client):
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
class TestSkus:
def test_shape(self, client):
r = client.get("/api/skus").json()
assert len(r["stores"]) == 10
assert len(r["items"]) == 3049
assert KNOWN[0] in r["stores"] and KNOWN[1] in r["items"]
class TestForecast:
def test_arrays_aligned(self, client):
r = client.get(f"/api/forecast/{KNOWN[0]}/{KNOWN[1]}")
assert r.status_code == 200
b = r.json()
n = len(b["dates"])
assert n == 28
assert len(b["p10"]) == len(b["p50"]) == len(b["p90"]) == len(b["actual"]) == n
assert len(b["history_dates"]) == len(b["history_actual"]) == 56
def test_quantiles_ordered(self, client):
b = client.get(f"/api/forecast/{KNOWN[0]}/{KNOWN[1]}").json()
for lo, mid, hi in zip(b["p10"], b["p50"], b["p90"]):
assert lo <= mid <= hi
def test_unknown_series_404(self, client):
assert client.get("/api/forecast/XX_9/NOPE").status_code == 404
class TestRecommendation:
def test_defaults(self, client):
b = client.get(f"/api/recommendation/{KNOWN[0]}/{KNOWN[1]}").json()
assert b["service_level"] == 0.95
assert b["lead_time_days"] == 7
assert b["suggested_order_qty"] >= 0
def test_inventory_reduces_order(self, client):
base = f"/api/recommendation/{KNOWN[0]}/{KNOWN[1]}"
q0 = client.get(base, params={"current_inventory": 0}).json()
q100 = client.get(base, params={"current_inventory": 100}).json()
assert q100["suggested_order_qty"] <= q0["suggested_order_qty"]
# reorder point is independent of what's on the shelf
assert q100["reorder_point"] == q0["reorder_point"]
def test_higher_service_level_bigger_cushion(self, client):
base = f"/api/recommendation/{KNOWN[0]}/{KNOWN[1]}"
lo = client.get(base, params={"service_level": 0.90}).json()
hi = client.get(base, params={"service_level": 0.99}).json()
assert hi["safety_stock"] > lo["safety_stock"]
def test_agrees_with_forecast(self, client):
"""Recommendation must be derivable from the served forecast (no drift
between endpoints)."""
fc = client.get(f"/api/forecast/{KNOWN[0]}/{KNOWN[1]}").json()
rec = client.get(f"/api/recommendation/{KNOWN[0]}/{KNOWN[1]}").json()
expected_avg = sum(fc["p50"][:7]) / 7
assert rec["avg_daily_demand"] == pytest.approx(expected_avg, rel=1e-3)
@pytest.mark.parametrize("bad", [{"service_level": 0}, {"service_level": 1.5},
{"lead_time_days": 0}, {"current_inventory": -5}])
def test_validation_422(self, client, bad):
r = client.get(f"/api/recommendation/{KNOWN[0]}/{KNOWN[1]}", params=bad)
assert r.status_code == 422
class TestActionList:
def test_counts_sum_to_total(self, client):
b = client.get(f"/api/action_list/{KNOWN[0]}").json()
assert b["total_items"] == 3049
assert sum(b["counts"].values()) == b["total_items"]
def test_sorted_by_order_size(self, client):
items = client.get(f"/api/action_list/{KNOWN[0]}?limit=50").json()["items"]
qtys = [i["suggested_order_qty"] for i in items]
assert qtys == sorted(qtys, reverse=True)
def test_zero_inventory_means_everything_ordered(self, client):
b = client.get(f"/api/action_list/{KNOWN[0]}?current_inventory=0").json()
assert b["counts"].get("order_now", 0) + b["counts"].get("order_soon", 0) >= 3000
def test_cached_response_identical(self, client):
url = f"/api/action_list/{KNOWN[0]}?current_inventory=42"
assert client.get(url).json() == client.get(url).json()
class TestBacktest:
def test_summary_shape(self, client):
b = client.get("/api/backtest_summary").json()
for policy in ("naive", "recommended"):
p = b["policies"][policy]
assert 0 <= p["stockout_rate_pct"] <= 100
assert p["avg_holding_units"] >= 0
assert len(b["top_skus"]) == 20