|
|
@@ -751,6 +751,56 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
|
|
|
)
|
|
|
self.assertEqual(result["configured_status"], "AD_STATUS_DELETED")
|
|
|
|
|
|
+ def test_tencent_creative_delete_accepts_deleted_system_status(self):
|
|
|
+ from tencent_client import TencentClient
|
|
|
+
|
|
|
+ response = Mock(status_code=200, text="ok")
|
|
|
+ response.raise_for_status.return_value = None
|
|
|
+ response.json.return_value = {"code": 0, "data": {}}
|
|
|
+ client = TencentClient()
|
|
|
+ client.session.post = Mock(return_value=response)
|
|
|
+ client._common_params = Mock(return_value={"nonce": "1"})
|
|
|
+ client._user_token = Mock(return_value="user-token")
|
|
|
+ client.get_dynamic_creative = Mock(return_value={
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
|
|
|
+ "creative_set_approval_status": (
|
|
|
+ "CREATIVE_SET_APPROVAL_STATUS_PARTIAL_NORMAL"
|
|
|
+ ),
|
|
|
+ })
|
|
|
+
|
|
|
+ result = client.delete_dynamic_creative(1, 3)
|
|
|
+
|
|
|
+ self.assertEqual(result["system_status"], "DYNAMIC_CREATIVE_STATUS_DELETED")
|
|
|
+ self.assertFalse(result["deleted_from_listing"])
|
|
|
+ self.assertEqual(client.get_dynamic_creative.call_count, 1)
|
|
|
+
|
|
|
+ def test_scan_excludes_creative_with_deleted_system_status(self):
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
+
|
|
|
+ tencent = Mock()
|
|
|
+ tencent.get_dynamic_creatives.return_value = [{
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
|
|
|
+ "creative_set_approval_status": cleanup.CREATIVE_PARTIAL_NORMAL_STATUS,
|
|
|
+ }]
|
|
|
+ tencent.get_ads.return_value = []
|
|
|
+ review_fetcher = Mock(return_value=[])
|
|
|
+
|
|
|
+ result = cleanup._scan_one_account(
|
|
|
+ {"account_id": 1},
|
|
|
+ tencent=tencent,
|
|
|
+ review_fetcher=review_fetcher,
|
|
|
+ spend_start_date=date(2026, 8, 14),
|
|
|
+ spend_end_date=date(2026, 8, 16),
|
|
|
+ )
|
|
|
+
|
|
|
+ self.assertEqual(result[5], 0)
|
|
|
+ review_fetcher.assert_not_called()
|
|
|
+ tencent.get_dynamic_creative_costs.assert_not_called()
|
|
|
+
|
|
|
def test_tencent_creative_delete_retries_explicit_rate_limit_only(self):
|
|
|
import tencent_client
|
|
|
|
|
|
@@ -2440,6 +2490,79 @@ class CreativeRejectionCleanupTests(unittest.TestCase):
|
|
|
for _, values in updates
|
|
|
))
|
|
|
|
|
|
+ def test_unknown_write_result_recovers_from_deleted_status_without_redelete(self):
|
|
|
+ from tools import creative_rejection_cleanup as cleanup
|
|
|
+
|
|
|
+ @contextmanager
|
|
|
+ def acquired_lock(_name):
|
|
|
+ yield True
|
|
|
+
|
|
|
+ tencent = Mock()
|
|
|
+ tencent.get_dynamic_creatives.return_value = []
|
|
|
+ tencent.get_ads.return_value = []
|
|
|
+ tencent.get_dynamic_creative.return_value = {
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
+ "adgroup_id": 2,
|
|
|
+ "configured_status": "AD_STATUS_NORMAL",
|
|
|
+ "system_status": "DYNAMIC_CREATIVE_STATUS_DELETED",
|
|
|
+ "creative_set_approval_status": cleanup.CREATIVE_PARTIAL_NORMAL_STATUS,
|
|
|
+ }
|
|
|
+ retry_item = {
|
|
|
+ "id": 7,
|
|
|
+ "account_id": 1,
|
|
|
+ "agency_name": "代理A",
|
|
|
+ "adgroup_id": 2,
|
|
|
+ "dynamic_creative_id": 3,
|
|
|
+ "cleanup_action": cleanup.DELETE_CREATIVE,
|
|
|
+ "cleanup_status": "WRITE_OUTCOME_UNKNOWN",
|
|
|
+ }
|
|
|
+ updates = []
|
|
|
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
|
|
|
+ os.environ,
|
|
|
+ {
|
|
|
+ "DAILY_REJECTED_CREATIVE_APPLY_ENABLED": "1",
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED": "1",
|
|
|
+ "ROI_AGENCY_WEBHOOKS_JSON": json.dumps({
|
|
|
+ "代理A": (
|
|
|
+ "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
|
+ "test-cleanup-route"
|
|
|
+ )
|
|
|
+ }),
|
|
|
+ "FEISHU_AD_PROJECT_CHAT_ID": "chat-operator",
|
|
|
+ },
|
|
|
+ clear=False,
|
|
|
+ ), patch.object(cleanup, "initialize_schema"), patch.object(
|
|
|
+ cleanup, "resolve_end_date", return_value="20260812"
|
|
|
+ ), patch.object(cleanup, "fetch_daily_data", return_value=pd.DataFrame()), patch.object(
|
|
|
+ cleanup, "fetch_recent_spend_accounts", return_value=[{"account_id": 1}]
|
|
|
+ ), patch.object(cleanup, "fetch_account_agency_fallbacks", return_value={}), patch.object(
|
|
|
+ cleanup, "prefetch_account_access_tokens", return_value={}
|
|
|
+ ), patch.object(cleanup, "upsert_cleanup_candidate"), patch.object(
|
|
|
+ cleanup, "load_retryable_cleanup_items", return_value=[retry_item]
|
|
|
+ ), patch.object(
|
|
|
+ cleanup, "claim_cleanup_item", return_value=True
|
|
|
+ ), patch.object(
|
|
|
+ cleanup,
|
|
|
+ "update_cleanup_item",
|
|
|
+ side_effect=lambda item_id, **values: updates.append((item_id, values)),
|
|
|
+ ), patch.object(cleanup, "load_unnotified_deleted_items", return_value=[]), patch.object(
|
|
|
+ cleanup, "advisory_lock", side_effect=acquired_lock
|
|
|
+ ):
|
|
|
+ summary = cleanup.run_rejected_creative_cleanup(
|
|
|
+ output_dir=Path(directory),
|
|
|
+ now=datetime(2026, 8, 13, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
|
|
|
+ tencent=tencent,
|
|
|
+ odps=Mock(),
|
|
|
+ )
|
|
|
+
|
|
|
+ tencent.delete_dynamic_creative.assert_not_called()
|
|
|
+ self.assertEqual(summary["deleted"], 1)
|
|
|
+ self.assertTrue(any(
|
|
|
+ values.get("cleanup_status") == "CREATIVE_DELETED"
|
|
|
+ and "DYNAMIC_CREATIVE_STATUS_DELETED" in values.get("readback_json", "")
|
|
|
+ for _, values in updates
|
|
|
+ ))
|
|
|
+
|
|
|
def test_notification_channels_retry_independently(self):
|
|
|
from tools import creative_rejection_cleanup as cleanup
|
|
|
|