"""API-level tests for POST /api/funnels/trend (按日折线). Repository dependency overridden with the in-memory fake — no 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 _dt(days_ago: int) -> str: return (date.today() - timedelta(days=days_ago)).strftime("%Y%m%d") @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_trend_default_window_returns_ascending_points(client_and_repo) -> None: client, repo = client_and_repo # 3 active days ending yesterday. for d in (3, 2, 1): repo.set_daily(_dt(d), _daily([1000 - d, 800 - d, 500 - d, 200 - d, 100 - d])) async with client: resp = await client.post("/api/funnels/trend", json={}) assert resp.status_code == 200 body = resp.json() assert body["data_status"] == "ready" dts = [p["dt"] for p in body["points"]] assert dts == [_dt(3), _dt(2), _dt(1)] # ascending assert body["start_dt"] == _dt(3) assert body["end_dt"] == _dt(1) # each point carries the fixed 5-step funnel assert all(len(p["results"]) == 5 for p in body["points"]) async def test_trend_trims_leading_zero_days(client_and_repo) -> None: client, repo = client_and_repo repo.set_daily(_dt(5), _daily([0, 0, 0, 0, 0])) # pre-launch repo.set_daily(_dt(4), _daily([0, 0, 0, 0, 0])) # pre-launch repo.set_daily(_dt(3), _daily([900, 700, 400, 150, 80])) repo.set_daily(_dt(2), _daily([950, 720, 410, 160, 90])) repo.set_daily(_dt(1), _daily([0, 0, 0, 0, 0])) # mid/trailing zero — kept async with client: resp = await client.post("/api/funnels/trend", json={}) body = resp.json() dts = [p["dt"] for p in body["points"]] assert dts == [_dt(3), _dt(2), _dt(1)] # leading zeros dropped, trailing zero kept assert body["start_dt"] == _dt(3) async def test_trend_conversion_matches_funnel_口径(client_and_repo) -> None: client, repo = client_and_repo repo.set_daily(_dt(1), _daily([1000, 800, 500, 200, 100])) async with client: resp = await client.post("/api/funnels/trend", json={}) rows = resp.json()["points"][0]["results"] assert rows[0]["conversion_rate"] is None # step 1 assert rows[1]["conversion_rate"] == pytest.approx(800 / 1000) assert rows[3]["conversion_rate"] == pytest.approx(200 / 500) async def test_trend_custom_range_subsets(client_and_repo) -> None: client, repo = client_and_repo for d in (4, 3, 2, 1): repo.set_daily(_dt(d), _daily([1000, 800, 500, 200, 100])) start = (date.today() - timedelta(days=3)).isoformat() end = (date.today() - timedelta(days=2)).isoformat() async with client: resp = await client.post( "/api/funnels/trend", json={"start_dt": start, "end_dt": end} ) dts = [p["dt"] for p in resp.json()["points"]] assert dts == [_dt(3), _dt(2)] async def test_trend_no_data_is_missing(client_and_repo) -> None: client, _ = client_and_repo async with client: resp = await client.post("/api/funnels/trend", json={}) body = resp.json() assert body["data_status"] == "missing" assert body["points"] == [] assert body["start_dt"] is None and body["end_dt"] is None async def test_trend_future_end_rejected(client_and_repo) -> None: client, _ = client_and_repo today = date.today().isoformat() async with client: resp = await client.post("/api/funnels/trend", json={"end_dt": today}) assert resp.status_code == 422 async def test_trend_inverted_range_rejected(client_and_repo) -> None: client, _ = client_and_repo start = (date.today() - timedelta(days=1)).isoformat() end = (date.today() - timedelta(days=5)).isoformat() async with client: resp = await client.post( "/api/funnels/trend", json={"start_dt": start, "end_dt": end} ) assert resp.status_code == 422