test_daily_roi_polling.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import json
  2. import sys
  3. import unittest
  4. from datetime import date, datetime
  5. from pathlib import Path
  6. from unittest.mock import Mock, patch
  7. HERE = Path(__file__).resolve().parent
  8. sys.path.insert(0, str(HERE))
  9. import run_daily_service # noqa: E402
  10. from roi_control import repository # noqa: E402
  11. from roi_control.config import RoiConfig # noqa: E402
  12. from roi_control.data_source import SHANGHAI, SourceDataNotReadyError # noqa: E402
  13. class RoiPollingConfigTest(unittest.TestCase):
  14. def test_default_polling_configuration_is_30_minutes_until_1800(self):
  15. config = RoiConfig()
  16. config.validate()
  17. self.assertEqual(30, config.daily_poll_interval_minutes)
  18. self.assertEqual((18, 0), (
  19. config.daily_poll_cutoff_hour,
  20. config.daily_poll_cutoff_minute,
  21. ))
  22. self.assertEqual("0,30", run_daily_service._roi_poll_minutes(config))
  23. def test_poll_interval_must_divide_one_hour(self):
  24. with self.assertRaisesRegex(ValueError, "positive divisor of 60"):
  25. RoiConfig(daily_poll_interval_minutes=25).validate()
  26. def test_cutoff_must_be_later_than_report_time(self):
  27. with self.assertRaisesRegex(ValueError, "cutoff must be later"):
  28. RoiConfig(
  29. report_hour=18,
  30. report_minute=0,
  31. daily_poll_cutoff_hour=18,
  32. daily_poll_cutoff_minute=0,
  33. ).validate()
  34. class DailyRoiPollingTest(unittest.TestCase):
  35. def setUp(self):
  36. self.config = RoiConfig(
  37. daily_enabled=True,
  38. report_hour=9,
  39. report_minute=0,
  40. daily_poll_interval_minutes=30,
  41. daily_poll_cutoff_hour=18,
  42. daily_poll_cutoff_minute=0,
  43. )
  44. self.active_time = datetime(2026, 8, 14, 9, 30, tzinfo=SHANGHAI)
  45. self.cutoff_time = datetime(2026, 8, 14, 18, 0, tzinfo=SHANGHAI)
  46. def test_existing_formal_run_skips_source_check_and_report(self):
  47. existing = {"run_id": "roi-existing", "status": "PENDING_APPROVAL"}
  48. with (
  49. patch.object(
  50. run_daily_service,
  51. "load_formal_run_for_end_date",
  52. return_value=existing,
  53. ),
  54. patch.object(run_daily_service, "validate_source_ready") as validate,
  55. patch.object(run_daily_service, "run_daily_roi") as run,
  56. ):
  57. result = run_daily_service.poll_daily_roi(
  58. self.config,
  59. now=self.active_time,
  60. )
  61. self.assertEqual("ALREADY_RAN", result)
  62. validate.assert_not_called()
  63. run.assert_not_called()
  64. def test_cutoff_skips_existing_formal_run_without_alert(self):
  65. existing = {"run_id": "roi-existing", "status": "COMPLETED"}
  66. with (
  67. patch.object(
  68. run_daily_service,
  69. "load_formal_run_for_end_date",
  70. return_value=existing,
  71. ),
  72. patch.object(run_daily_service, "validate_source_ready") as validate,
  73. patch.object(run_daily_service, "_notify_roi_source_cutoff") as notify,
  74. patch.object(run_daily_service, "run_daily_roi") as run,
  75. ):
  76. result = run_daily_service.finalize_daily_roi_at_cutoff(
  77. self.config,
  78. now=self.cutoff_time,
  79. )
  80. self.assertEqual("ALREADY_RAN", result)
  81. validate.assert_not_called()
  82. notify.assert_not_called()
  83. run.assert_not_called()
  84. def test_missing_source_waits_without_failure_alert(self):
  85. error = SourceDataNotReadyError("required_dt=20260813")
  86. with (
  87. patch.object(
  88. run_daily_service,
  89. "load_formal_run_for_end_date",
  90. return_value=None,
  91. ),
  92. patch.object(run_daily_service, "ODPSClient"),
  93. patch.object(
  94. run_daily_service,
  95. "validate_source_ready",
  96. side_effect=error,
  97. ),
  98. patch.object(run_daily_service, "_notify_roi_source_cutoff") as notify,
  99. patch.object(run_daily_service, "run_daily_roi") as run,
  100. ):
  101. result = run_daily_service.poll_daily_roi(
  102. self.config,
  103. now=self.active_time,
  104. )
  105. self.assertEqual("WAITING_SOURCE", result)
  106. notify.assert_not_called()
  107. run.assert_not_called()
  108. def test_ready_source_triggers_formal_run_once(self):
  109. with (
  110. patch.object(
  111. run_daily_service,
  112. "load_formal_run_for_end_date",
  113. return_value=None,
  114. ),
  115. patch.object(run_daily_service, "ODPSClient"),
  116. patch.object(
  117. run_daily_service,
  118. "validate_source_ready",
  119. return_value={"row_count": 10, "self_rows": 6, "gzh_rows": 4},
  120. ) as validate,
  121. patch.object(run_daily_service, "run_daily_roi") as run,
  122. ):
  123. result = run_daily_service.poll_daily_roi(
  124. self.config,
  125. now=self.active_time,
  126. )
  127. self.assertEqual("TRIGGERED", result)
  128. validate.assert_called_once_with(unittest.mock.ANY, "20260813")
  129. run.assert_called_once_with()
  130. def test_cutoff_performs_final_check_and_alerts_when_still_missing(self):
  131. error = SourceDataNotReadyError("required_dt=20260813")
  132. with (
  133. patch.object(
  134. run_daily_service,
  135. "load_formal_run_for_end_date",
  136. return_value=None,
  137. ),
  138. patch.object(run_daily_service, "ODPSClient"),
  139. patch.object(
  140. run_daily_service,
  141. "validate_source_ready",
  142. side_effect=error,
  143. ),
  144. patch.object(run_daily_service, "_notify_roi_source_cutoff") as notify,
  145. patch.object(run_daily_service, "run_daily_roi") as run,
  146. ):
  147. result = run_daily_service.finalize_daily_roi_at_cutoff(
  148. self.config,
  149. now=self.cutoff_time,
  150. )
  151. self.assertEqual("CUTOFF_NOT_READY", result)
  152. notify.assert_called_once_with(
  153. target_date="20260813",
  154. cutoff_time="18:00",
  155. error="required_dt=20260813",
  156. )
  157. run.assert_not_called()
  158. def test_cutoff_runs_when_source_becomes_ready(self):
  159. with (
  160. patch.object(
  161. run_daily_service,
  162. "load_formal_run_for_end_date",
  163. return_value=None,
  164. ),
  165. patch.object(run_daily_service, "ODPSClient"),
  166. patch.object(
  167. run_daily_service,
  168. "validate_source_ready",
  169. return_value={"row_count": 10, "self_rows": 6, "gzh_rows": 4},
  170. ),
  171. patch.object(run_daily_service, "run_daily_roi") as run,
  172. ):
  173. result = run_daily_service.finalize_daily_roi_at_cutoff(
  174. self.config,
  175. now=self.cutoff_time,
  176. )
  177. self.assertEqual("TRIGGERED", result)
  178. run.assert_called_once_with()
  179. def test_poll_does_nothing_outside_active_window(self):
  180. for now in (
  181. datetime(2026, 8, 14, 8, 59, tzinfo=SHANGHAI),
  182. self.cutoff_time,
  183. datetime(2026, 8, 14, 18, 30, tzinfo=SHANGHAI),
  184. ):
  185. with self.subTest(now=now), patch.object(
  186. run_daily_service,
  187. "load_formal_run_for_end_date",
  188. ) as load_run:
  189. result = run_daily_service.poll_daily_roi(self.config, now=now)
  190. self.assertEqual("OUTSIDE_WINDOW", result)
  191. load_run.assert_not_called()
  192. def test_scheduler_registers_poll_and_cutoff_jobs(self):
  193. scheduler = Mock()
  194. scheduler.get_jobs.return_value = []
  195. with (
  196. patch.object(
  197. run_daily_service.RoiConfig,
  198. "from_env",
  199. return_value=self.config,
  200. ),
  201. patch.object(run_daily_service, "validate_region_dictionary"),
  202. patch.object(run_daily_service, "initialize_schema"),
  203. patch.object(run_daily_service, "_env_flag", return_value=False),
  204. patch.object(
  205. run_daily_service,
  206. "BlockingScheduler",
  207. return_value=scheduler,
  208. ),
  209. ):
  210. run_daily_service.main([])
  211. jobs = {call.kwargs["id"]: call for call in scheduler.add_job.call_args_list}
  212. self.assertIn("daily_roi", jobs)
  213. self.assertIn("daily_roi_cutoff", jobs)
  214. self.assertIs(jobs["daily_roi"].args[0], run_daily_service.poll_daily_roi)
  215. self.assertIs(
  216. jobs["daily_roi_cutoff"].args[0],
  217. run_daily_service.finalize_daily_roi_at_cutoff,
  218. )
  219. class FormalRunLookupTest(unittest.TestCase):
  220. def test_internal_test_is_ignored_when_formal_run_exists(self):
  221. cursor = Mock()
  222. cursor.__enter__ = Mock(return_value=cursor)
  223. cursor.__exit__ = Mock(return_value=False)
  224. cursor.fetchall.return_value = [
  225. {
  226. "run_id": "roi-internal_internal_test",
  227. "config_json": json.dumps({"internal_test": True}),
  228. },
  229. {
  230. "run_id": "roi-formal",
  231. "config_json": json.dumps({"internal_test": False}),
  232. },
  233. ]
  234. connection = Mock()
  235. connection.cursor.return_value = cursor
  236. with patch.object(repository, "connect", return_value=connection):
  237. result = repository.load_formal_run_for_end_date(date(2026, 8, 13))
  238. self.assertEqual("roi-formal", result["run_id"])
  239. connection.close.assert_called_once_with()
  240. if __name__ == "__main__":
  241. unittest.main()