| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- from __future__ import annotations
- import sys
- import unittest
- from datetime import datetime
- from pathlib import Path
- from unittest.mock import Mock, patch
- from zoneinfo import ZoneInfo
- HERE = Path(__file__).resolve().parent
- ROOT = HERE.parents[1]
- for path in (ROOT, HERE):
- if str(path) not in sys.path:
- sys.path.insert(0, str(path))
- from agent.tools.builtin.feishu.feishu_client import (
- ChatType,
- FeishuClient,
- FeishuMessageEvent,
- )
- from command_intent_parser import CommandIntentParser, IntentParserConfig
- from feishu_command_service import FeishuCommandService
- from operator_commands import (
- ACTION_DAY_PAUSE,
- ACTION_STOP,
- ACTION_TODAY_SPEND,
- SCOPE_ACCOUNTS,
- SCOPE_ALL,
- SCOPE_AUTOMATION,
- SCOPE_MISSING,
- CommandIntent,
- parse_deterministic_intent,
- )
- from operator_control import _execute_pause, pause_status_summary, preview_write_command
- from realtime_config import RealtimeControlConfig
- from run_scheduler import next_wake
- from tencent_client import TencentClient, TencentWriteRejectedError
- from today_spend_query import query_today_spend
- SHANGHAI = ZoneInfo("Asia/Shanghai")
- class NaturalCommandParsingTest(unittest.TestCase):
- def test_bare_pause_requires_scope(self) -> None:
- intent = parse_deterministic_intent("暂停")
- self.assertIsNotNone(intent)
- self.assertEqual(ACTION_DAY_PAUSE, intent.action)
- self.assertFalse(intent.complete)
- self.assertEqual(("scope",), intent.missing_fields)
- def test_stop_today_is_temporary_and_all_managed(self) -> None:
- intent = parse_deterministic_intent("停止今天所有账户的投放")
- self.assertEqual(ACTION_DAY_PAUSE, intent.action)
- self.assertEqual(SCOPE_ALL, intent.scope_type)
- self.assertTrue(intent.complete)
- def test_automation_accounts_means_all_managed(self) -> None:
- intent = parse_deterministic_intent("停止自动化账户")
- self.assertEqual(ACTION_STOP, intent.action)
- self.assertEqual(SCOPE_ALL, intent.scope_type)
- def test_model_cannot_invent_account_ids(self) -> None:
- parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
- intent = parser._validate_model_intent(
- {
- "action": "STOP",
- "scope_type": "ACCOUNTS",
- "missing_fields": [],
- "confidence": 0.99,
- },
- "把那个账户停掉",
- )
- self.assertEqual(SCOPE_MISSING, intent.scope_type)
- self.assertFalse(intent.complete)
- self.assertEqual((), intent.account_ids)
- def test_source_account_id_overrides_model_all_scope(self) -> None:
- parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
- intent = parser._validate_model_intent(
- {
- "action": "STOP",
- "scope_type": "ALL",
- "missing_fields": [],
- "confidence": 0.99,
- },
- "把86748335停掉",
- )
- self.assertEqual(SCOPE_ACCOUNTS, intent.scope_type)
- self.assertEqual((86748335,), intent.account_ids)
- def test_deterministic_action_is_not_overridden_by_model(self) -> None:
- parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
- with patch.object(
- parser,
- "_parse_with_model",
- return_value=CommandIntent(
- action=ACTION_STOP,
- scope_type=SCOPE_ALL,
- parse_source="llm",
- ),
- ):
- intent = parser.understand("今天让这批自动投放账户先歇一下")
- self.assertEqual(ACTION_DAY_PAUSE, intent.action)
- self.assertEqual(SCOPE_ALL, intent.scope_type)
- def test_incomplete_standard_command_survives_model_failure(self) -> None:
- parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
- with patch.object(
- parser,
- "_parse_with_model",
- side_effect=RuntimeError("model unavailable"),
- ):
- intent = parser.understand("暂停")
- self.assertEqual(ACTION_DAY_PAUSE, intent.action)
- self.assertEqual(("scope",), intent.missing_fields)
- def test_model_selects_automation_scope_for_today_spend(self) -> None:
- parser = CommandIntentParser(IntentParserConfig(True, "test", 1, 0.8))
- intent = parser._validate_model_intent(
- {
- "action": "TODAY_SPEND",
- "scope_type": "AUTOMATION",
- "missing_fields": [],
- "confidence": 0.99,
- },
- "查询今天自动化账户消耗",
- )
- self.assertEqual(ACTION_TODAY_SPEND, intent.action)
- self.assertEqual(SCOPE_AUTOMATION, intent.scope_type)
- self.assertTrue(intent.complete)
- class TodaySpendQueryTest(unittest.TestCase):
- class Tencent:
- def get_today_account_metrics(self, account_id, data_date):
- return {
- "cost_fen": account_id,
- "impressions": 100,
- "clicks": 10,
- "conversions": 2,
- }
- def test_all_scope_returns_summary_only(self) -> None:
- now = datetime(2026, 7, 30, 12, 0, tzinfo=SHANGHAI)
- with patch(
- "today_spend_query.load_all_spend_accounts",
- return_value=[{"account_id": 10000001}, {"account_id": 10000002}],
- ):
- summary = query_today_spend(
- CommandIntent(
- action=ACTION_TODAY_SPEND,
- scope_type=SCOPE_ALL,
- ).to_parsed_command(),
- now=now,
- tencent=self.Tencent(),
- )
- self.assertEqual(2, summary["account_count"])
- self.assertEqual(20000003, summary["cost_fen"])
- self.assertEqual(200, summary["impressions"])
- self.assertTrue(summary["complete"])
- class FeishuAuthorizationTest(unittest.TestCase):
- def setUp(self) -> None:
- self.service = FeishuCommandService.__new__(FeishuCommandService)
- self.service.allowed_chat_id = "oc_allowed"
- self.service.allowed_open_ids = {"ou_allowed"}
- def _event(self, *, mentioned: bool, chat_type: ChatType = ChatType.GROUP):
- return FeishuMessageEvent(
- message_id="om_1",
- chat_id="oc_allowed",
- chat_type=chat_type,
- content="暂停全部",
- content_type="text",
- sender_open_id="ou_allowed",
- mentioned_bot=mentioned,
- )
- def test_group_message_without_mention_is_ignored(self) -> None:
- self.assertFalse(self.service._authorized(self._event(mentioned=False)))
- def test_private_message_is_ignored(self) -> None:
- self.assertFalse(
- self.service._authorized(
- self._event(mentioned=True, chat_type=ChatType.P2P)
- )
- )
- class FeishuMentionDetectionTest(unittest.TestCase):
- def setUp(self) -> None:
- self.client = FeishuClient.__new__(FeishuClient)
- self.client._bot_open_id = "ou_bot"
- @staticmethod
- def _mention(open_id: str) -> Mock:
- mention = Mock()
- mention.id.open_id = open_id
- return mention
- def test_mentioning_another_user_is_not_bot_mention(self) -> None:
- self.assertFalse(
- self.client._check_bot_mentioned([self._mention("ou_other")])
- )
- def test_mentioning_bot_is_bot_mention(self) -> None:
- self.assertTrue(
- self.client._check_bot_mentioned([self._mention("ou_bot")])
- )
- def test_missing_bot_identity_fails_closed(self) -> None:
- self.client._bot_open_id = None
- with patch.object(
- self.client,
- "_load_bot_open_id",
- side_effect=RuntimeError("unavailable"),
- ):
- self.assertFalse(
- self.client._check_bot_mentioned([self._mention("ou_other")])
- )
- class PreviewSnapshotTest(unittest.TestCase):
- class FakeTencent:
- def get_ads(self, account_id: int):
- if account_id == 10000001:
- return [
- {
- "adgroup_id": 101,
- "adgroup_name": "active",
- "configured_status": "AD_STATUS_NORMAL",
- "begin_date": "2026-07-01",
- "end_date": "0",
- },
- {
- "adgroup_id": 102,
- "adgroup_name": "manual-off",
- "configured_status": "AD_STATUS_SUSPEND",
- },
- {
- "adgroup_id": 103,
- "adgroup_name": "already-deferred",
- "configured_status": "AD_STATUS_NORMAL",
- "begin_date": "2026-07-31",
- },
- ]
- return []
- def get_today_ad_metrics(self, account_id, adgroup_ids, data_date):
- return {
- 101: {
- "cost_fen": 12345,
- "impressions": 1000,
- "clicks": 20,
- "conversions": 3,
- }
- }
- def test_preview_uses_impacted_accounts_and_freezes_metrics(self) -> None:
- captured = {}
- def persist(record, items):
- captured["record"] = record
- captured["items"] = items
- return {**record}
- now = datetime(2026, 7, 30, 12, 0, tzinfo=SHANGHAI)
- intent = parse_deterministic_intent("暂停全部")
- with (
- patch(
- "operator_control.load_realtime_accounts",
- return_value=[
- {"account_id": 10000001, "audience_name": "auto"},
- {"account_id": 10000002, "audience_name": "pause-only"},
- ],
- ),
- patch("operator_control.load_operator_pauses", return_value=[]),
- patch("operator_control.find_pending_command_conflict", return_value=None),
- patch("operator_control.create_operator_command_with_items", side_effect=persist),
- patch("operator_control.advisory_lock") as preview_lock,
- ):
- preview_lock.return_value.__enter__.return_value = True
- command = preview_write_command(
- intent.to_parsed_command(),
- now=now,
- source_message_id="om_1",
- chat_id="oc_1",
- sender_open_id="ou_1",
- sender_name="operator",
- confirmation_ttl_minutes=10,
- start_hour=6,
- tencent=self.FakeTencent(),
- )
- self.assertEqual(1, command["preview_account_count"])
- self.assertEqual([10000001], command["target_account_ids"])
- self.assertEqual(1, command["preview_ad_count"])
- self.assertEqual(12345, command["preview_cost_fen"])
- self.assertEqual(101, captured["items"][0]["adgroup_id"])
- self.assertEqual(3, captured["items"][0]["preview_conversions"])
- self.assertEqual(
- "AD_STATUS_NORMAL",
- captured["items"][0]["target_status"],
- )
- class DayPauseExecutionTest(unittest.TestCase):
- class FakeTencent:
- def __init__(self) -> None:
- self.date_updates = []
- def get_ads(self, account_id: int):
- return [{
- "adgroup_id": 101,
- "adgroup_name": "active",
- "configured_status": "AD_STATUS_NORMAL",
- "begin_date": "2026-07-01",
- "end_date": "0",
- }]
- def update_ad_begin_dates(self, account_id, adgroup_ids, begin_date):
- self.date_updates.append((account_id, adgroup_ids, begin_date))
- def update_ad(self, *args, **kwargs):
- raise AssertionError("DAY_PAUSE must not update configured_status")
- def test_day_pause_only_moves_begin_date_to_tomorrow(self) -> None:
- client = self.FakeTencent()
- now = datetime(2026, 7, 30, 12, 0, tzinfo=SHANGHAI)
- with (
- patch(
- "operator_control.load_ad_states",
- return_value={101: {"operator_pause_mode": None}},
- ),
- patch("operator_control.set_operator_pause") as set_pause,
- patch("operator_control._record_item") as record_item,
- ):
- successes, failures, skipped = _execute_pause(
- {"command_id": "cmd_1", "action": ACTION_DAY_PAUSE},
- {"account_id": 10000001, "audience_name": "auto"},
- now=now,
- start_hour=6,
- client=client,
- items=[{"id": 1, "adgroup_id": 101}],
- )
- self.assertEqual((1, 0, 0), (successes, failures, skipped))
- self.assertEqual(
- [(10000001, [101], "2026-07-31")],
- client.date_updates,
- )
- self.assertEqual("UNTIL_NEXT_DELIVERY", set_pause.call_args.kwargs["mode"])
- self.assertEqual(
- "AD_STATUS_NORMAL",
- record_item.call_args.kwargs["target_status"],
- )
- def test_rejected_day_pause_clears_pending_pause_state(self) -> None:
- client = self.FakeTencent()
- now = datetime(2026, 7, 30, 12, 0, tzinfo=SHANGHAI)
- with (
- patch.object(
- client,
- "update_ad_begin_dates",
- side_effect=TencentWriteRejectedError("rejected"),
- ),
- patch(
- "operator_control.load_ad_states",
- return_value={101: {"operator_pause_mode": None}},
- ),
- patch("operator_control.set_operator_pause"),
- patch("operator_control.clear_operator_pause") as clear_pause,
- patch("operator_control._record_item"),
- ):
- result = _execute_pause(
- {"command_id": "cmd_1", "action": ACTION_DAY_PAUSE},
- {"account_id": 10000001, "audience_name": "auto"},
- now=now,
- start_hour=6,
- client=client,
- items=[{"id": 1, "adgroup_id": 101}],
- )
- self.assertEqual((0, 1, 0), result)
- clear_pause.assert_called_once_with(
- 10000001,
- 101,
- action="OPERATOR_PAUSE_FAILED",
- action_at=now,
- )
- def test_scheduler_waits_for_cpm_start_not_delivery_start(self) -> None:
- config = RealtimeControlConfig(start_hour=12, next_delivery_hour=6)
- now = datetime(2026, 7, 30, 1, 0, tzinfo=SHANGHAI)
- self.assertEqual(12, next_wake(now, config).hour)
- def test_expired_day_pause_is_not_reported_as_active(self) -> None:
- now = datetime(2026, 7, 31, 8, 0, tzinfo=SHANGHAI)
- with (
- patch(
- "operator_control.load_realtime_accounts",
- return_value=[{"account_id": 10000001}],
- ),
- patch(
- "operator_control.load_operator_pauses",
- return_value=[{
- "account_id": 10000001,
- "adgroup_id": 101,
- "operator_pause_mode": "UNTIL_NEXT_DELIVERY",
- "operator_resume_at": datetime(2026, 7, 31, 6, 0),
- }],
- ),
- ):
- summary = pause_status_summary(now)
- self.assertEqual(0, summary["total"])
- class TencentDateUpdateClassificationTest(unittest.TestCase):
- def test_tencent_rejection_uses_rejected_error(self) -> None:
- client = TencentClient()
- response = Mock(status_code=200, text='{"code": 1900001}')
- response.json.return_value = {
- "code": 1900001,
- "message": "rejected",
- }
- with (
- patch.object(client, "_common_params", return_value={}),
- patch.object(client, "_user_token", return_value="token"),
- patch.object(client.session, "post", return_value=response),
- self.assertRaises(TencentWriteRejectedError),
- ):
- client.update_ad_begin_dates(10000001, [101], "2026-07-31")
- if __name__ == "__main__":
- unittest.main()
|