test_roi_agency_delivery.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import json
  2. import logging
  3. import os
  4. import tempfile
  5. import unittest
  6. from contextlib import contextmanager
  7. from datetime import datetime
  8. from pathlib import Path
  9. from unittest.mock import Mock, patch
  10. import httpx
  11. from roi_control.agency_delivery import (
  12. AgencyWebhookNotifier,
  13. agency_route_key,
  14. publish_agency_reports,
  15. )
  16. from roi_control.config import AgencyWebhookConfig, RoiConfig
  17. from roi_control.feishu import RoiFeishuPublisher
  18. from roi_control.service import (
  19. _build_internal_reports,
  20. _internal_source_revision,
  21. _internal_webhook_config,
  22. _redact_agency_webhooks,
  23. )
  24. WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/test-route"
  25. class FakePublisher:
  26. def __init__(self):
  27. self.paths = []
  28. def upload_workbook(self, path):
  29. self.paths.append(path)
  30. return {"url": "https://example.test/sheet", "sheet_token": "sheet-1"}
  31. class FakeNotifier:
  32. def __init__(self):
  33. self.calls = []
  34. def send(self, webhook_url, **values):
  35. self.calls.append((webhook_url, values))
  36. return "0"
  37. class AgencyDeliveryTest(unittest.TestCase):
  38. def test_formal_and_internal_schedule_defaults(self):
  39. with patch.dict(os.environ, {}, clear=True):
  40. config = RoiConfig.from_env()
  41. self.assertEqual((config.report_hour, config.report_minute), (9, 0))
  42. self.assertEqual(
  43. (config.internal_test_hour, config.internal_test_minute),
  44. (15, 30),
  45. )
  46. self.assertTrue(config.internal_test_dedup_enabled)
  47. def test_internal_test_dedup_switch_controls_run_revision(self):
  48. current = datetime(2026, 8, 4, 14, 0, 1, 123456)
  49. dedup = RoiConfig(internal_test_dedup_enabled=True)
  50. no_dedup = RoiConfig(internal_test_dedup_enabled=False)
  51. self.assertEqual(_internal_source_revision(dedup, current), "internal_test")
  52. self.assertEqual(
  53. _internal_source_revision(no_dedup, current),
  54. "it_140001123456",
  55. )
  56. def test_internal_report_set_contains_only_automation_report(self):
  57. reports = _build_internal_reports(
  58. agency_reports=[
  59. {
  60. "agency_name": "小程序-代投-棱镜",
  61. "report_version": "v7",
  62. "report": "mirror.xlsx",
  63. "creative_rows": 4,
  64. "ad_rows": 2,
  65. },
  66. {
  67. "agency_name": "自动化投放",
  68. "report_version": "v7",
  69. "report": "auto.xlsx",
  70. "creative_rows": 1,
  71. "ad_rows": 1,
  72. },
  73. ],
  74. )
  75. self.assertEqual(
  76. [row["agency_name"] for row in reports],
  77. ["自动化投放"],
  78. )
  79. self.assertTrue(all(str(row["title"]).startswith("内部测试_") for row in reports))
  80. config = _internal_webhook_config(reports, WEBHOOK)
  81. self.assertEqual(set(config.webhooks.values()), {WEBHOOK})
  82. self.assertEqual(set(config.webhooks), {"自动化投放"})
  83. def test_internal_report_set_rejects_missing_automation_report(self):
  84. with self.assertRaisesRegex(RuntimeError, "自动化投放"):
  85. _build_internal_reports(
  86. agency_reports=[
  87. {
  88. "agency_name": "小程序-代投-棱镜",
  89. "report_version": "v7",
  90. "report": "mirror.xlsx",
  91. "creative_rows": 4,
  92. "ad_rows": 2,
  93. }
  94. ]
  95. )
  96. def test_config_snapshot_never_contains_webhook_secret(self):
  97. env = {
  98. "ROI_AGENCY_WEBHOOK_ENABLED": "1",
  99. "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({"棱镜": WEBHOOK}),
  100. }
  101. with patch.dict(os.environ, env, clear=False):
  102. config = AgencyWebhookConfig.from_env()
  103. snapshot = config.snapshot()
  104. self.assertEqual(snapshot["agencies"], ["棱镜"])
  105. self.assertNotIn(WEBHOOK, json.dumps(snapshot, ensure_ascii=False))
  106. def test_disabled_config_does_not_parse_secret_json(self):
  107. with patch.dict(
  108. os.environ,
  109. {
  110. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  111. "ROI_AGENCY_WEBHOOKS_JSON": "not-json",
  112. },
  113. clear=False,
  114. ):
  115. config = AgencyWebhookConfig.from_env()
  116. self.assertFalse(config.enabled)
  117. self.assertEqual(config.webhooks, {})
  118. def test_route_key_is_exact_and_only_removes_known_prefix(self):
  119. self.assertEqual(agency_route_key("小程序-代投-棱镜"), "棱镜")
  120. self.assertEqual(agency_route_key("小程序 -代投-贝湉"), "贝湉")
  121. self.assertEqual(agency_route_key("自动化投放"), "自动化投放")
  122. def test_service_error_redaction_removes_webhook_secret(self):
  123. config = AgencyWebhookConfig(enabled=True, webhooks={"棱镜": WEBHOOK})
  124. error = RuntimeError(f"POST {WEBHOOK} failed")
  125. redacted = _redact_agency_webhooks(error, config)
  126. self.assertNotIn(WEBHOOK, redacted)
  127. self.assertIn("<redacted>", redacted)
  128. @patch("roi_control.agency_delivery.update_agency_delivery")
  129. @patch("roi_control.agency_delivery.upsert_agency_delivery")
  130. def test_only_configured_agency_uploads_and_sends(
  131. self,
  132. upsert,
  133. update,
  134. ):
  135. upsert.side_effect = [
  136. {"id": 1, "status": "PENDING"},
  137. {"id": 2, "status": "PENDING"},
  138. ]
  139. publisher = FakePublisher()
  140. notifier = FakeNotifier()
  141. with tempfile.TemporaryDirectory() as directory:
  142. route_path = Path(directory) / "20260804_棱镜_调控建议.xlsx"
  143. route_path.write_bytes(b"xlsx")
  144. skipped_path = Path(directory) / "20260804_自动化投放_调控建议.xlsx"
  145. skipped_path.write_bytes(b"xlsx")
  146. reports = [
  147. {
  148. "agency_name": "小程序-代投-棱镜",
  149. "report_version": "roi_agency_advice_v7",
  150. "report": str(route_path),
  151. "creative_rows": 10,
  152. "ad_rows": 3,
  153. },
  154. {
  155. "agency_name": "自动化投放",
  156. "report_version": "roi_agency_advice_v7",
  157. "report": str(skipped_path),
  158. "creative_rows": 1,
  159. "ad_rows": 1,
  160. },
  161. ]
  162. outcomes = publish_agency_reports(
  163. run_id="run-1",
  164. reports=reports,
  165. config=AgencyWebhookConfig(enabled=True, webhooks={"棱镜": WEBHOOK}),
  166. publisher=publisher,
  167. notifier=notifier,
  168. )
  169. self.assertEqual([row["status"] for row in outcomes], ["SENT", "SKIPPED"])
  170. self.assertEqual(len(publisher.paths), 1)
  171. self.assertEqual(len(notifier.calls), 1)
  172. self.assertEqual(notifier.calls[0][0], WEBHOOK)
  173. self.assertEqual(notifier.calls[0][1]["creative_rows"], 10)
  174. statuses = [call.kwargs["status"] for call in update.call_args_list]
  175. self.assertEqual(statuses, ["UPLOADED", "SENT", "SKIPPED"])
  176. self.assertNotIn(WEBHOOK, str(upsert.call_args_list))
  177. @patch("roi_control.agency_delivery.update_agency_delivery")
  178. @patch("roi_control.agency_delivery.upsert_agency_delivery")
  179. def test_uploaded_sheet_is_reused_when_notification_retries(
  180. self,
  181. upsert,
  182. update,
  183. ):
  184. upsert.return_value = {
  185. "id": 3,
  186. "status": "FAILED",
  187. "sheet_url": "https://example.test/existing-sheet",
  188. "sheet_token": "existing-token",
  189. }
  190. publisher = FakePublisher()
  191. notifier = FakeNotifier()
  192. with tempfile.TemporaryDirectory() as directory:
  193. path = Path(directory) / "20260804_棱镜_调控建议.xlsx"
  194. path.write_bytes(b"xlsx")
  195. outcomes = publish_agency_reports(
  196. run_id="run-2",
  197. reports=[
  198. {
  199. "agency_name": "小程序-代投-棱镜",
  200. "report_version": "roi_agency_advice_v7",
  201. "report": str(path),
  202. "creative_rows": 10,
  203. "ad_rows": 3,
  204. }
  205. ],
  206. config=AgencyWebhookConfig(
  207. enabled=True,
  208. webhooks={"棱镜": WEBHOOK},
  209. ),
  210. publisher=publisher,
  211. notifier=notifier,
  212. )
  213. self.assertEqual(outcomes[0]["status"], "SENT")
  214. self.assertEqual(publisher.paths, [])
  215. self.assertEqual(
  216. notifier.calls[0][1]["sheet_url"],
  217. "https://example.test/existing-sheet",
  218. )
  219. self.assertEqual(update.call_args.kwargs["status"], "SENT")
  220. def test_webhook_card_contains_sheet_button(self):
  221. captured = {}
  222. def handler(request):
  223. captured.update(json.loads(request.content))
  224. return httpx.Response(200, json={"StatusCode": 0, "StatusMessage": "success"})
  225. notifier = AgencyWebhookNotifier()
  226. notifier.client.close()
  227. notifier.client = httpx.Client(transport=httpx.MockTransport(handler))
  228. try:
  229. code = notifier.send(
  230. WEBHOOK,
  231. title="20260804_棱镜_调控建议",
  232. sheet_url="https://example.test/sheet",
  233. creative_rows=10,
  234. ad_rows=3,
  235. )
  236. finally:
  237. notifier.close()
  238. self.assertEqual(code, "0")
  239. self.assertEqual(captured["msg_type"], "interactive")
  240. button = captured["card"]["elements"][1]["actions"][0]
  241. self.assertEqual(button["url"], "https://example.test/sheet")
  242. def test_main_chat_publish_still_uploads_once_and_notifies_each_chat(self):
  243. publisher = RoiFeishuPublisher.__new__(RoiFeishuPublisher)
  244. publisher.chat_ids = ["chat-1", "chat-2"]
  245. publisher.upload_workbook = Mock(
  246. return_value={
  247. "url": "https://example.test/main-sheet",
  248. "sheet_token": "main-token",
  249. }
  250. )
  251. publisher._token = Mock(return_value="tenant-token")
  252. publisher._send_card = Mock(side_effect=["message-1", "message-2"])
  253. result = publisher.publish(
  254. Path("main.xlsx"),
  255. run_id="run-main",
  256. batch_name="batch-main",
  257. summary="summary",
  258. requires_approval=True,
  259. )
  260. publisher.upload_workbook.assert_called_once_with(Path("main.xlsx"))
  261. self.assertEqual(publisher._send_card.call_count, 2)
  262. self.assertEqual(result["message_id"], "message-1")
  263. self.assertEqual(result["sheet_token"], "main-token")
  264. def test_service_failure_alert_uses_explicit_chat_and_red_card(self):
  265. publisher = RoiFeishuPublisher.__new__(RoiFeishuPublisher)
  266. publisher.client = Mock()
  267. publisher._token = Mock(return_value="tenant-token")
  268. publisher._json = Mock(return_value={"data": {"message_id": "alert-1"}})
  269. message_id = publisher.send_service_alert(
  270. title="日级ROI任务失败",
  271. content="任务执行失败",
  272. chat_id="chat-internal",
  273. )
  274. self.assertEqual(message_id, "alert-1")
  275. request = publisher.client.post.call_args
  276. self.assertEqual(request.kwargs["json"]["receive_id"], "chat-internal")
  277. self.assertEqual(request.kwargs["json"]["msg_type"], "interactive")
  278. card = json.loads(request.kwargs["json"]["content"])
  279. self.assertEqual(card["header"]["template"], "red")
  280. self.assertEqual(card["header"]["title"]["content"], "日级ROI任务失败")
  281. def test_service_failure_alert_falls_back_to_operator_chat(self):
  282. publisher = RoiFeishuPublisher.__new__(RoiFeishuPublisher)
  283. publisher.client = Mock()
  284. publisher._token = Mock(return_value="tenant-token")
  285. publisher._json = Mock(return_value={"data": {"message_id": "alert-2"}})
  286. with patch.dict(
  287. os.environ,
  288. {
  289. "ROI_FAILURE_FEISHU_CHAT_ID": "",
  290. "FEISHU_OPERATOR_CHAT_ID": "chat-operator",
  291. },
  292. clear=False,
  293. ):
  294. message_id = publisher.send_service_alert(
  295. title="日级ROI任务失败",
  296. content="任务执行失败",
  297. )
  298. self.assertEqual(message_id, "alert-2")
  299. request = publisher.client.post.call_args
  300. self.assertEqual(request.kwargs["json"]["receive_id"], "chat-operator")
  301. def test_alert_only_publisher_does_not_require_report_chat(self):
  302. with patch.dict(
  303. os.environ,
  304. {
  305. "FEISHU_APP_ID": "app-id",
  306. "FEISHU_APP_SECRET": "app-secret",
  307. "ROI_FEISHU_CHAT_ID": "",
  308. "FEISHU_AD_PROJECT_CHAT_ID": "",
  309. "RTC_COMMAND_CHAT_ID": "",
  310. "FEISHU_OPERATOR_CHAT_ID": "",
  311. "ROI_FAILURE_FEISHU_CHAT_ID": "chat-failure",
  312. },
  313. clear=True,
  314. ):
  315. publisher = RoiFeishuPublisher(require_chat_ids=False)
  316. try:
  317. self.assertEqual(publisher.chat_ids, [])
  318. finally:
  319. publisher.close()
  320. def test_formal_publisher_still_requires_report_chat(self):
  321. with patch.dict(
  322. os.environ,
  323. {
  324. "FEISHU_APP_ID": "app-id",
  325. "FEISHU_APP_SECRET": "app-secret",
  326. },
  327. clear=True,
  328. ):
  329. with self.assertRaisesRegex(RuntimeError, "ROI_FEISHU_CHAT_ID"):
  330. RoiFeishuPublisher()
  331. def test_daily_roi_nonzero_exit_triggers_failure_alert(self):
  332. previous_logging_disable = logging.root.manager.disable
  333. logging.disable(logging.CRITICAL)
  334. try:
  335. import run_daily_service as daily_service
  336. finally:
  337. logging.disable(previous_logging_disable)
  338. @contextmanager
  339. def acquired_lock():
  340. yield True
  341. internal_args = ["--internal-test"]
  342. with (
  343. patch.object(
  344. daily_service,
  345. "advisory_lock",
  346. return_value=acquired_lock(),
  347. ),
  348. patch.object(
  349. daily_service.subprocess,
  350. "run",
  351. return_value=Mock(returncode=7),
  352. ),
  353. patch.object(daily_service, "_notify_roi_failure") as notify,
  354. ):
  355. with self.assertRaisesRegex(RuntimeError, "exited with code 7"):
  356. daily_service._run_script(
  357. "run_daily_roi.py",
  358. "test-roi-lock",
  359. internal_args,
  360. )
  361. notify.assert_called_once_with(
  362. extra_args=internal_args,
  363. error="process exited with code 7",
  364. )
  365. def test_disabled_internal_job_does_not_parse_agency_routes_at_startup(self):
  366. import run_daily_service as daily_service
  367. scheduler = Mock()
  368. scheduler.get_jobs.return_value = []
  369. with (
  370. patch.object(
  371. daily_service.RoiConfig,
  372. "from_env",
  373. return_value=RoiConfig(
  374. daily_enabled=False,
  375. internal_test_enabled=False,
  376. ),
  377. ),
  378. patch.object(
  379. daily_service.AgencyWebhookConfig,
  380. "from_env",
  381. ) as agency_from_env,
  382. patch.object(daily_service, "initialize_schema"),
  383. patch.object(daily_service, "_env_flag", return_value=False),
  384. patch.object(
  385. daily_service,
  386. "BlockingScheduler",
  387. return_value=scheduler,
  388. ),
  389. ):
  390. daily_service.main()
  391. agency_from_env.assert_not_called()
  392. scheduler.start.assert_called_once_with()
  393. if __name__ == "__main__":
  394. unittest.main()