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