"""API-level tests for POST /api/funnels/query. The repository dependency is overridden with an in-memory fake, so these run without Postgres. """ from __future__ import annotations from datetime import date, timedelta import pytest from httpx import ASGITransport, AsyncClient from app.api.funnels import get_repository from app.main import app from tests.conftest import FakeRepository def _daily(uvs: list[int | None]) -> dict[str, int | None]: keys = ["start", "show", "detail", "order", "paid"] return {f"uv_{k}": uv for k, uv in zip(keys, uvs)} def _rolling_7d(uvs: list[int | None]) -> dict[str, int | None]: keys = ["start", "show", "detail", "order", "paid"] values: dict[str, int | None] = {f"uv_{k}_7d": uv for k, uv in zip(keys, uvs)} for k in keys: values[f"uv_{k}_30d"] = None return values @pytest.fixture def client_and_repo(): repo = FakeRepository() app.dependency_overrides[get_repository] = lambda: repo transport = ASGITransport(app=app) client = AsyncClient(transport=transport, base_url="http://test") yield client, repo app.dependency_overrides.clear() async def test_health(client_and_repo) -> None: client, _ = client_and_repo async with client: resp = await client.get("/health") assert resp.status_code == 200 assert resp.json() == {"status": "ok"} async def test_query_ready_exact_shape(client_and_repo) -> None: client, repo = client_and_repo repo.set_rolling("20260623", _rolling_7d([10000, 8200, 5100, 2200, 1800])) async with client: resp = await client.post("/api/funnels/query", json={"period": "last_7d"}) assert resp.status_code == 200 data = resp.json() assert data["period"] == "last_7d" assert data["snapshot_dt"] == "20260623" assert data["data_status"] == "ready" assert data["results"][0] == { "step_index": 1, "name": "启动", "event_key": "start", "uv": 10000, "conversion_rate": None, "dropoff_rate": None, } # Step 3: event_key=detail, name=拼团详情. assert data["results"][2]["event_key"] == "detail" assert data["results"][2]["name"] == "拼团详情" assert data["results"][1]["conversion_rate"] == pytest.approx(0.82) assert data["results"][1]["dropoff_rate"] == pytest.approx(0.18) assert [r["event_key"] for r in data["results"]] == [ "start", "show", "detail", "order", "paid", ] assert [r["name"] for r in data["results"]] == [ "启动", "曝光", "拼团详情", "下单", "成功", ] async def test_query_day_latest(client_and_repo) -> None: client, repo = client_and_repo repo.set_daily("20260623", _daily([100, 80, 50, 20, 10])) async with client: resp = await client.post("/api/funnels/query", json={"period": "day"}) assert resp.status_code == 200 data = resp.json() assert data["snapshot_dt"] == "20260623" assert [r["uv"] for r in data["results"]] == [100, 80, 50, 20, 10] async def test_query_day_specific_dt(client_and_repo) -> None: client, repo = client_and_repo yesterday = date.today() - timedelta(days=1) older = yesterday - timedelta(days=2) repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10])) repo.set_daily(older.strftime("%Y%m%d"), _daily([90, 70, 40, 15, 8])) async with client: resp = await client.post( "/api/funnels/query", json={"period": "day", "snapshot_dt": older.isoformat()}, ) assert resp.status_code == 200 data = resp.json() assert data["snapshot_dt"] == older.strftime("%Y%m%d") assert [r["uv"] for r in data["results"]] == [90, 70, 40, 15, 8] async def test_query_day_nonexistent_dt_missing(client_and_repo) -> None: client, repo = client_and_repo yesterday = date.today() - timedelta(days=1) repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10])) far_past = yesterday - timedelta(days=300) async with client: resp = await client.post( "/api/funnels/query", json={"period": "day", "snapshot_dt": far_past.isoformat()}, ) assert resp.status_code == 200 data = resp.json() assert data["data_status"] == "missing" assert data["snapshot_dt"] is None async def test_query_day_future_dt_rejected(client_and_repo) -> None: client, _ = client_and_repo future = (date.today() + timedelta(days=1)).isoformat() async with client: resp = await client.post( "/api/funnels/query", json={"period": "day", "snapshot_dt": future}, ) assert resp.status_code == 422 async def test_query_day_today_dt_rejected(client_and_repo) -> None: client, _ = client_and_repo async with client: resp = await client.post( "/api/funnels/query", json={"period": "day", "snapshot_dt": date.today().isoformat()}, ) assert resp.status_code == 422 async def test_query_rolling_ignores_snapshot_dt(client_and_repo) -> None: client, repo = client_and_repo repo.set_rolling("20260623", _rolling_7d([700, 560, 350, 140, 70])) future = (date.today() + timedelta(days=5)).isoformat() async with client: resp = await client.post( "/api/funnels/query", json={"period": "last_7d", "snapshot_dt": future}, ) # snapshot_dt ignored for rolling -> not a 422, normal ready response. assert resp.status_code == 200 data = resp.json() assert data["data_status"] == "ready" assert data["snapshot_dt"] == "20260623" async def test_query_missing_no_row(client_and_repo) -> None: client, _repo = client_and_repo async with client: resp = await client.post("/api/funnels/query", json={"period": "last_30d"}) assert resp.status_code == 200 data = resp.json() assert data["data_status"] == "missing" assert data["snapshot_dt"] is None assert data["results"] == [] async def test_query_bad_period_rejected(client_and_repo) -> None: client, _ = client_and_repo async with client: resp = await client.post("/api/funnels/query", json={"period": "yesterday"}) assert resp.status_code == 422 async def test_query_missing_period_field_rejected(client_and_repo) -> None: client, _ = client_and_repo async with client: resp = await client.post("/api/funnels/query", json={}) assert resp.status_code == 422