test_creative_performance_cleanup.py 78 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151
  1. import json
  2. import unittest
  3. import os
  4. from contextlib import contextmanager
  5. from datetime import date, datetime
  6. from pathlib import Path
  7. from tempfile import TemporaryDirectory
  8. from unittest.mock import Mock, patch
  9. from zoneinfo import ZoneInfo
  10. class CreativePerformanceRuleTests(unittest.TestCase):
  11. def setUp(self):
  12. from tools.creative_rejection_cleanup import performance_cleanup_config
  13. self.config = {
  14. "new_min_age_days": 5,
  15. "new_max_age_days": 7,
  16. "new_impressions_threshold": 100,
  17. "old_daily_impressions_threshold": 100.0,
  18. "window_days": 7,
  19. }
  20. # Keep an import-time reference so failures in the public config helper
  21. # are caught by this focused test module too.
  22. self.assertTrue(callable(performance_cleanup_config))
  23. @staticmethod
  24. def _creative(**overrides):
  25. creative = {
  26. "configured_status": "AD_STATUS_NORMAL",
  27. "creative_set_approval_status": (
  28. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  29. ),
  30. }
  31. creative.update(overrides)
  32. return creative
  33. @staticmethod
  34. def _ad(**overrides):
  35. ad = {"configured_status": "AD_STATUS_NORMAL"}
  36. ad.update(overrides)
  37. return ad
  38. def _action(self, *, age_days, impressions, cost_fen=0, **overrides):
  39. from tools.creative_rejection_cleanup import (
  40. determine_performance_cleanup_action,
  41. )
  42. as_of = date(2026, 8, 19)
  43. created = datetime(
  44. 2026,
  45. 8,
  46. 19 - age_days,
  47. 10,
  48. tzinfo=ZoneInfo("Asia/Shanghai"),
  49. )
  50. return determine_performance_cleanup_action(
  51. overrides.pop("creative", self._creative()),
  52. overrides.pop("ad", self._ad()),
  53. creative_created_at=created,
  54. as_of_date=as_of,
  55. impressions=impressions,
  56. cost_fen=cost_fen,
  57. metric_start_date=date(2026, 8, 12),
  58. metric_end_date=date(2026, 8, 18),
  59. config=self.config,
  60. **overrides,
  61. )
  62. def test_new_creative_requires_strict_age_and_impression_boundaries(self):
  63. from tools.creative_rejection_cleanup import PERFORMANCE_NEW_RULE
  64. self.assertIsNone(self._action(age_days=5, impressions=0))
  65. self.assertEqual(
  66. self._action(age_days=6, impressions=99)["cleanup_rule_type"],
  67. PERFORMANCE_NEW_RULE,
  68. )
  69. self.assertEqual(
  70. self._action(age_days=7, impressions=0)["cleanup_rule_type"],
  71. PERFORMANCE_NEW_RULE,
  72. )
  73. self.assertIsNone(self._action(age_days=6, impressions=100))
  74. self.assertIsNone(self._action(age_days=6, impressions=0, cost_fen=1))
  75. self.assertIsNone(
  76. self._action(
  77. age_days=6,
  78. impressions=0,
  79. current_day_cost_fen=1,
  80. )
  81. )
  82. def test_old_creative_matches_both_requested_strict_exposure_branches(self):
  83. from tools.creative_rejection_cleanup import (
  84. PERFORMANCE_OLD_HIGH_RULE,
  85. PERFORMANCE_OLD_LOW_RULE,
  86. )
  87. self.assertEqual(
  88. self._action(age_days=8, impressions=699)["cleanup_rule_type"],
  89. PERFORMANCE_OLD_LOW_RULE,
  90. )
  91. self.assertEqual(
  92. self._action(age_days=8, impressions=701)["cleanup_rule_type"],
  93. PERFORMANCE_OLD_HIGH_RULE,
  94. )
  95. self.assertIsNone(self._action(age_days=8, impressions=700))
  96. self.assertIsNone(self._action(age_days=8, impressions=701, cost_fen=1))
  97. self.assertIsNone(
  98. self._action(
  99. age_days=8,
  100. impressions=701,
  101. current_day_cost_fen=1,
  102. )
  103. )
  104. def test_stopped_or_unapproved_assets_are_never_performance_deleted(self):
  105. self.assertIsNone(
  106. self._action(
  107. age_days=8,
  108. impressions=0,
  109. creative=self._creative(configured_status="AD_STATUS_SUSPEND"),
  110. )
  111. )
  112. self.assertIsNone(
  113. self._action(
  114. age_days=8,
  115. impressions=0,
  116. ad=self._ad(configured_status="AD_STATUS_SUSPEND"),
  117. )
  118. )
  119. self.assertIsNone(
  120. self._action(
  121. age_days=8,
  122. impressions=0,
  123. ad=self._ad(begin_date="2026-08-20"),
  124. )
  125. )
  126. self.assertIsNone(
  127. self._action(
  128. age_days=8,
  129. impressions=0,
  130. creative=self._creative(
  131. creative_set_approval_status=(
  132. "CREATIVE_SET_APPROVAL_STATUS_PENDING"
  133. )
  134. ),
  135. )
  136. )
  137. class CreativePerformanceMetricApiTests(unittest.TestCase):
  138. def test_tencent_metric_query_sums_impressions_and_cost(self):
  139. from tencent_client import TencentClient
  140. response = Mock()
  141. response.raise_for_status.return_value = None
  142. response.json.return_value = {
  143. "code": 0,
  144. "data": {
  145. "list": [
  146. {
  147. "dynamic_creative_id": 3,
  148. "view_count": 40,
  149. "cost": 0,
  150. },
  151. {
  152. "dynamic_creative_id": 3,
  153. "view_count": 59,
  154. "cost": 0,
  155. },
  156. ],
  157. "page_info": {"total_page": 1},
  158. },
  159. }
  160. client = TencentClient()
  161. client.session.get = Mock(return_value=response)
  162. client._common_params = Mock(return_value={"access_token": "token"})
  163. metrics = client.get_dynamic_creative_metrics(
  164. 1,
  165. [3, 4],
  166. date(2026, 8, 12),
  167. date(2026, 8, 18),
  168. )
  169. self.assertEqual(
  170. metrics,
  171. {
  172. 3: {"impressions": 99, "cost_fen": 0},
  173. 4: {"impressions": 0, "cost_fen": 0},
  174. },
  175. )
  176. def test_tencent_ad_metric_query_sums_three_days_and_keeps_zero_rows(self):
  177. from tencent_client import TencentClient
  178. response = Mock()
  179. response.raise_for_status.return_value = None
  180. response.json.return_value = {
  181. "code": 0,
  182. "data": {
  183. "list": [
  184. {"adgroup_id": 2, "cost": 0, "view_count": 10},
  185. {"adgroup_id": 2, "cost": 0, "view_count": 20},
  186. ],
  187. "page_info": {"total_page": 1},
  188. },
  189. }
  190. client = TencentClient()
  191. client.session.get = Mock(return_value=response)
  192. client._common_params = Mock(return_value={"access_token": "token"})
  193. metrics = client.get_ad_metrics(
  194. 1,
  195. [2, 4],
  196. date(2026, 8, 16),
  197. date(2026, 8, 18),
  198. )
  199. self.assertEqual(metrics[2]["cost_fen"], 0)
  200. self.assertEqual(metrics[2]["impressions"], 30)
  201. self.assertEqual(metrics[4]["cost_fen"], 0)
  202. class CreativePerformanceScopeTests(unittest.TestCase):
  203. def test_account_metadata_uses_latest_active_account_row(self):
  204. import pandas as pd
  205. from tools.creative_rejection_cleanup import (
  206. fetch_tencent_account_metadata,
  207. )
  208. client = Mock()
  209. client.execute_sql.return_value = pd.DataFrame([
  210. {
  211. "account_id": "1",
  212. "account_name": " 账户一 ",
  213. "agent_name": " 代理商甲 ",
  214. },
  215. {
  216. "account_id": "2",
  217. "account_name": float("nan"),
  218. "agent_name": float("nan"),
  219. },
  220. ])
  221. metadata = fetch_tencent_account_metadata(client)
  222. sql = " ".join(client.execute_sql.call_args.args[0].split())
  223. self.assertIn("FROM loghubods.ad_put_tencent_account", sql)
  224. self.assertIn("PARTITION BY account_id", sql)
  225. self.assertLess(sql.index("ROW_NUMBER() OVER"), sql.index("WHERE rn=1"))
  226. self.assertIn("WHERE rn=1 AND is_delete=0", sql)
  227. self.assertEqual(
  228. metadata[1],
  229. {"account_name": "账户一", "agent_name": "代理商甲"},
  230. )
  231. self.assertEqual(
  232. metadata[2],
  233. {"account_name": "", "agent_name": ""},
  234. )
  235. def test_inventory_filters_is_delete_after_latest_row_selection(self):
  236. import pandas as pd
  237. from tools.creative_rejection_cleanup import (
  238. fetch_active_creative_inventory,
  239. )
  240. client = Mock()
  241. client.execute_sql.return_value = pd.DataFrame([{
  242. "account_id": 1,
  243. "ad_id": 2,
  244. "creative_id": 3,
  245. "creative_name": "创意三",
  246. "creative_status": "NORMAL",
  247. "create_time": "2026-08-13 10:00:00",
  248. "update_time": "2026-08-18 10:00:00",
  249. }])
  250. inventory = fetch_active_creative_inventory(client)
  251. sql = " ".join(client.execute_sql.call_args.args[0].split())
  252. self.assertEqual(sql.count("is_delete=0"), 1)
  253. self.assertIn(
  254. "ROW_NUMBER() OVER ( PARTITION BY account_id, creative_id",
  255. sql,
  256. )
  257. self.assertLess(sql.index("ROW_NUMBER() OVER"), sql.index("WHERE rn=1"))
  258. self.assertEqual(inventory[0]["account_id"], 1)
  259. self.assertEqual(inventory[0]["adgroup_id"], 2)
  260. self.assertEqual(inventory[0]["creative_id"], 3)
  261. self.assertEqual(inventory[0]["creative_status"], "NORMAL")
  262. self.assertIsNotNone(inventory[0]["create_time"])
  263. def test_scan_uses_only_odps_creatives_and_queries_today_separately(self):
  264. from tools import creative_rejection_cleanup as cleanup
  265. tencent = Mock()
  266. tencent.get_dynamic_creatives.return_value = [
  267. {
  268. "dynamic_creative_id": 3,
  269. "adgroup_id": 2,
  270. "configured_status": "AD_STATUS_NORMAL",
  271. "creative_set_approval_status": (
  272. cleanup.CREATIVE_NORMAL_STATUS
  273. ),
  274. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  275. },
  276. {
  277. "dynamic_creative_id": 9,
  278. "adgroup_id": 9,
  279. "configured_status": "AD_STATUS_NORMAL",
  280. "creative_set_approval_status": (
  281. cleanup.CREATIVE_NORMAL_STATUS
  282. ),
  283. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  284. },
  285. ]
  286. tencent.get_ads.return_value = [
  287. {
  288. "adgroup_id": 2,
  289. "configured_status": "AD_STATUS_NORMAL",
  290. "created_time": "2026-08-10 00:00:00",
  291. },
  292. {
  293. "adgroup_id": 9,
  294. "configured_status": "AD_STATUS_NORMAL",
  295. "created_time": "2026-08-10 00:00:00",
  296. },
  297. ]
  298. tencent.get_dynamic_creative_metrics.side_effect = [
  299. {3: {"impressions": 99, "cost_fen": 0}},
  300. {3: {"impressions": 1, "cost_fen": 5}},
  301. ]
  302. tencent.get_ad_metrics.side_effect = [
  303. {2: {"impressions": 0, "cost_fen": 0}},
  304. {2: {"impressions": 1, "cost_fen": 7}},
  305. ]
  306. sources = {
  307. 3: {
  308. "account_id": 1,
  309. "adgroup_id": 2,
  310. "creative_id": 3,
  311. "create_time": "2026-08-13 10:00:00",
  312. },
  313. 4: {
  314. "account_id": 1,
  315. "adgroup_id": 4,
  316. "creative_id": 4,
  317. "create_time": "2026-08-13 10:00:00",
  318. },
  319. }
  320. result = cleanup._scan_one_account(
  321. {"account_id": 1},
  322. tencent=tencent,
  323. review_fetcher=Mock(),
  324. spend_start_date=date(2026, 8, 16),
  325. spend_end_date=date(2026, 8, 18),
  326. review_enabled=False,
  327. performance_enabled=True,
  328. performance_source_creatives=sources,
  329. performance_start_date=date(2026, 8, 12),
  330. performance_end_date=date(2026, 8, 18),
  331. current_day_date=date(2026, 8, 19),
  332. ad_cleanup_enabled=True,
  333. ad_metric_start_date=date(2026, 8, 16),
  334. ad_metric_end_date=date(2026, 8, 18),
  335. )
  336. self.assertEqual(result[7][3]["current_day_cost_fen"], 5)
  337. self.assertEqual(result[7][3]["impressions"], 100)
  338. self.assertEqual(result[9][2]["current_day_cost_fen"], 7)
  339. self.assertEqual(result[12]["missing_tencent_creatives"], 1)
  340. creative_calls = tencent.get_dynamic_creative_metrics.call_args_list
  341. self.assertEqual(creative_calls[0].args[1], [3])
  342. self.assertEqual(
  343. creative_calls[0].args[2:],
  344. (date(2026, 8, 13), date(2026, 8, 18)),
  345. )
  346. self.assertEqual(
  347. creative_calls[1].args[2:],
  348. (date(2026, 8, 19), date(2026, 8, 19)),
  349. )
  350. ad_calls = tencent.get_ad_metrics.call_args_list
  351. self.assertEqual(ad_calls[0].args[1], [2])
  352. self.assertEqual(
  353. ad_calls[1].args[2:],
  354. (date(2026, 8, 19), date(2026, 8, 19)),
  355. )
  356. def test_review_failure_does_not_block_independent_ad_metric_scan(self):
  357. from tools import creative_rejection_cleanup as cleanup
  358. tencent = Mock()
  359. tencent.get_dynamic_creatives.return_value = [{
  360. "dynamic_creative_id": 3,
  361. "adgroup_id": 2,
  362. "configured_status": "AD_STATUS_NORMAL",
  363. "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
  364. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  365. }]
  366. tencent.get_ads.return_value = [{
  367. "adgroup_id": 2,
  368. "configured_status": "AD_STATUS_NORMAL",
  369. "created_time": "2026-08-10 00:00:00",
  370. }]
  371. tencent.get_ad_metrics.return_value = {
  372. 2: {"impressions": 0, "cost_fen": 0}
  373. }
  374. result = cleanup._scan_one_account(
  375. {"account_id": 1},
  376. tencent=tencent,
  377. review_fetcher=Mock(side_effect=RuntimeError("review unavailable")),
  378. spend_start_date=date(2026, 8, 16),
  379. spend_end_date=date(2026, 8, 18),
  380. review_enabled=True,
  381. performance_enabled=True,
  382. performance_source_creatives={
  383. 3: {
  384. "account_id": 1,
  385. "adgroup_id": 2,
  386. "creative_id": 3,
  387. "create_time": "2026-08-13 10:00:00",
  388. }
  389. },
  390. performance_start_date=date(2026, 8, 12),
  391. performance_end_date=date(2026, 8, 18),
  392. performance_as_of=datetime(
  393. 2026,
  394. 8,
  395. 19,
  396. 11,
  397. tzinfo=ZoneInfo("Asia/Shanghai"),
  398. ),
  399. current_day_date=date(2026, 8, 19),
  400. ad_cleanup_enabled=True,
  401. ad_metric_start_date=date(2026, 8, 16),
  402. ad_metric_end_date=date(2026, 8, 18),
  403. )
  404. self.assertEqual(result[11], "review unavailable")
  405. self.assertIsNone(result[10])
  406. self.assertIn(2, result[9])
  407. tencent.get_dynamic_creative_metrics.assert_not_called()
  408. def test_retry_item_that_left_active_inventory_is_skipped_not_deferred(self):
  409. from tools import creative_rejection_cleanup as cleanup
  410. creative_item = {
  411. "account_id": 1,
  412. "adgroup_id": 2,
  413. "dynamic_creative_id": 3,
  414. "cleanup_action": cleanup.DELETE_CREATIVE,
  415. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  416. }
  417. ad_item = {
  418. "account_id": 1,
  419. "adgroup_id": 2,
  420. "dynamic_creative_id": -2,
  421. "cleanup_action": cleanup.DELETE_AD,
  422. "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  423. }
  424. creative_result = cleanup.cleanup_precondition_failure(
  425. creative_item,
  426. set(),
  427. {},
  428. performance_scanned_accounts={1},
  429. performance_scope_loaded=True,
  430. performance_source_keys=set(),
  431. )
  432. ad_result = cleanup.cleanup_precondition_failure(
  433. ad_item,
  434. set(),
  435. {},
  436. ad_scanned_accounts={1},
  437. performance_scope_loaded=True,
  438. performance_source_ad_keys=set(),
  439. )
  440. scope_error_result = cleanup.cleanup_precondition_failure(
  441. creative_item,
  442. set(),
  443. {},
  444. performance_scope_loaded=False,
  445. )
  446. self.assertEqual(
  447. creative_result[0], "SKIPPED_REVIEW_NOT_RECONFIRMED"
  448. )
  449. self.assertEqual(ad_result[0], "SKIPPED_REVIEW_NOT_RECONFIRMED")
  450. self.assertEqual(scope_error_result[0], "DEFERRED")
  451. def test_ad_uses_odps_ad_id_even_when_tencent_creative_mapping_differs(self):
  452. from tools import creative_rejection_cleanup as cleanup
  453. tencent = Mock()
  454. tencent.get_dynamic_creatives.return_value = [{
  455. "dynamic_creative_id": 3,
  456. "adgroup_id": 2,
  457. "configured_status": "AD_STATUS_NORMAL",
  458. "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
  459. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  460. }]
  461. tencent.get_ads.return_value = [{
  462. "adgroup_id": 5,
  463. "configured_status": "AD_STATUS_NORMAL",
  464. "created_time": "2026-08-10 00:00:00",
  465. }]
  466. tencent.get_ad_metrics.return_value = {
  467. 5: {"impressions": 0, "cost_fen": 0}
  468. }
  469. result = cleanup._scan_one_account(
  470. {"account_id": 1},
  471. tencent=tencent,
  472. review_fetcher=Mock(),
  473. spend_start_date=date(2026, 8, 16),
  474. spend_end_date=date(2026, 8, 18),
  475. review_enabled=False,
  476. performance_source_creatives={
  477. 3: {
  478. "account_id": 1,
  479. "adgroup_id": 5,
  480. "creative_id": 3,
  481. "create_time": "2026-08-13 10:00:00",
  482. }
  483. },
  484. current_day_date=date(2026, 8, 19),
  485. ad_cleanup_enabled=True,
  486. ad_metric_start_date=date(2026, 8, 16),
  487. ad_metric_end_date=date(2026, 8, 18),
  488. )
  489. self.assertIn(5, result[9])
  490. self.assertEqual(result[12]["ad_mismatches"], 1)
  491. self.assertEqual(tencent.get_ad_metrics.call_count, 2)
  492. self.assertEqual(tencent.get_ad_metrics.call_args_list[0].args[1], [5])
  493. def test_creative_list_failure_does_not_block_ad_scan(self):
  494. from tools import creative_rejection_cleanup as cleanup
  495. tencent = Mock()
  496. tencent.get_dynamic_creatives.side_effect = RuntimeError(
  497. "creative endpoint unavailable"
  498. )
  499. tencent.get_ads.return_value = [{
  500. "adgroup_id": 2,
  501. "configured_status": "AD_STATUS_NORMAL",
  502. "created_time": "2026-08-10 00:00:00",
  503. }]
  504. tencent.get_ad_metrics.return_value = {
  505. 2: {"impressions": 0, "cost_fen": 0}
  506. }
  507. result = cleanup._scan_one_account(
  508. {"account_id": 1},
  509. tencent=tencent,
  510. review_fetcher=Mock(),
  511. spend_start_date=date(2026, 8, 16),
  512. spend_end_date=date(2026, 8, 18),
  513. review_enabled=True,
  514. performance_enabled=True,
  515. performance_source_creatives={
  516. 3: {
  517. "account_id": 1,
  518. "adgroup_id": 2,
  519. "creative_id": 3,
  520. "create_time": "2026-08-13 10:00:00",
  521. }
  522. },
  523. performance_start_date=date(2026, 8, 12),
  524. performance_end_date=date(2026, 8, 18),
  525. current_day_date=date(2026, 8, 19),
  526. ad_cleanup_enabled=True,
  527. ad_metric_start_date=date(2026, 8, 16),
  528. ad_metric_end_date=date(2026, 8, 18),
  529. )
  530. self.assertIn("creative endpoint unavailable", result[11])
  531. self.assertIn("creative endpoint unavailable", result[8])
  532. self.assertIsNone(result[10])
  533. self.assertIn(2, result[9])
  534. tencent.get_ads.assert_called_once_with(1)
  535. class AdPerformanceRuleTests(unittest.TestCase):
  536. def test_active_ad_with_zero_three_day_spend_is_deleted(self):
  537. from tools.creative_rejection_cleanup import (
  538. DELETE_AD,
  539. PERFORMANCE_AD_ZERO_SPEND_RULE,
  540. determine_ad_performance_cleanup_action,
  541. )
  542. action = determine_ad_performance_cleanup_action(
  543. {
  544. "configured_status": "AD_STATUS_NORMAL",
  545. "begin_date": "2026-08-01",
  546. "end_date": "2026-08-31",
  547. "created_time": "2026-08-10 00:00:00",
  548. },
  549. as_of_date=date(2026, 8, 19),
  550. cost_fen=0,
  551. metric_start_date=date(2026, 8, 16),
  552. metric_end_date=date(2026, 8, 18),
  553. window_days=3,
  554. )
  555. self.assertEqual(action["cleanup_action"], DELETE_AD)
  556. self.assertEqual(
  557. action["cleanup_rule_type"],
  558. PERFORMANCE_AD_ZERO_SPEND_RULE,
  559. )
  560. def test_ad_with_any_spend_or_inactive_status_is_not_deleted(self):
  561. from tools.creative_rejection_cleanup import (
  562. determine_ad_performance_cleanup_action,
  563. )
  564. common = {
  565. "as_of_date": date(2026, 8, 19),
  566. "metric_start_date": date(2026, 8, 16),
  567. "metric_end_date": date(2026, 8, 18),
  568. "window_days": 3,
  569. }
  570. self.assertIsNone(
  571. determine_ad_performance_cleanup_action(
  572. {
  573. "configured_status": "AD_STATUS_NORMAL",
  574. "created_time": "2026-08-10 00:00:00",
  575. },
  576. cost_fen=1,
  577. **common,
  578. )
  579. )
  580. self.assertIsNone(
  581. determine_ad_performance_cleanup_action(
  582. {
  583. "configured_status": "AD_STATUS_SUSPEND",
  584. "created_time": "2026-08-10 00:00:00",
  585. },
  586. cost_fen=0,
  587. **common,
  588. )
  589. )
  590. self.assertIsNone(
  591. determine_ad_performance_cleanup_action(
  592. {
  593. "configured_status": "AD_STATUS_NORMAL",
  594. "created_time": "2026-08-10 00:00:00",
  595. },
  596. cost_fen=0,
  597. current_day_cost_fen=1,
  598. **common,
  599. )
  600. )
  601. def test_ad_requires_more_than_five_complete_days_and_created_time(self):
  602. from tools.creative_rejection_cleanup import (
  603. determine_ad_performance_cleanup_action,
  604. )
  605. as_of = datetime(
  606. 2026,
  607. 8,
  608. 19,
  609. 11,
  610. tzinfo=ZoneInfo("Asia/Shanghai"),
  611. )
  612. common = {
  613. "configured_status": "AD_STATUS_NORMAL",
  614. "begin_date": "2026-08-01",
  615. "end_date": "2026-08-31",
  616. }
  617. kwargs = {
  618. "as_of_date": as_of,
  619. "cost_fen": 0,
  620. "metric_start_date": date(2026, 8, 16),
  621. "metric_end_date": date(2026, 8, 18),
  622. "window_days": 3,
  623. "min_age_days": 5,
  624. }
  625. self.assertIsNone(
  626. determine_ad_performance_cleanup_action(
  627. {**common, "created_time": "2026-08-14 11:00:00"},
  628. **kwargs,
  629. )
  630. )
  631. self.assertIsNone(
  632. determine_ad_performance_cleanup_action(
  633. {**common, "created_time": "2026-08-13 11:01:00"},
  634. **kwargs,
  635. )
  636. )
  637. self.assertIsNotNone(
  638. determine_ad_performance_cleanup_action(
  639. {**common, "created_time": "2026-08-13 11:00:00"},
  640. **kwargs,
  641. )
  642. )
  643. self.assertIsNone(
  644. determine_ad_performance_cleanup_action(common, **kwargs)
  645. )
  646. def test_tencent_created_time_epoch_seconds_are_parsed_in_shanghai(self):
  647. from tools.creative_rejection_cleanup import (
  648. _as_shanghai_datetime,
  649. determine_ad_performance_cleanup_action,
  650. )
  651. created = datetime(
  652. 2026,
  653. 8,
  654. 10,
  655. 9,
  656. 30,
  657. tzinfo=ZoneInfo("Asia/Shanghai"),
  658. )
  659. epoch_seconds = int(created.timestamp())
  660. self.assertEqual(_as_shanghai_datetime(epoch_seconds), created)
  661. self.assertEqual(_as_shanghai_datetime(epoch_seconds * 1000), created)
  662. self.assertEqual(
  663. _as_shanghai_datetime(20260810093000),
  664. created,
  665. )
  666. self.assertIsNone(_as_shanghai_datetime(2))
  667. action = determine_ad_performance_cleanup_action(
  668. {
  669. "configured_status": "AD_STATUS_NORMAL",
  670. "created_time": epoch_seconds,
  671. },
  672. as_of_date=datetime(
  673. 2026,
  674. 8,
  675. 19,
  676. 11,
  677. tzinfo=ZoneInfo("Asia/Shanghai"),
  678. ),
  679. cost_fen=0,
  680. metric_start_date=date(2026, 8, 16),
  681. metric_end_date=date(2026, 8, 18),
  682. )
  683. self.assertEqual(action["creative_created_at"], created)
  684. self.assertNotEqual(action["creative_created_at"].year, 1970)
  685. def test_tencent_ad_fields_include_creation_time_and_deleted_flag(self):
  686. from tencent_client import AD_FIELDS
  687. self.assertIn("created_time", AD_FIELDS)
  688. self.assertIn("is_deleted", AD_FIELDS)
  689. def test_explicit_deleted_ad_states_are_not_cleanup_candidates(self):
  690. from tools.creative_rejection_cleanup import (
  691. determine_ad_performance_cleanup_action,
  692. )
  693. common = {
  694. "configured_status": "AD_STATUS_NORMAL",
  695. "created_time": "2026-08-10 00:00:00",
  696. }
  697. kwargs = {
  698. "as_of_date": date(2026, 8, 19),
  699. "cost_fen": 0,
  700. "metric_start_date": date(2026, 8, 16),
  701. "metric_end_date": date(2026, 8, 18),
  702. "window_days": 3,
  703. }
  704. for deleted_state in (
  705. {"is_deleted": True},
  706. {"is_delete": 1},
  707. {"system_status": "ADGROUP_STATUS_DELETED"},
  708. {"system_status": "SMART_ADGROUP_STATUS_DELETED"},
  709. ):
  710. with self.subTest(deleted_state=deleted_state):
  711. self.assertIsNone(
  712. determine_ad_performance_cleanup_action(
  713. {**common, **deleted_state},
  714. **kwargs,
  715. )
  716. )
  717. def test_get_ad_retries_exact_id_in_deleted_scope(self):
  718. from tencent_client import TencentClient
  719. active_response = Mock()
  720. active_response.raise_for_status.return_value = None
  721. active_response.json.return_value = {
  722. "code": 0,
  723. "data": {"list": []},
  724. }
  725. deleted_response = Mock()
  726. deleted_response.raise_for_status.return_value = None
  727. deleted_response.json.return_value = {
  728. "code": 0,
  729. "data": {
  730. "list": [{
  731. "adgroup_id": 2,
  732. "is_deleted": True,
  733. "system_status": "ADGROUP_STATUS_DELETED",
  734. }]
  735. },
  736. }
  737. client = TencentClient()
  738. client.session.get = Mock(
  739. side_effect=[active_response, deleted_response]
  740. )
  741. client._common_params = Mock(return_value={"access_token": "token"})
  742. result = client.get_ad(1, 2)
  743. self.assertTrue(result["is_deleted"])
  744. self.assertEqual(client.session.get.call_count, 2)
  745. first_params = client.session.get.call_args_list[0].kwargs["params"]
  746. second_params = client.session.get.call_args_list[1].kwargs["params"]
  747. self.assertEqual(
  748. json.loads(first_params["filtering"])[0]["values"],
  749. ["2"],
  750. )
  751. self.assertNotIn("is_deleted", first_params)
  752. self.assertIs(second_params["is_deleted"], True)
  753. def test_cleanup_day_guard_rejects_a_batch_that_crossed_midnight(self):
  754. from tools.creative_rejection_cleanup import _is_current_cleanup_day
  755. self.assertTrue(
  756. _is_current_cleanup_day(
  757. date(2026, 8, 19),
  758. datetime(
  759. 2026,
  760. 8,
  761. 19,
  762. 23,
  763. 59,
  764. tzinfo=ZoneInfo("Asia/Shanghai"),
  765. ),
  766. )
  767. )
  768. self.assertFalse(
  769. _is_current_cleanup_day(
  770. date(2026, 8, 19),
  771. datetime(
  772. 2026,
  773. 8,
  774. 20,
  775. 0,
  776. 1,
  777. tzinfo=ZoneInfo("Asia/Shanghai"),
  778. ),
  779. )
  780. )
  781. def test_tencent_ad_delete_uses_delete_endpoint_and_readback(self):
  782. from tencent_client import TencentClient
  783. response = Mock(status_code=200, text="ok", headers={})
  784. response.raise_for_status.return_value = None
  785. response.json.return_value = {"code": 0, "data": {}}
  786. client = TencentClient()
  787. client.session.post = Mock(return_value=response)
  788. client._common_params = Mock(return_value={"access_token": "token"})
  789. client._user_token = Mock(return_value="user-token")
  790. client.get_ad = Mock(
  791. return_value={
  792. "configured_status": "AD_STATUS_NORMAL",
  793. "system_status": "ADGROUP_STATUS_DELETED",
  794. "is_deleted": False,
  795. }
  796. )
  797. result = client.delete_ad(1, 2)
  798. self.assertEqual(result["system_status"], "ADGROUP_STATUS_DELETED")
  799. request = client.session.post.call_args
  800. self.assertTrue(request.args[0].endswith("/adgroups/delete"))
  801. self.assertEqual(request.kwargs["json"]["adgroup_id"], 2)
  802. def test_tencent_ad_delete_accepts_is_deleted_flag(self):
  803. from tencent_client import TencentClient
  804. response = Mock(status_code=200, text="ok", headers={})
  805. response.raise_for_status.return_value = None
  806. response.json.return_value = {"code": 0, "data": {}}
  807. client = TencentClient()
  808. client.session.post = Mock(return_value=response)
  809. client._common_params = Mock(return_value={"access_token": "token"})
  810. client._user_token = Mock(return_value="user-token")
  811. client.get_ad = Mock(
  812. return_value={
  813. "configured_status": "AD_STATUS_NORMAL",
  814. "system_status": "ADGROUP_STATUS_ACTIVE",
  815. "is_deleted": True,
  816. }
  817. )
  818. result = client.delete_ad(1, 2)
  819. self.assertTrue(result["is_deleted"])
  820. def test_tencent_ad_delete_does_not_infer_deletion_from_missing_row(self):
  821. from tencent_client import PostWriteVerificationError, TencentClient
  822. response = Mock(status_code=200, text="ok", headers={})
  823. response.raise_for_status.return_value = None
  824. response.json.return_value = {"code": 0, "data": {}}
  825. client = TencentClient()
  826. client.verify_attempts = 1
  827. client.session.post = Mock(return_value=response)
  828. client._common_params = Mock(return_value={"access_token": "token"})
  829. client._user_token = Mock(return_value="user-token")
  830. client.get_ad = Mock(
  831. side_effect=RuntimeError(
  832. "Ad not found after update: account=1 adgroup=2"
  833. )
  834. )
  835. with self.assertRaises(PostWriteVerificationError):
  836. client.delete_ad(1, 2)
  837. class CreativePerformanceNotificationTests(unittest.TestCase):
  838. def test_preview_notification_excludes_candidates_not_confirmed_this_run(self):
  839. from tools import creative_rejection_cleanup as cleanup
  840. current_row = {
  841. "id": 1,
  842. "check_date": date(2026, 8, 20),
  843. "account_id": 1,
  844. "dynamic_creative_id": 3,
  845. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  846. "operator_notified_at": datetime(2026, 8, 20, 11),
  847. }
  848. current_ad_row = {
  849. "id": 2,
  850. "check_date": date(2026, 8, 20),
  851. "account_id": 1,
  852. "dynamic_creative_id": -2,
  853. "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  854. "creative_created_at": datetime(2026, 8, 10, 10),
  855. "operator_notified_at": None,
  856. }
  857. stale_bad_timestamp_row = {
  858. **current_ad_row,
  859. "id": 3,
  860. "dynamic_creative_id": -4,
  861. "creative_created_at": datetime(1970, 1, 1, 0, 0, 2),
  862. }
  863. selected = cleanup.filter_preview_notification_rows(
  864. [current_row, current_ad_row, stale_bad_timestamp_row],
  865. check_date=date(2026, 8, 20),
  866. confirmed_actions={
  867. (1, 3): {
  868. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  869. },
  870. (1, -2): {
  871. "cleanup_rule_type": (
  872. cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE
  873. ),
  874. }
  875. },
  876. force_notification=True,
  877. )
  878. self.assertEqual([row["id"] for row in selected], [1, 2])
  879. self.assertIsNone(selected[0]["operator_notified_at"])
  880. def test_performance_rows_only_enter_the_separate_internal_delivery(self):
  881. from tools.creative_rejection_cleanup import (
  882. PERFORMANCE_AD_ZERO_SPEND_RULE,
  883. PERFORMANCE_NEW_RULE,
  884. REVIEW_DENIED_RULE,
  885. split_notification_rows,
  886. )
  887. review_row = {
  888. "id": 1,
  889. "agency_name": "代理一",
  890. "cleanup_rule_type": REVIEW_DENIED_RULE,
  891. "agency_notified_at": None,
  892. "operator_notified_at": None,
  893. }
  894. performance_row = {
  895. "id": 2,
  896. "agency_name": "",
  897. "cleanup_rule_type": PERFORMANCE_NEW_RULE,
  898. "agency_notified_at": datetime(2026, 8, 19, 10),
  899. "operator_notified_at": None,
  900. }
  901. ad_row = {
  902. "id": 3,
  903. "agency_name": "",
  904. "cleanup_rule_type": PERFORMANCE_AD_ZERO_SPEND_RULE,
  905. "agency_notified_at": datetime(2026, 8, 19, 10),
  906. "operator_notified_at": None,
  907. }
  908. agency, review_internal, performance_internal = split_notification_rows(
  909. [review_row, performance_row, ad_row]
  910. )
  911. self.assertEqual(agency, [review_row])
  912. self.assertEqual(review_internal, [review_row])
  913. self.assertEqual(performance_internal, [performance_row, ad_row])
  914. legacy_agency_rows = [
  915. row
  916. for row in [review_row, performance_row, ad_row]
  917. if row.get("agency_notified_at") is None
  918. and str(row.get("agency_name") or "").strip()
  919. ]
  920. self.assertEqual(legacy_agency_rows, [review_row])
  921. def test_performance_upsert_atomically_suppresses_agency_delivery(self):
  922. from tools import creative_rejection_cleanup as cleanup
  923. class Cursor:
  924. def __init__(self):
  925. self.calls = []
  926. def __enter__(self):
  927. return self
  928. def __exit__(self, *_args):
  929. return None
  930. def execute(self, sql, params=None):
  931. self.calls.append((sql, params))
  932. def fetchone(self):
  933. return {"id": 7}
  934. cursor = Cursor()
  935. connection = Mock()
  936. connection.cursor.return_value = cursor
  937. with patch.object(cleanup, "get_connection", return_value=connection):
  938. cleanup.upsert_cleanup_candidate({
  939. "account_id": 1,
  940. "account_name": "账户一",
  941. "agency_name": "代理一",
  942. "adgroup_id": 2,
  943. "adgroup_name": "广告二",
  944. "dynamic_creative_id": 3,
  945. "dynamic_creative_name": "创意三",
  946. "check_date": date(2026, 8, 19),
  947. "cleanup_action": cleanup.DELETE_CREATIVE,
  948. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  949. "component_ids": [],
  950. "element_ids": [],
  951. "recent_cost_fen": 0,
  952. "cost_start_date": date(2026, 8, 12),
  953. "cost_end_date": date(2026, 8, 18),
  954. "action_reason": "未起量",
  955. "reject_reason": "",
  956. "review_result": {},
  957. "pre_state": {},
  958. })
  959. insert_sql, insert_params = cursor.calls[0]
  960. self.assertEqual(insert_params[3], "")
  961. self.assertEqual(insert_params[10], cleanup.PERFORMANCE_NEW_RULE)
  962. self.assertIsInstance(insert_params[-1], datetime)
  963. self.assertIn("agency_notified_at", insert_sql)
  964. self.assertIn("VALUES(cleanup_rule_type)", insert_sql)
  965. normalized_insert_sql = " ".join(insert_sql.split())
  966. terminal_guard = (
  967. "WHEN cleanup_status IN ( "
  968. "'DELETING','CREATIVE_DELETED','AD_DELETED' )"
  969. )
  970. self.assertEqual(normalized_insert_sql.count(terminal_guard), 2)
  971. self.assertIn(f"{terminal_guard} THEN agency_name", normalized_insert_sql)
  972. self.assertIn(
  973. f"{terminal_guard} THEN agency_notified_at",
  974. normalized_insert_sql,
  975. )
  976. self.assertIn("performance_suppression_at", cursor.calls[1][0])
  977. self.assertIn("performance_suppression_at", cursor.calls[2][0])
  978. extended_sql = cursor.calls[2][0]
  979. self.assertIn(
  980. "item.creative_created_at <=> incoming.creative_created_at",
  981. extended_sql,
  982. )
  983. self.assertIn(
  984. "item.agent_name <=> COALESCE(NULLIF(incoming.agent_name,''), item.agent_name)",
  985. extended_sql,
  986. )
  987. def test_schema_backfill_suppresses_existing_performance_rows(self):
  988. from storage import _suppress_performance_agency_delivery
  989. cursor = Mock()
  990. _suppress_performance_agency_delivery(cursor)
  991. sql = " ".join(cursor.execute.call_args.args[0].split())
  992. self.assertIn("SET agency_name=''", sql)
  993. self.assertIn(
  994. "agency_notified_at=COALESCE(agency_notified_at, NOW())",
  995. sql,
  996. )
  997. self.assertIn(
  998. "WHERE LEFT(cleanup_rule_type, 12)='PERFORMANCE_'",
  999. sql,
  1000. )
  1001. def test_schema_backfill_preserves_legacy_partial_review_rule(self):
  1002. from storage import _backfill_legacy_review_rule_types
  1003. cursor = Mock()
  1004. _backfill_legacy_review_rule_types(cursor)
  1005. sql = " ".join(cursor.execute.call_args.args[0].split())
  1006. self.assertIn("SET cleanup_rule_type='REVIEW_PARTIAL'", sql)
  1007. self.assertIn("cleanup_action='ALERT_ONLY'", sql)
  1008. self.assertIn(
  1009. "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL",
  1010. sql,
  1011. )
  1012. def test_performance_report_has_its_own_internal_title_and_sheet(self):
  1013. from openpyxl import load_workbook
  1014. from tools.creative_rejection_cleanup import (
  1015. DELETE_AD,
  1016. PERFORMANCE_AD_ZERO_SPEND_RULE,
  1017. PERFORMANCE_NEW_RULE,
  1018. write_performance_operator_summary,
  1019. )
  1020. row = {
  1021. "id": 2,
  1022. "agency_name": "代理一",
  1023. "account_id": 1,
  1024. "account_name": "账户一",
  1025. "agent_name": "代理商甲",
  1026. "adgroup_id": 2,
  1027. "dynamic_creative_id": 3,
  1028. "cleanup_action": "DELETE_CREATIVE",
  1029. "cleanup_rule_type": PERFORMANCE_NEW_RULE,
  1030. "cleanup_status": "DISCOVERED",
  1031. "reject_reason": "",
  1032. "review_result_json": "{}",
  1033. "pre_state_json": "{}",
  1034. "creative_age_days": 6,
  1035. "metric_impressions": 99,
  1036. "metric_daily_avg_impressions": 99 / 7,
  1037. "recent_cost_fen": 0,
  1038. "cost_start_date": "2026-08-12",
  1039. "cost_end_date": "2026-08-18",
  1040. }
  1041. ad_row = {
  1042. "id": 3,
  1043. "agency_name": "",
  1044. "account_id": 1,
  1045. "account_name": "账户一",
  1046. "agent_name": "代理商甲",
  1047. "adgroup_id": 2,
  1048. "adgroup_name": "广告二",
  1049. "dynamic_creative_id": -2,
  1050. "dynamic_creative_name": "",
  1051. "cleanup_action": DELETE_AD,
  1052. "cleanup_rule_type": PERFORMANCE_AD_ZERO_SPEND_RULE,
  1053. "cleanup_status": "DISCOVERED",
  1054. "reject_reason": "",
  1055. "review_result_json": "{}",
  1056. "pre_state_json": '{"configured_status":"AD_STATUS_NORMAL"}',
  1057. "recent_cost_fen": 0,
  1058. "metric_window_days": 3,
  1059. "cost_start_date": "2026-08-16",
  1060. "cost_end_date": "2026-08-18",
  1061. }
  1062. with TemporaryDirectory() as directory:
  1063. report = write_performance_operator_summary(
  1064. [row, ad_row],
  1065. Path(directory),
  1066. "20260819",
  1067. )
  1068. workbook = load_workbook(report["report"])
  1069. self.assertEqual(
  1070. report["title"],
  1071. "长期未起量创意及广告清理汇总通知",
  1072. )
  1073. self.assertEqual(report["notification_kind"], "performance_internal")
  1074. self.assertLessEqual(len(str(report["report_version"])), 64)
  1075. self.assertEqual(report["creative_rows"], 1)
  1076. self.assertEqual(report["ad_rows"], 1)
  1077. self.assertIn("长期未起量清理", workbook.sheetnames)
  1078. sheet = workbook["长期未起量清理"]
  1079. self.assertEqual(sheet["A1"].value, "清理对象")
  1080. self.assertEqual(sheet["D1"].value, "代理商昵称")
  1081. self.assertEqual([sheet["D2"].value, sheet["D3"].value], ["代理商甲"] * 2)
  1082. self.assertEqual([sheet["A2"].value, sheet["A3"].value], ["创意", "广告"])
  1083. class CreativePerformanceIntegrationTests(unittest.TestCase):
  1084. def setUp(self):
  1085. flags = patch.dict(
  1086. os.environ,
  1087. {
  1088. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1089. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "0",
  1090. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1091. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "0",
  1092. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
  1093. },
  1094. clear=False,
  1095. )
  1096. flags.start()
  1097. self.addCleanup(flags.stop)
  1098. account_metadata = patch(
  1099. "tools.creative_rejection_cleanup.fetch_tencent_account_metadata",
  1100. return_value={},
  1101. )
  1102. account_metadata.start()
  1103. self.addCleanup(account_metadata.stop)
  1104. def test_ad_candidate_is_created_after_creative_judgment(self):
  1105. import pandas as pd
  1106. from tools import creative_rejection_cleanup as cleanup
  1107. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1108. tencent = Mock()
  1109. tencent.get_dynamic_creatives.return_value = [
  1110. {
  1111. "dynamic_creative_id": 3,
  1112. "dynamic_creative_name": "创意三",
  1113. "adgroup_id": 2,
  1114. "configured_status": "AD_STATUS_NORMAL",
  1115. "creative_set_approval_status": (
  1116. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  1117. ),
  1118. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1119. },
  1120. {
  1121. "dynamic_creative_id": 4,
  1122. "dynamic_creative_name": "拒审创意四",
  1123. "adgroup_id": 2,
  1124. "configured_status": "AD_STATUS_NORMAL",
  1125. "creative_set_approval_status": (
  1126. "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  1127. ),
  1128. "system_status": "DYNAMIC_CREATIVE_STATUS_DENIED",
  1129. },
  1130. {
  1131. "dynamic_creative_id": 5,
  1132. "dynamic_creative_name": "已删除创意五",
  1133. "adgroup_id": 2,
  1134. "configured_status": "AD_STATUS_DELETED",
  1135. "creative_set_approval_status": (
  1136. "CREATIVE_SET_APPROVAL_STATUS_DENIED"
  1137. ),
  1138. "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
  1139. },
  1140. ]
  1141. tencent.get_ads.return_value = [{
  1142. "adgroup_id": 2,
  1143. "adgroup_name": "广告二",
  1144. "configured_status": "AD_STATUS_NORMAL",
  1145. "created_time": "2026-08-10 00:00:00",
  1146. }]
  1147. tencent.get_dynamic_creative_metrics.side_effect = [
  1148. {3: {"impressions": 99, "cost_fen": 0}},
  1149. {3: {"impressions": 0, "cost_fen": 0}},
  1150. ]
  1151. tencent.get_ad_metrics.return_value = {
  1152. 2: {"impressions": 0, "cost_fen": 0}
  1153. }
  1154. captured = []
  1155. with TemporaryDirectory() as directory, patch.dict(
  1156. os.environ,
  1157. {
  1158. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1159. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1160. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1161. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1162. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
  1163. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1164. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1165. },
  1166. clear=False,
  1167. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1168. cleanup, "resolve_end_date", return_value="20260818"
  1169. ), patch.object(
  1170. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1171. ), patch.object(
  1172. cleanup,
  1173. "fetch_recent_spend_accounts",
  1174. return_value=[{"account_id": 1, "account_name": "账户一"}],
  1175. ), patch.object(
  1176. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1177. ), patch.object(
  1178. cleanup,
  1179. "fetch_active_creative_inventory",
  1180. return_value=[{
  1181. "account_id": 1,
  1182. "adgroup_id": 2,
  1183. "creative_id": 3,
  1184. "create_time": datetime(
  1185. 2026,
  1186. 8,
  1187. 13,
  1188. 10,
  1189. tzinfo=ZoneInfo("Asia/Shanghai"),
  1190. ),
  1191. }],
  1192. ), patch.object(
  1193. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1194. ), patch.object(
  1195. cleanup,
  1196. "upsert_cleanup_candidate",
  1197. side_effect=lambda record: captured.append(record) or {"id": 1},
  1198. ), patch.object(
  1199. cleanup, "load_retryable_cleanup_items", return_value=[]
  1200. ), patch.object(
  1201. cleanup, "load_unnotified_deleted_items", return_value=[]
  1202. ):
  1203. summary = cleanup.run_rejected_creative_cleanup(
  1204. output_dir=Path(directory),
  1205. now=current,
  1206. tencent=tencent,
  1207. odps=Mock(),
  1208. review_fetcher=Mock(return_value=[]),
  1209. )
  1210. self.assertEqual(
  1211. [record["cleanup_rule_type"] for record in captured],
  1212. [
  1213. cleanup.REVIEW_DENIED_RULE,
  1214. cleanup.PERFORMANCE_NEW_RULE,
  1215. cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  1216. ],
  1217. )
  1218. self.assertEqual(captured[2]["dynamic_creative_id"], -2)
  1219. self.assertNotIn(5, [record["dynamic_creative_id"] for record in captured])
  1220. self.assertEqual(summary["rejected_discovered"], 1)
  1221. self.assertEqual(summary["ad_discovered"], 1)
  1222. def test_combined_task_discovers_performance_candidate_without_applying(self):
  1223. import pandas as pd
  1224. from tools import creative_rejection_cleanup as cleanup
  1225. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1226. tencent = Mock()
  1227. tencent.get_dynamic_creatives.return_value = [
  1228. {
  1229. "dynamic_creative_id": 3,
  1230. "dynamic_creative_name": "创意三",
  1231. "adgroup_id": 2,
  1232. "configured_status": "AD_STATUS_NORMAL",
  1233. "creative_set_approval_status": (
  1234. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  1235. ),
  1236. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1237. }
  1238. ]
  1239. tencent.get_ads.return_value = [
  1240. {
  1241. "adgroup_id": 2,
  1242. "adgroup_name": "广告二",
  1243. "configured_status": "AD_STATUS_NORMAL",
  1244. }
  1245. ]
  1246. tencent.get_dynamic_creative_metrics.side_effect = [
  1247. {3: {"impressions": 99, "cost_fen": 0}},
  1248. {3: {"impressions": 0, "cost_fen": 0}},
  1249. ]
  1250. review_fetcher = Mock(return_value=[])
  1251. captured = []
  1252. with TemporaryDirectory() as directory, patch.dict(
  1253. os.environ,
  1254. {
  1255. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1256. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1257. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1258. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1259. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1260. },
  1261. clear=False,
  1262. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1263. cleanup, "resolve_end_date", return_value="20260818"
  1264. ), patch.object(
  1265. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1266. ), patch.object(
  1267. cleanup,
  1268. "fetch_recent_spend_accounts",
  1269. return_value=[],
  1270. ), patch.object(
  1271. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1272. ), patch.object(
  1273. cleanup,
  1274. "fetch_active_creative_inventory",
  1275. return_value=[{
  1276. "account_id": 1,
  1277. "adgroup_id": 2,
  1278. "creative_id": 3,
  1279. "create_time": datetime(
  1280. 2026,
  1281. 8,
  1282. 13,
  1283. 10,
  1284. tzinfo=ZoneInfo("Asia/Shanghai"),
  1285. ),
  1286. }],
  1287. ), patch.object(
  1288. cleanup,
  1289. "fetch_tencent_account_metadata",
  1290. return_value={
  1291. 1: {
  1292. "account_name": "大数据账户一",
  1293. "agent_name": "代理商甲",
  1294. }
  1295. },
  1296. ), patch.object(
  1297. cleanup,
  1298. "prefetch_account_access_tokens",
  1299. return_value={1: "access-token"},
  1300. ), patch.object(
  1301. cleanup,
  1302. "upsert_cleanup_candidate",
  1303. side_effect=lambda record: captured.append(record) or {"id": 1},
  1304. ), patch.object(
  1305. cleanup, "load_retryable_cleanup_items", return_value=[]
  1306. ), patch.object(
  1307. cleanup, "load_unnotified_deleted_items", return_value=[]
  1308. ):
  1309. summary = cleanup.run_rejected_creative_cleanup(
  1310. output_dir=Path(directory),
  1311. now=current,
  1312. tencent=tencent,
  1313. odps=Mock(),
  1314. review_fetcher=review_fetcher,
  1315. )
  1316. self.assertEqual(len(captured), 1)
  1317. self.assertEqual(
  1318. captured[0]["cleanup_rule_type"],
  1319. cleanup.PERFORMANCE_NEW_RULE,
  1320. )
  1321. self.assertEqual(captured[0]["account_name"], "大数据账户一")
  1322. self.assertEqual(captured[0]["agent_name"], "代理商甲")
  1323. self.assertEqual(summary["performance_discovered"], 1)
  1324. self.assertEqual(summary["rejected_discovered"], 0)
  1325. self.assertEqual(summary["review_account_ids"], [])
  1326. self.assertEqual(summary["performance_account_ids"], [1])
  1327. review_fetcher.assert_not_called()
  1328. tencent.delete_dynamic_creative.assert_not_called()
  1329. def test_current_day_spend_blocks_creative_and_ad_candidates(self):
  1330. import pandas as pd
  1331. from tools import creative_rejection_cleanup as cleanup
  1332. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1333. tencent = Mock()
  1334. tencent.get_dynamic_creatives.return_value = [{
  1335. "dynamic_creative_id": 3,
  1336. "dynamic_creative_name": "创意三",
  1337. "adgroup_id": 2,
  1338. "configured_status": "AD_STATUS_NORMAL",
  1339. "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
  1340. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1341. }]
  1342. tencent.get_ads.return_value = [{
  1343. "adgroup_id": 2,
  1344. "adgroup_name": "广告二",
  1345. "configured_status": "AD_STATUS_NORMAL",
  1346. "created_time": "2026-08-10 00:00:00",
  1347. }]
  1348. tencent.get_dynamic_creative_metrics.side_effect = [
  1349. {3: {"impressions": 99, "cost_fen": 0}},
  1350. {3: {"impressions": 1, "cost_fen": 1}},
  1351. ]
  1352. tencent.get_ad_metrics.side_effect = [
  1353. {2: {"impressions": 0, "cost_fen": 0}},
  1354. {2: {"impressions": 1, "cost_fen": 1}},
  1355. ]
  1356. captured = []
  1357. with TemporaryDirectory() as directory, patch.dict(
  1358. os.environ,
  1359. {
  1360. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1361. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1362. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1363. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1364. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "0",
  1365. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1366. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1367. },
  1368. clear=False,
  1369. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1370. cleanup, "resolve_end_date", return_value="20260818"
  1371. ), patch.object(
  1372. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1373. ), patch.object(
  1374. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1375. ), patch.object(
  1376. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1377. ), patch.object(
  1378. cleanup,
  1379. "fetch_active_creative_inventory",
  1380. return_value=[{
  1381. "account_id": 1,
  1382. "adgroup_id": 2,
  1383. "creative_id": 3,
  1384. "create_time": datetime(
  1385. 2026,
  1386. 8,
  1387. 13,
  1388. 10,
  1389. tzinfo=ZoneInfo("Asia/Shanghai"),
  1390. ),
  1391. }],
  1392. ), patch.object(
  1393. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1394. ), patch.object(
  1395. cleanup,
  1396. "upsert_cleanup_candidate",
  1397. side_effect=lambda record: captured.append(record) or {"id": 1},
  1398. ), patch.object(
  1399. cleanup, "load_retryable_cleanup_items", return_value=[]
  1400. ), patch.object(
  1401. cleanup, "load_unnotified_deleted_items", return_value=[]
  1402. ):
  1403. summary = cleanup.run_rejected_creative_cleanup(
  1404. output_dir=Path(directory),
  1405. now=current,
  1406. tencent=tencent,
  1407. odps=Mock(),
  1408. review_fetcher=Mock(return_value=[]),
  1409. )
  1410. self.assertEqual(captured, [])
  1411. self.assertEqual(summary["performance_discovered"], 0)
  1412. self.assertEqual(summary["ad_discovered"], 0)
  1413. self.assertEqual(summary["current_day_metric_date"], date(2026, 8, 19))
  1414. def test_review_sources_and_creative_endpoint_can_fail_while_ad_continues(self):
  1415. from tools import creative_rejection_cleanup as cleanup
  1416. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1417. tencent = Mock()
  1418. tencent.get_dynamic_creatives.side_effect = RuntimeError(
  1419. "creative endpoint unavailable"
  1420. )
  1421. tencent.get_ads.return_value = [{
  1422. "adgroup_id": 2,
  1423. "adgroup_name": "广告二",
  1424. "configured_status": "AD_STATUS_NORMAL",
  1425. "created_time": "2026-08-10 00:00:00",
  1426. }]
  1427. tencent.get_ad_metrics.return_value = {
  1428. 2: {"impressions": 0, "cost_fen": 0}
  1429. }
  1430. captured = []
  1431. with TemporaryDirectory() as directory, patch.dict(
  1432. os.environ,
  1433. {
  1434. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1435. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1436. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1437. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1438. },
  1439. clear=False,
  1440. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1441. cleanup,
  1442. "resolve_end_date",
  1443. side_effect=RuntimeError("review source unavailable"),
  1444. ), patch.object(
  1445. cleanup,
  1446. "fetch_active_creative_inventory",
  1447. return_value=[{
  1448. "account_id": 1,
  1449. "adgroup_id": 2,
  1450. "creative_id": 3,
  1451. "create_time": datetime(
  1452. 2026,
  1453. 8,
  1454. 13,
  1455. 10,
  1456. tzinfo=ZoneInfo("Asia/Shanghai"),
  1457. ),
  1458. }],
  1459. ), patch.object(
  1460. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1461. ), patch.object(
  1462. cleanup,
  1463. "upsert_cleanup_candidate",
  1464. side_effect=lambda record: captured.append(record) or {"id": 1},
  1465. ), patch.object(
  1466. cleanup, "load_retryable_cleanup_items", return_value=[]
  1467. ), patch.object(
  1468. cleanup, "load_unnotified_deleted_items", return_value=[]
  1469. ):
  1470. summary = cleanup.run_rejected_creative_cleanup(
  1471. output_dir=Path(directory),
  1472. now=current,
  1473. tencent=tencent,
  1474. odps=Mock(),
  1475. )
  1476. self.assertEqual(
  1477. [row["cleanup_rule_type"] for row in captured],
  1478. [cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE],
  1479. )
  1480. self.assertEqual(summary["ad_discovered"], 1)
  1481. self.assertEqual(summary["performance_discovered"], 1)
  1482. self.assertIn("review source unavailable", summary["review_scope_error"])
  1483. self.assertTrue(summary["scan_errors"])
  1484. tencent.get_dynamic_creative_metrics.assert_not_called()
  1485. def test_performance_apply_does_not_require_agency_route(self):
  1486. import pandas as pd
  1487. from tools import creative_rejection_cleanup as cleanup
  1488. @contextmanager
  1489. def acquired_lock(_name):
  1490. yield True
  1491. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1492. created_at = datetime(
  1493. 2026,
  1494. 8,
  1495. 13,
  1496. 10,
  1497. tzinfo=ZoneInfo("Asia/Shanghai"),
  1498. )
  1499. creative = {
  1500. "dynamic_creative_id": 3,
  1501. "dynamic_creative_name": "创意三",
  1502. "adgroup_id": 2,
  1503. "configured_status": "AD_STATUS_NORMAL",
  1504. "creative_set_approval_status": (
  1505. "CREATIVE_SET_APPROVAL_STATUS_NORMAL"
  1506. ),
  1507. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1508. }
  1509. ad = {
  1510. "adgroup_id": 2,
  1511. "adgroup_name": "广告二",
  1512. "configured_status": "AD_STATUS_NORMAL",
  1513. }
  1514. tencent = Mock()
  1515. tencent.get_dynamic_creatives.return_value = [creative]
  1516. tencent.get_ads.return_value = [ad]
  1517. tencent.get_dynamic_creative.return_value = creative
  1518. tencent.get_ad.return_value = ad
  1519. tencent.get_dynamic_creative_metrics.side_effect = [
  1520. {3: {"impressions": 99, "cost_fen": 0}},
  1521. {3: {"impressions": 0, "cost_fen": 0}},
  1522. {3: {"impressions": 99, "cost_fen": 0}},
  1523. {3: {"impressions": 0, "cost_fen": 0}},
  1524. ]
  1525. tencent.delete_dynamic_creative.return_value = {
  1526. "dynamic_creative_id": 3,
  1527. "configured_status": "AD_STATUS_DELETED",
  1528. }
  1529. retry_item = {
  1530. "id": 7,
  1531. "account_id": 1,
  1532. "account_name": "账户一",
  1533. "agency_name": "",
  1534. "adgroup_id": 2,
  1535. "adgroup_name": "广告二",
  1536. "dynamic_creative_id": 3,
  1537. "dynamic_creative_name": "创意三",
  1538. "cleanup_action": cleanup.DELETE_CREATIVE,
  1539. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  1540. "cleanup_status": "DISCOVERED",
  1541. "creative_created_at": created_at,
  1542. }
  1543. with TemporaryDirectory() as directory, patch.dict(
  1544. os.environ,
  1545. {
  1546. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1547. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1548. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "1",
  1549. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1550. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1551. },
  1552. clear=False,
  1553. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1554. cleanup, "resolve_end_date", return_value="20260818"
  1555. ), patch.object(
  1556. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1557. ), patch.object(
  1558. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1559. ), patch.object(
  1560. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1561. ), patch.object(
  1562. cleanup,
  1563. "fetch_active_creative_inventory",
  1564. return_value=[{
  1565. "account_id": 1,
  1566. "adgroup_id": 2,
  1567. "creative_id": 3,
  1568. "create_time": created_at,
  1569. }],
  1570. ), patch.object(
  1571. cleanup,
  1572. "prefetch_account_access_tokens",
  1573. return_value={1: "access-token"},
  1574. ), patch.object(
  1575. cleanup, "upsert_cleanup_candidate"
  1576. ), patch.object(
  1577. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  1578. ), patch.object(
  1579. cleanup, "claim_cleanup_item", return_value=True
  1580. ), patch.object(
  1581. cleanup, "update_cleanup_item", return_value=True
  1582. ), patch.object(
  1583. cleanup, "load_unnotified_deleted_items", return_value=[]
  1584. ), patch.object(
  1585. cleanup, "advisory_lock", side_effect=acquired_lock
  1586. ):
  1587. summary = cleanup.run_rejected_creative_cleanup(
  1588. output_dir=Path(directory),
  1589. now=current,
  1590. tencent=tencent,
  1591. odps=Mock(),
  1592. review_fetcher=Mock(return_value=[]),
  1593. )
  1594. tencent.delete_dynamic_creative.assert_called_once_with(1, 3)
  1595. self.assertEqual(summary["deleted"], 1)
  1596. self.assertEqual(summary["deferred"], 0)
  1597. def test_zero_spend_ad_apply_runs_after_reconfirmation(self):
  1598. import pandas as pd
  1599. from tools import creative_rejection_cleanup as cleanup
  1600. @contextmanager
  1601. def acquired_lock(_name):
  1602. yield True
  1603. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1604. ad = {
  1605. "adgroup_id": 2,
  1606. "adgroup_name": "广告二",
  1607. "configured_status": "AD_STATUS_NORMAL",
  1608. "created_time": "2026-08-10 00:00:00",
  1609. }
  1610. tencent = Mock()
  1611. tencent.get_dynamic_creatives.return_value = [{
  1612. "dynamic_creative_id": 3,
  1613. "adgroup_id": 2,
  1614. "configured_status": "AD_STATUS_NORMAL",
  1615. "creative_set_approval_status": (
  1616. "CREATIVE_SET_APPROVAL_STATUS_PENDING"
  1617. ),
  1618. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1619. }]
  1620. tencent.get_ads.return_value = [ad]
  1621. tencent.get_ad_metrics.return_value = {
  1622. 2: {"impressions": 0, "cost_fen": 0}
  1623. }
  1624. tencent.get_ad.return_value = ad
  1625. tencent.delete_ad.return_value = {
  1626. "adgroup_id": 2,
  1627. "configured_status": "AD_STATUS_DELETED",
  1628. }
  1629. retry_item = {
  1630. "id": 9,
  1631. "account_id": 1,
  1632. "account_name": "账户一",
  1633. "agency_name": "",
  1634. "adgroup_id": 2,
  1635. "adgroup_name": "广告二",
  1636. "dynamic_creative_id": -2,
  1637. "dynamic_creative_name": "",
  1638. "cleanup_action": cleanup.DELETE_AD,
  1639. "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  1640. "cleanup_status": "DISCOVERED",
  1641. }
  1642. with TemporaryDirectory() as directory, patch.dict(
  1643. os.environ,
  1644. {
  1645. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1646. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1647. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1648. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1649. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "1",
  1650. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1651. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1652. },
  1653. clear=False,
  1654. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1655. cleanup, "resolve_end_date", return_value="20260818"
  1656. ), patch.object(
  1657. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1658. ), patch.object(
  1659. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1660. ), patch.object(
  1661. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1662. ), patch.object(
  1663. cleanup,
  1664. "fetch_active_creative_inventory",
  1665. return_value=[{
  1666. "account_id": 1,
  1667. "adgroup_id": 2,
  1668. "creative_id": 3,
  1669. "create_time": datetime(
  1670. 2026,
  1671. 8,
  1672. 10,
  1673. tzinfo=ZoneInfo("Asia/Shanghai"),
  1674. ),
  1675. }],
  1676. ), patch.object(
  1677. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1678. ), patch.object(
  1679. cleanup, "upsert_cleanup_candidate"
  1680. ), patch.object(
  1681. cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
  1682. ), patch.object(
  1683. cleanup, "claim_cleanup_item", return_value=True
  1684. ), patch.object(
  1685. cleanup, "update_cleanup_item", return_value=True
  1686. ), patch.object(
  1687. cleanup, "load_unnotified_deleted_items", return_value=[]
  1688. ), patch.object(
  1689. cleanup, "advisory_lock", side_effect=acquired_lock
  1690. ):
  1691. summary = cleanup.run_rejected_creative_cleanup(
  1692. output_dir=Path(directory),
  1693. now=current,
  1694. tencent=tencent,
  1695. odps=Mock(),
  1696. review_fetcher=Mock(return_value=[]),
  1697. )
  1698. tencent.delete_ad.assert_called_once_with(1, 2)
  1699. self.assertEqual(summary["deleted"], 1)
  1700. self.assertEqual(summary["ad_discovered"], 1)
  1701. def test_write_lock_recheck_blocks_deletes_when_today_spend_appears(self):
  1702. import pandas as pd
  1703. from tools import creative_rejection_cleanup as cleanup
  1704. @contextmanager
  1705. def acquired_lock(_name):
  1706. yield True
  1707. current = datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai"))
  1708. created_at = datetime(
  1709. 2026,
  1710. 8,
  1711. 13,
  1712. 10,
  1713. tzinfo=ZoneInfo("Asia/Shanghai"),
  1714. )
  1715. creative = {
  1716. "dynamic_creative_id": 3,
  1717. "dynamic_creative_name": "创意三",
  1718. "adgroup_id": 2,
  1719. "configured_status": "AD_STATUS_NORMAL",
  1720. "creative_set_approval_status": cleanup.CREATIVE_NORMAL_STATUS,
  1721. "system_status": "DYNAMIC_CREATIVE_STATUS_NORMAL",
  1722. }
  1723. ad = {
  1724. "adgroup_id": 2,
  1725. "adgroup_name": "广告二",
  1726. "configured_status": "AD_STATUS_NORMAL",
  1727. "created_time": "2026-08-10 00:00:00",
  1728. }
  1729. tencent = Mock()
  1730. tencent.get_dynamic_creatives.return_value = [creative]
  1731. tencent.get_ads.return_value = [ad]
  1732. tencent.get_dynamic_creative.return_value = creative
  1733. tencent.get_ad.return_value = ad
  1734. tencent.get_dynamic_creative_metrics.side_effect = [
  1735. {3: {"impressions": 99, "cost_fen": 0}},
  1736. {3: {"impressions": 0, "cost_fen": 0}},
  1737. {3: {"impressions": 99, "cost_fen": 0}},
  1738. {3: {"impressions": 1, "cost_fen": 0}},
  1739. ]
  1740. tencent.get_ad_metrics.side_effect = [
  1741. {2: {"impressions": 0, "cost_fen": 0}},
  1742. {2: {"impressions": 0, "cost_fen": 0}},
  1743. {2: {"impressions": 0, "cost_fen": 0}},
  1744. {2: {"impressions": 0, "cost_fen": 1}},
  1745. ]
  1746. retry_items = [
  1747. {
  1748. "id": 7,
  1749. "account_id": 1,
  1750. "agency_name": "",
  1751. "adgroup_id": 2,
  1752. "dynamic_creative_id": 3,
  1753. "cleanup_action": cleanup.DELETE_CREATIVE,
  1754. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  1755. "cleanup_status": "DISCOVERED",
  1756. "creative_created_at": created_at,
  1757. },
  1758. {
  1759. "id": 8,
  1760. "account_id": 1,
  1761. "agency_name": "",
  1762. "adgroup_id": 2,
  1763. "dynamic_creative_id": -2,
  1764. "cleanup_action": cleanup.DELETE_AD,
  1765. "cleanup_rule_type": cleanup.PERFORMANCE_AD_ZERO_SPEND_RULE,
  1766. "cleanup_status": "DISCOVERED",
  1767. },
  1768. ]
  1769. with TemporaryDirectory() as directory, patch.dict(
  1770. os.environ,
  1771. {
  1772. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1773. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1774. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "1",
  1775. "DAILY_UNDERPERFORMING_AD_CLEANUP_ENABLED": "1",
  1776. "DAILY_UNDERPERFORMING_AD_APPLY_ENABLED": "1",
  1777. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1778. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1779. },
  1780. clear=False,
  1781. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1782. cleanup, "resolve_end_date", return_value="20260818"
  1783. ), patch.object(
  1784. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1785. ), patch.object(
  1786. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1787. ), patch.object(
  1788. cleanup, "fetch_account_agency_fallbacks", return_value={}
  1789. ), patch.object(
  1790. cleanup,
  1791. "fetch_active_creative_inventory",
  1792. return_value=[{
  1793. "account_id": 1,
  1794. "adgroup_id": 2,
  1795. "creative_id": 3,
  1796. "create_time": created_at,
  1797. }],
  1798. ), patch.object(
  1799. cleanup, "prefetch_account_access_tokens", return_value={1: "token"}
  1800. ), patch.object(
  1801. cleanup, "upsert_cleanup_candidate"
  1802. ), patch.object(
  1803. cleanup, "load_retryable_cleanup_items", return_value=retry_items
  1804. ), patch.object(
  1805. cleanup, "claim_cleanup_item", return_value=True
  1806. ), patch.object(
  1807. cleanup, "update_cleanup_item", return_value=True
  1808. ), patch.object(
  1809. cleanup, "load_unnotified_deleted_items", return_value=[]
  1810. ), patch.object(
  1811. cleanup, "advisory_lock", side_effect=acquired_lock
  1812. ):
  1813. summary = cleanup.run_rejected_creative_cleanup(
  1814. output_dir=Path(directory),
  1815. now=current,
  1816. tencent=tencent,
  1817. odps=Mock(),
  1818. review_fetcher=Mock(return_value=[]),
  1819. )
  1820. tencent.delete_dynamic_creative.assert_not_called()
  1821. tencent.delete_ad.assert_not_called()
  1822. self.assertEqual(summary["deleted"], 0)
  1823. self.assertEqual(tencent.get_dynamic_creative_metrics.call_count, 4)
  1824. self.assertEqual(tencent.get_ad_metrics.call_count, 4)
  1825. def test_performance_notification_sends_internal_message_without_webhook(self):
  1826. import pandas as pd
  1827. from tools import creative_rejection_cleanup as cleanup
  1828. @contextmanager
  1829. def acquired_lock(_name):
  1830. yield True
  1831. pending = {
  1832. "id": 7,
  1833. "check_date": date(2026, 8, 19),
  1834. "account_id": 1,
  1835. "adgroup_id": 2,
  1836. "dynamic_creative_id": 3,
  1837. "agency_name": "代理一",
  1838. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  1839. "cleanup_status": "DISCOVERED",
  1840. "operator_notified_at": None,
  1841. "agency_notified_at": None,
  1842. }
  1843. performance_report = {
  1844. "run_id": "performance_20260819_test",
  1845. "report": "unused.xlsx",
  1846. "title": "长期未起量创意清理汇总通知",
  1847. "notification_kind": "performance_internal",
  1848. "creative_rows": 1,
  1849. }
  1850. with TemporaryDirectory() as directory, patch.dict(
  1851. os.environ,
  1852. {
  1853. "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "0",
  1854. "DAILY_UNDERPERFORMING_CREATIVE_CLEANUP_ENABLED": "1",
  1855. "DAILY_UNDERPERFORMING_CREATIVE_APPLY_ENABLED": "0",
  1856. "ROI_AGENCY_WEBHOOK_ENABLED": "0",
  1857. "FEISHU_AD_PROJECT_CHAT_ID": "chat-internal",
  1858. },
  1859. clear=False,
  1860. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1861. cleanup, "resolve_end_date", return_value="20260818"
  1862. ), patch.object(
  1863. cleanup, "fetch_daily_data", return_value=pd.DataFrame()
  1864. ), patch.object(
  1865. cleanup, "fetch_recent_spend_accounts", return_value=[]
  1866. ), patch.object(
  1867. cleanup, "fetch_active_creative_inventory", return_value=[]
  1868. ), patch.object(
  1869. cleanup, "prefetch_account_access_tokens", return_value={}
  1870. ), patch.object(
  1871. cleanup, "load_retryable_cleanup_items", return_value=[]
  1872. ), patch.object(
  1873. cleanup,
  1874. "load_unnotified_deleted_items",
  1875. side_effect=[[pending], [pending]],
  1876. ), patch.object(
  1877. cleanup, "advisory_lock", side_effect=acquired_lock
  1878. ), patch.object(
  1879. cleanup,
  1880. "write_performance_operator_summary",
  1881. return_value=performance_report,
  1882. ), patch.object(
  1883. cleanup,
  1884. "publish_cleanup_operator_summary",
  1885. return_value={"route": "长期未起量清理汇总", "status": "SENT"},
  1886. ) as publish_internal, patch.object(
  1887. cleanup, "publish_agency_reports"
  1888. ) as publish_agency, patch.object(
  1889. cleanup, "mark_cleanup_items_operator_notified"
  1890. ) as mark_internal:
  1891. summary = cleanup.run_rejected_creative_cleanup(
  1892. output_dir=Path(directory),
  1893. now=datetime(
  1894. 2026,
  1895. 8,
  1896. 19,
  1897. 11,
  1898. tzinfo=ZoneInfo("Asia/Shanghai"),
  1899. ),
  1900. tencent=Mock(),
  1901. odps=Mock(),
  1902. publisher=Mock(),
  1903. )
  1904. publish_agency.assert_not_called()
  1905. publish_internal.assert_called_once()
  1906. self.assertEqual(
  1907. publish_internal.call_args.kwargs["chat_id"],
  1908. "chat-internal",
  1909. )
  1910. mark_internal.assert_called_once_with(
  1911. [7],
  1912. datetime(2026, 8, 19, 11, tzinfo=ZoneInfo("Asia/Shanghai")),
  1913. )
  1914. def test_force_preview_resends_already_notified_performance_rows(self):
  1915. from tools import creative_rejection_cleanup as cleanup
  1916. @contextmanager
  1917. def acquired_lock(_name):
  1918. yield True
  1919. current = datetime(
  1920. 2026, 8, 20, 16, 30, tzinfo=ZoneInfo("Asia/Shanghai")
  1921. )
  1922. pending = {
  1923. "id": 9,
  1924. "check_date": current.date(),
  1925. "account_id": 1,
  1926. "adgroup_id": 2,
  1927. "dynamic_creative_id": 3,
  1928. "cleanup_rule_type": cleanup.PERFORMANCE_NEW_RULE,
  1929. "cleanup_status": "DISCOVERED",
  1930. "operator_notified_at": datetime(2026, 8, 20, 11),
  1931. "agency_notified_at": datetime(2026, 8, 20, 11),
  1932. }
  1933. report = {
  1934. "run_id": "performance_2026-08-20_digest",
  1935. "report": "unused.xlsx",
  1936. "title": "长期未起量创意及广告清理汇总通知",
  1937. "notification_kind": "performance_internal",
  1938. "creative_rows": 1,
  1939. "ad_rows": 0,
  1940. }
  1941. with TemporaryDirectory() as directory, patch.dict(
  1942. os.environ,
  1943. {"FEISHU_AD_PROJECT_CHAT_ID": "chat-internal"},
  1944. clear=False,
  1945. ), patch.object(cleanup, "initialize_schema"), patch.object(
  1946. cleanup, "fetch_active_creative_inventory", return_value=[]
  1947. ), patch.object(
  1948. cleanup, "fetch_tencent_account_metadata", return_value={}
  1949. ), patch.object(
  1950. cleanup, "prefetch_account_access_tokens", return_value={}
  1951. ), patch.object(
  1952. cleanup, "load_retryable_cleanup_items", return_value=[]
  1953. ), patch.object(
  1954. cleanup,
  1955. "load_unnotified_deleted_items",
  1956. side_effect=[[pending], [pending]],
  1957. ) as load_notifications, patch.object(
  1958. cleanup,
  1959. "filter_preview_notification_rows",
  1960. side_effect=lambda rows, **_kwargs: [
  1961. {**row, "operator_notified_at": None} for row in rows
  1962. ],
  1963. ), patch.object(
  1964. cleanup, "advisory_lock", side_effect=acquired_lock
  1965. ), patch.object(
  1966. cleanup,
  1967. "write_performance_operator_summary",
  1968. return_value=report,
  1969. ), patch.object(
  1970. cleanup,
  1971. "publish_cleanup_operator_summary",
  1972. return_value={"route": "长期未起量清理汇总", "status": "SENT"},
  1973. ) as publish, patch.object(
  1974. cleanup, "mark_cleanup_items_operator_notified"
  1975. ):
  1976. summary = cleanup.run_rejected_creative_cleanup(
  1977. output_dir=Path(directory),
  1978. now=current,
  1979. tencent=Mock(),
  1980. odps=Mock(),
  1981. publisher=Mock(),
  1982. underperformance_preview_only=True,
  1983. force_notification=True,
  1984. )
  1985. self.assertTrue(summary["force_notification"])
  1986. self.assertTrue(
  1987. publish.call_args.kwargs["run_id"].startswith(
  1988. "performance_2026-08-20_digest_force_"
  1989. )
  1990. )
  1991. self.assertIn("手动重发", publish.call_args.kwargs["report"]["title"])
  1992. for call in load_notifications.call_args_list:
  1993. self.assertTrue(call.kwargs["include_notified"])
  1994. self.assertEqual(summary["notification_errors"], [])
  1995. if __name__ == "__main__":
  1996. unittest.main()