test_funnel_service.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. """Tests for the group-buy funnel service: routing, conversion math,
  2. data_status, snapshot_dt validation, and the fake data source."""
  3. from __future__ import annotations
  4. from datetime import date, timedelta
  5. import pytest
  6. from app.schemas import DataStatus, Period
  7. from app.services.funnel import build_results, run_funnel_query
  8. from app.services.repository import FakeFunnelRepository
  9. from tests.conftest import FakeRepository
  10. def _daily(uvs: list[int | None]) -> dict[str, int | None]:
  11. keys = ["start", "show", "detail", "order", "paid"]
  12. return {f"uv_{k}": uv for k, uv in zip(keys, uvs)}
  13. def _rolling(
  14. uvs_7d: list[int | None] | None = None,
  15. uvs_30d: list[int | None] | None = None,
  16. ) -> dict[str, int | None]:
  17. keys = ["start", "show", "detail", "order", "paid"]
  18. uvs_7d = uvs_7d if uvs_7d is not None else [None] * 5
  19. uvs_30d = uvs_30d if uvs_30d is not None else [None] * 5
  20. values: dict[str, int | None] = {}
  21. for k, uv in zip(keys, uvs_7d):
  22. values[f"uv_{k}_7d"] = uv
  23. for k, uv in zip(keys, uvs_30d):
  24. values[f"uv_{k}_30d"] = uv
  25. return values
  26. # --------------------------------------------------------------------------- #
  27. # Conversion math #
  28. # --------------------------------------------------------------------------- #
  29. def test_build_results_step1_null_rates() -> None:
  30. results = build_results([10000, 8200, 5100, 2200, 1800])
  31. assert results[0].step_index == 1
  32. assert results[0].conversion_rate is None
  33. assert results[0].dropoff_rate is None
  34. def test_build_results_descending_funnel() -> None:
  35. results = build_results([10000, 8200, 5100, 2200, 1800])
  36. assert [r.uv for r in results] == [10000, 8200, 5100, 2200, 1800]
  37. assert [r.event_key for r in results] == [
  38. "start",
  39. "show",
  40. "detail",
  41. "order",
  42. "paid",
  43. ]
  44. # Step 3 display name is "拼团详情" (was "详情").
  45. assert [r.name for r in results] == ["启动", "曝光", "拼团详情", "下单", "成功"]
  46. assert results[1].conversion_rate == pytest.approx(0.82)
  47. assert results[1].dropoff_rate == pytest.approx(0.18)
  48. assert results[2].conversion_rate == pytest.approx(5100 / 8200)
  49. assert results[3].conversion_rate == pytest.approx(2200 / 5100)
  50. def test_build_results_prev_zero_yields_null() -> None:
  51. results = build_results([0, 5, 3, 1, 0])
  52. assert results[1].conversion_rate is None
  53. assert results[1].dropoff_rate is None
  54. assert results[2].conversion_rate == pytest.approx(0.6)
  55. # --------------------------------------------------------------------------- #
  56. # day routing #
  57. # --------------------------------------------------------------------------- #
  58. async def test_day_latest_when_no_snapshot_dt(repo: FakeRepository) -> None:
  59. repo.set_daily("20260620", _daily([1, 1, 1, 1, 1]))
  60. repo.set_daily("20260623", _daily([100, 80, 50, 20, 10])) # latest
  61. resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
  62. assert resp.data_status == DataStatus.ready
  63. assert resp.snapshot_dt == "20260623"
  64. assert [r.uv for r in resp.results] == [100, 80, 50, 20, 10]
  65. async def test_day_specific_dt(repo: FakeRepository) -> None:
  66. yesterday = date.today() - timedelta(days=1)
  67. older = yesterday - timedelta(days=3)
  68. repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10]))
  69. repo.set_daily(older.strftime("%Y%m%d"), _daily([90, 70, 40, 15, 8]))
  70. resp = await run_funnel_query(Period.day, repo, snapshot_dt=older)
  71. assert resp.snapshot_dt == older.strftime("%Y%m%d")
  72. assert [r.uv for r in resp.results] == [90, 70, 40, 15, 8]
  73. async def test_day_nonexistent_dt_is_missing(repo: FakeRepository) -> None:
  74. yesterday = date.today() - timedelta(days=1)
  75. repo.set_daily(yesterday.strftime("%Y%m%d"), _daily([100, 80, 50, 20, 10]))
  76. # A valid (<= yesterday) date with no row installed.
  77. missing_day = yesterday - timedelta(days=400)
  78. resp = await run_funnel_query(Period.day, repo, snapshot_dt=missing_day)
  79. assert resp.data_status == DataStatus.missing
  80. assert resp.snapshot_dt is None
  81. assert resp.results == []
  82. async def test_day_no_rows_at_all_is_missing(repo: FakeRepository) -> None:
  83. resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
  84. assert resp.data_status == DataStatus.missing
  85. assert resp.snapshot_dt is None
  86. async def test_day_null_column_is_missing(repo: FakeRepository) -> None:
  87. repo.set_daily("20260623", _daily([100, 80, None, 20, 10]))
  88. resp = await run_funnel_query(Period.day, repo, snapshot_dt=None)
  89. assert resp.data_status == DataStatus.missing
  90. assert resp.snapshot_dt == "20260623"
  91. async def test_day_future_snapshot_dt_raises(repo: FakeRepository) -> None:
  92. from app.services.funnel import SnapshotDateError
  93. with pytest.raises(SnapshotDateError):
  94. await run_funnel_query(
  95. Period.day, repo, snapshot_dt=date.today() + timedelta(days=1)
  96. )
  97. async def test_day_today_snapshot_dt_raises(repo: FakeRepository) -> None:
  98. from app.services.funnel import SnapshotDateError
  99. with pytest.raises(SnapshotDateError):
  100. await run_funnel_query(Period.day, repo, snapshot_dt=date.today())
  101. # --------------------------------------------------------------------------- #
  102. # rolling routing #
  103. # --------------------------------------------------------------------------- #
  104. async def test_rolling_7d_picks_7d_columns(repo: FakeRepository) -> None:
  105. repo.set_rolling(
  106. "20260623",
  107. _rolling(
  108. uvs_7d=[700, 560, 350, 140, 70],
  109. uvs_30d=[3000, 2400, 1500, 600, 300],
  110. ),
  111. )
  112. resp = await run_funnel_query(Period.last_7d, repo)
  113. assert resp.data_status == DataStatus.ready
  114. assert resp.snapshot_dt == "20260623"
  115. assert [r.uv for r in resp.results] == [700, 560, 350, 140, 70]
  116. async def test_rolling_30d_picks_30d_columns(repo: FakeRepository) -> None:
  117. repo.set_rolling(
  118. "20260623",
  119. _rolling(
  120. uvs_7d=[700, 560, 350, 140, 70],
  121. uvs_30d=[3000, 2400, 1500, 600, 300],
  122. ),
  123. )
  124. resp = await run_funnel_query(Period.last_30d, repo)
  125. assert [r.uv for r in resp.results] == [3000, 2400, 1500, 600, 300]
  126. async def test_rolling_ignores_snapshot_dt(repo: FakeRepository) -> None:
  127. repo.set_rolling("20260623", _rolling(uvs_7d=[700, 560, 350, 140, 70]))
  128. # A future snapshot_dt would be rejected for period=day, but must be
  129. # silently ignored for rolling periods.
  130. resp = await run_funnel_query(
  131. Period.last_7d, repo, snapshot_dt=date.today() + timedelta(days=5)
  132. )
  133. assert resp.data_status == DataStatus.ready
  134. assert resp.snapshot_dt == "20260623"
  135. async def test_rolling_missing_when_no_row(repo: FakeRepository) -> None:
  136. resp = await run_funnel_query(Period.last_7d, repo)
  137. assert resp.data_status == DataStatus.missing
  138. assert resp.snapshot_dt is None
  139. assert resp.results == []
  140. async def test_rolling_missing_when_columns_null(repo: FakeRepository) -> None:
  141. # Row present, 30d columns NULL.
  142. repo.set_rolling("20260623", _rolling(uvs_7d=[700, 560, 350, 140, 70]))
  143. resp = await run_funnel_query(Period.last_30d, repo)
  144. assert resp.data_status == DataStatus.missing
  145. assert resp.snapshot_dt == "20260623"
  146. # --------------------------------------------------------------------------- #
  147. # Fake data source #
  148. # --------------------------------------------------------------------------- #
  149. async def test_fake_source_ready_for_all_periods() -> None:
  150. repo = FakeFunnelRepository()
  151. yesterday = date.today() - timedelta(days=1)
  152. for period in (Period.day, Period.last_7d, Period.last_30d):
  153. resp = await run_funnel_query(period, repo)
  154. assert resp.data_status == DataStatus.ready
  155. assert resp.snapshot_dt == yesterday.strftime("%Y%m%d")
  156. assert len(resp.results) == 5
  157. uvs = [r.uv for r in resp.results]
  158. assert uvs == sorted(uvs, reverse=True)
  159. assert resp.results[0].conversion_rate is None
  160. async def test_fake_source_historical_day_ready() -> None:
  161. repo = FakeFunnelRepository()
  162. yesterday = date.today() - timedelta(days=1)
  163. older = yesterday - timedelta(days=4)
  164. resp = await run_funnel_query(Period.day, repo, snapshot_dt=older)
  165. assert resp.data_status == DataStatus.ready
  166. assert resp.snapshot_dt == older.strftime("%Y%m%d")
  167. assert len(resp.results) == 5
  168. async def test_fake_source_history_differs_by_day() -> None:
  169. repo = FakeFunnelRepository()
  170. yesterday = date.today() - timedelta(days=1)
  171. older = yesterday - timedelta(days=5)
  172. resp_latest = await run_funnel_query(Period.day, repo, snapshot_dt=yesterday)
  173. resp_older = await run_funnel_query(Period.day, repo, snapshot_dt=older)
  174. # Older day scaled down, so the top UVs differ.
  175. assert resp_latest.results[0].uv != resp_older.results[0].uv
  176. async def test_fake_source_nonexistent_day_missing() -> None:
  177. repo = FakeFunnelRepository()
  178. yesterday = date.today() - timedelta(days=1)
  179. far_past = yesterday - timedelta(days=500)
  180. resp = await run_funnel_query(Period.day, repo, snapshot_dt=far_past)
  181. assert resp.data_status == DataStatus.missing