| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- """Tests for the group-buy funnel service: routing, conversion math,
- data_status, snapshot_dt validation, and the fake data source."""
- from __future__ import annotations
- from datetime import date, timedelta
- import pytest
- from app.schemas import DataStatus, Period
- from app.services.funnel import build_results, run_funnel_query
- from app.services.repository import FakeFunnelRepository
- 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(
- uvs_7d: list[int | None] | None = None,
- uvs_30d: list[int | None] | None = None,
- ) -> dict[str, int | None]:
- keys = ["start", "show", "detail", "order", "paid"]
- uvs_7d = uvs_7d if uvs_7d is not None else [None] * 5
- uvs_30d = uvs_30d if uvs_30d is not None else [None] * 5
- values: dict[str, int | None] = {}
- for k, uv in zip(keys, uvs_7d):
- values[f"uv_{k}_7d"] = uv
- for k, uv in zip(keys, uvs_30d):
- values[f"uv_{k}_30d"] = uv
- return values
- # --------------------------------------------------------------------------- #
- # Conversion math #
- # --------------------------------------------------------------------------- #
- def test_build_results_step1_null_rates() -> None:
- results = build_results([10000, 8200, 5100, 2200, 1800])
- assert results[0].step_index == 1
- assert results[0].conversion_rate is None
- assert results[0].dropoff_rate is None
- def test_build_results_descending_funnel() -> None:
- results = build_results([10000, 8200, 5100, 2200, 1800])
- assert [r.uv for r in results] == [10000, 8200, 5100, 2200, 1800]
- assert [r.event_key for r in results] == [
- "start",
- "show",
- "detail",
- "order",
- "paid",
- ]
- # Step 3 display name is "拼团详情" (was "详情").
- assert [r.name for r in results] == ["启动", "曝光", "拼团详情", "下单", "成功"]
- assert results[1].conversion_rate == pytest.approx(0.82)
- assert results[1].dropoff_rate == pytest.approx(0.18)
- assert results[2].conversion_rate == pytest.approx(5100 / 8200)
- assert results[3].conversion_rate == pytest.approx(2200 / 5100)
- def test_build_results_prev_zero_yields_null() -> None:
- results = build_results([0, 5, 3, 1, 0])
- assert results[1].conversion_rate is None
- assert results[1].dropoff_rate is None
- assert results[2].conversion_rate == pytest.approx(0.6)
- # --------------------------------------------------------------------------- #
- # day routing #
- # --------------------------------------------------------------------------- #
- async def test_day_latest_when_no_snapshot_dt(repo: FakeRepository) -> None:
- repo.set_daily("20260620", _daily([1, 1, 1, 1, 1]))
- repo.set_daily("20260623", _daily([100, 80, 50, 20, 10])) # latest
- resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
- assert resp.data_status == DataStatus.ready
- assert resp.snapshot_dt == "20260623"
- assert [r.uv for r in resp.results] == [100, 80, 50, 20, 10]
- async def test_day_specific_dt(repo: FakeRepository) -> None:
- yesterday = date.today() - timedelta(days=1)
- older = yesterday - timedelta(days=3)
- 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]))
- resp = await run_funnel_query(Period.day, repo, snapshot_dt=older)
- assert resp.snapshot_dt == older.strftime("%Y%m%d")
- assert [r.uv for r in resp.results] == [90, 70, 40, 15, 8]
- async def test_day_nonexistent_dt_is_missing(repo: FakeRepository) -> None:
- yesterday = date.today() - timedelta(days=1)
- repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10]))
- # A valid (<= yesterday) date with no row installed.
- missing_day = yesterday - timedelta(days=400)
- resp = await run_funnel_query(Period.day, repo, snapshot_dt=missing_day)
- assert resp.data_status == DataStatus.missing
- assert resp.snapshot_dt is None
- assert resp.results == []
- async def test_day_no_rows_at_all_is_missing(repo: FakeRepository) -> None:
- resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
- assert resp.data_status == DataStatus.missing
- assert resp.snapshot_dt is None
- async def test_day_null_column_is_missing(repo: FakeRepository) -> None:
- repo.set_daily("20260623", _daily([100, 80, None, 20, 10]))
- resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
- assert resp.data_status == DataStatus.missing
- assert resp.snapshot_dt == "20260623"
- async def test_day_future_snapshot_dt_raises(repo: FakeRepository) -> None:
- from app.services.funnel import SnapshotDateError
- with pytest.raises(SnapshotDateError):
- await run_funnel_query(
- Period.day, repo, snapshot_dt=date.today() + timedelta(days=1)
- )
- async def test_day_today_snapshot_dt_raises(repo: FakeRepository) -> None:
- from app.services.funnel import SnapshotDateError
- with pytest.raises(SnapshotDateError):
- await run_funnel_query(Period.day, repo, snapshot_dt=date.today())
- # --------------------------------------------------------------------------- #
- # rolling routing #
- # --------------------------------------------------------------------------- #
- async def test_rolling_7d_picks_7d_columns(repo: FakeRepository) -> None:
- repo.set_rolling(
- "20260623",
- _rolling(
- uvs_7d=[700, 560, 350, 140, 70],
- uvs_30d=[3000, 2400, 1500, 600, 300],
- ),
- )
- resp = await run_funnel_query(Period.last_7d, repo)
- assert resp.data_status == DataStatus.ready
- assert resp.snapshot_dt == "20260623"
- assert [r.uv for r in resp.results] == [700, 560, 350, 140, 70]
- async def test_rolling_30d_picks_30d_columns(repo: FakeRepository) -> None:
- repo.set_rolling(
- "20260623",
- _rolling(
- uvs_7d=[700, 560, 350, 140, 70],
- uvs_30d=[3000, 2400, 1500, 600, 300],
- ),
- )
- resp = await run_funnel_query(Period.last_30d, repo)
- assert [r.uv for r in resp.results] == [3000, 2400, 1500, 600, 300]
- async def test_rolling_ignores_snapshot_dt(repo: FakeRepository) -> None:
- repo.set_rolling("20260623", _rolling(uvs_7d=[700, 560, 350, 140, 70]))
- # A future snapshot_dt would be rejected for period=day, but must be
- # silently ignored for rolling periods.
- resp = await run_funnel_query(
- Period.last_7d, repo, snapshot_dt=date.today() + timedelta(days=5)
- )
- assert resp.data_status == DataStatus.ready
- assert resp.snapshot_dt == "20260623"
- async def test_rolling_missing_when_no_row(repo: FakeRepository) -> None:
- resp = await run_funnel_query(Period.last_7d, repo)
- assert resp.data_status == DataStatus.missing
- assert resp.snapshot_dt is None
- assert resp.results == []
- async def test_rolling_missing_when_columns_null(repo: FakeRepository) -> None:
- # Row present, 30d columns NULL.
- repo.set_rolling("20260623", _rolling(uvs_7d=[700, 560, 350, 140, 70]))
- resp = await run_funnel_query(Period.last_30d, repo)
- assert resp.data_status == DataStatus.missing
- assert resp.snapshot_dt == "20260623"
- # --------------------------------------------------------------------------- #
- # Fake data source #
- # --------------------------------------------------------------------------- #
- async def test_fake_source_ready_for_all_periods() -> None:
- repo = FakeFunnelRepository()
- yesterday = date.today() - timedelta(days=1)
- for period in (Period.day, Period.last_7d, Period.last_30d):
- resp = await run_funnel_query(period, repo)
- assert resp.data_status == DataStatus.ready
- assert resp.snapshot_dt == yesterday.strftime("%Y%m%d")
- assert len(resp.results) == 5
- uvs = [r.uv for r in resp.results]
- assert uvs == sorted(uvs, reverse=True)
- assert resp.results[0].conversion_rate is None
- async def test_fake_source_historical_day_ready() -> None:
- repo = FakeFunnelRepository()
- yesterday = date.today() - timedelta(days=1)
- older = yesterday - timedelta(days=4)
- resp = await run_funnel_query(Period.day, repo, snapshot_dt=older)
- assert resp.data_status == DataStatus.ready
- assert resp.snapshot_dt == older.strftime("%Y%m%d")
- assert len(resp.results) == 5
- async def test_fake_source_history_differs_by_day() -> None:
- repo = FakeFunnelRepository()
- yesterday = date.today() - timedelta(days=1)
- older = yesterday - timedelta(days=5)
- resp_latest = await run_funnel_query(Period.day, repo, snapshot_dt=yesterday)
- resp_older = await run_funnel_query(Period.day, repo, snapshot_dt=older)
- # Older day scaled down, so the top UVs differ.
- assert resp_latest.results[0].uv != resp_older.results[0].uv
- async def test_fake_source_nonexistent_day_missing() -> None:
- repo = FakeFunnelRepository()
- yesterday = date.today() - timedelta(days=1)
- far_past = yesterday - timedelta(days=500)
- resp = await run_funnel_query(Period.day, repo, snapshot_dt=far_past)
- assert resp.data_status == DataStatus.missing
|