import pytest from app.services.integrations.feishu_integration_service.mixin_card_action import FeishuCardActionMixin from app.services.integrations.feishu_integration_service.mixin_receive_routing import FeishuReceiveRoutingMixin from app.services.integrations.feishu_integration_service.mixin_reply_templates import FeishuReplyTemplatesMixin from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService def test_extract_card_action_payload_parses_dict_value() -> None: payload = FeishuCardActionMixin._extract_card_action_payload( { "open_message_id": "om_123", "open_chat_id": "oc_123", "operator": {"open_id": "ou_abc", "name": "测试用户"}, "action": { "value": { "action": "confirm_draft", "work_item_id": 88, "draft_version": 3, "revision_note": "保持现有范围", } }, } ) assert payload["action"] == "confirm" assert payload["work_item_id"] == 88 assert payload["draft_version"] == 3 assert payload["source_message_id"] == "om_123" assert payload["chat_id"] == "oc_123" assert payload["sender_open_id"] == "ou_abc" def test_extract_card_action_payload_parses_json_string_value_and_aliases() -> None: payload = FeishuCardActionMixin._extract_card_action_payload( { "message_id": "msg_1", "chat_id": "chat_9", "operator": {"user_id": "ou_user", "display_name": "张三"}, "action": {"value": '{"draft_action":"修改","workItemId":"100","version":"4","note":"改成下周上线"}'}, } ) assert payload["action"] == "revise" assert payload["work_item_id"] == 100 assert payload["draft_version"] == 4 assert payload["revision_note"] == "改成下周上线" assert payload["source_message_id"] == "msg_1" assert payload["chat_id"] == "chat_9" def test_extract_card_action_payload_parses_plan_synthetic_text() -> None: payload = FeishuCardActionMixin._extract_card_action_payload( { "message_id": "msg_plan_1", "chat_id": "chat_plan_1", "operator": {"open_id": "ou_plan", "name": "计划用户"}, "action": {"value": {"action": "plan_choice", "choice": "C", "synthetic_text": "C"}}, } ) assert payload["action"] == "plan_choice" assert payload["choice"] == "C" assert payload["synthetic_text"] == "C" assert payload["source_message_id"] == "msg_plan_1" assert payload["chat_id"] == "chat_plan_1" @pytest.mark.asyncio async def test_handle_message_event_dispatches_card_action(monkeypatch) -> None: service = FeishuIntegrationService(db=object()) called: dict[str, object] = {} async def _fake_card_handler(event): called["event_type"] = event.get("type") return {"code": 0, "msg": "success", "data": {"handled": True}} monkeypatch.setattr(service, "_handle_card_action_trigger", _fake_card_handler) result = await service.handle_message_event({"type": "card.action.trigger", "event": {}}) assert result["code"] == 0 assert called["event_type"] == "card.action.trigger" def test_build_action_card_for_draft_pending_confirm() -> None: card = FeishuCardActionMixin._build_action_card_from_capture_result( capture_result={ "reply_template_key": "draft_pending_confirm", "work_item_id": 321, "draft_version": 5, "direct_reply_text": "📌 AI直播复盘助手\n面向跨境 DTC 团队的 AI 直播复盘工具\n\n✅ 当前判断\n可行性:待讨论\n\n⚠️ 仍需确认\n· 验收口径\n· 触发时机", }, mention_open_id="ou_xxx", mention_name="测试同学", ) assert isinstance(card, dict) assert card["header"]["title"]["content"] == "AI PMO 草稿待确认" div_blocks = [item for item in card["elements"] if item.get("tag") == "div"] assert len(div_blocks) >= 3 action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {}) actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else [] action_names = {((btn.get("value") or {}).get("action")) for btn in actions if isinstance(btn, dict)} assert {"confirm", "revise", "cancel"} <= action_names def test_build_action_card_for_pending_manual_continue() -> None: card = FeishuCardActionMixin._build_action_card_from_capture_result( capture_result={ "reply_template_key": "pending_manual", "results": [{"work_item_id": 99}], "direct_reply_text": "需要继续执行。", }, mention_open_id=None, mention_name=None, ) assert isinstance(card, dict) assert card["header"]["title"]["content"] == "AI PMO 待继续执行" action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {}) actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else [] assert len(actions) == 1 assert (actions[0].get("value") or {}).get("action") == "continue" assert (actions[0].get("value") or {}).get("work_item_id") == 99 def test_build_action_card_for_requirement_plan_followup() -> None: card = FeishuCardActionMixin._build_action_card_from_capture_result( capture_result={ "reply_template_key": "requirement_plan_followup", "work_item_id": 77, "direct_reply_text": "A. 规则引擎驱动\nB. 轻量模型辅助\nC. 混合增强路径\n\n⭐ 当前我更推荐:C", }, mention_open_id="ou_plan_xxx", mention_name="测试计划用户", ) assert isinstance(card, dict) assert card["header"]["title"]["content"] == "AI PMO 需求收敛" action_rows = [item for item in card["elements"] if item.get("tag") == "action"] assert len(action_rows) == 2 row_1_actions = action_rows[0].get("actions") if isinstance(action_rows[0].get("actions"), list) else [] row_2_actions = action_rows[1].get("actions") if isinstance(action_rows[1].get("actions"), list) else [] assert [((btn.get("value") or {}).get("synthetic_text")) for btn in row_1_actions] == ["A", "B", "C", "D"] assert [((btn.get("value") or {}).get("synthetic_text")) for btn in row_2_actions] == ["继续出草稿", "重出一版"] def test_build_execution_receipt_reply_prefers_compact_multiline_summary() -> None: reply = FeishuReplyTemplatesMixin._build_execution_receipt_reply( message_content="请处理这条信号", capture_result={ "status": "captured", "signal_id": 18, "linked_project_ids": [9], }, ) assert reply == "✅ 已执行完成\n信号#18 已入库\n项目#9 已关联" def test_build_action_card_for_queue_pending_confirm_returns_none() -> None: card = FeishuCardActionMixin._build_action_card_from_capture_result( capture_result={ "reply_template_key": "queue_pending_confirm", "results": [{"item_id": 201, "route_status": "queued_for_decision"}], "direct_reply_text": "需要你确认后继续执行。", }, mention_open_id=None, mention_name=None, ) assert card is None @pytest.mark.asyncio async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch) -> None: called: dict[str, object] = {} class DummyDB: async def commit(self) -> None: called["committed"] = True async def rollback(self) -> None: called["rolled_back"] = True service = FeishuIntegrationService(db=DummyDB()) service.tenant_id = 7 async def _fake_reply_text(**kwargs): called["reply_text"] = kwargs.get("text") called["reply_chat"] = kwargs.get("chat_id") monkeypatch.setattr(service, "_safe_reply_text", _fake_reply_text) class _FakeKernel: def __init__(self, db, tenant_id): called["kernel_tenant_id"] = tenant_id async def process_ingress(self, **kwargs): called["ingress_kwargs"] = kwargs return { "handled": True, "status": "captured", "direct_reply_text": "已继续执行。", } monkeypatch.setattr( "app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime.AIPMOKernelService", _FakeKernel, ) result = await service._handle_card_action_trigger( { "open_message_id": "om_continue_1", "open_chat_id": "oc_continue_1", "operator": {"open_id": "ou_continue", "name": "继续用户"}, "action": {"value": {"action": "continue", "work_item_id": 55}}, } ) assert result["code"] == 0 data = result.get("data") if isinstance(result.get("data"), dict) else {} assert data.get("handled") is True assert data.get("target_work_item_id") == 55 ingress_kwargs = called["ingress_kwargs"] assert ingress_kwargs["message_text"] == "继续处理 work_item #55" assert ingress_kwargs["source_system"] == "ai_pmo_ui" assert called["reply_text"] == "已继续执行。" assert called["reply_chat"] == "oc_continue_1" @pytest.mark.asyncio async def test_handle_card_action_trigger_plan_choice_dispatches_synthetic_ingress(monkeypatch) -> None: called: dict[str, object] = {} class DummyDB: async def commit(self) -> None: called["committed"] = True async def rollback(self) -> None: called["rolled_back"] = True service = FeishuIntegrationService(db=DummyDB()) service.tenant_id = 7 async def _fake_reply_card(**kwargs): called["reply_card"] = kwargs.get("card_content") called["reply_chat"] = kwargs.get("chat_id") monkeypatch.setattr(service, "_safe_reply_card", _fake_reply_card) class _FakeKernel: def __init__(self, db, tenant_id): called["kernel_tenant_id"] = tenant_id async def process_ingress(self, **kwargs): called["ingress_kwargs"] = kwargs return { "handled": True, "status": "captured", "reply_template_key": "requirement_plan_followup", "direct_reply_text": "C. 混合增强路径\n\n⭐ 当前我更推荐:C", "results": [{"work_item_id": 88}], "work_item_id": 88, } monkeypatch.setattr( "app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime.AIPMOKernelService", _FakeKernel, ) result = await service._handle_card_action_trigger( { "open_message_id": "om_plan_1", "open_chat_id": "oc_plan_1", "operator": {"open_id": "ou_plan", "name": "计划用户"}, "action": {"value": {"action": "plan_choice", "choice": "C", "synthetic_text": "C", "work_item_id": 88}}, } ) assert result["code"] == 0 data = result.get("data") if isinstance(result.get("data"), dict) else {} assert data.get("handled") is True assert data.get("target_work_item_id") == 88 ingress_kwargs = called["ingress_kwargs"] assert ingress_kwargs["message_text"] == "C" assert ingress_kwargs["source_system"] == "feishu" assert str(ingress_kwargs["source_message_id"]).startswith("feishu-card-plan-88-plan_choice-") ingress_options = ingress_kwargs.get("ingress_options") if isinstance(ingress_kwargs.get("ingress_options"), dict) else {} thread_context = ingress_options.get("thread_context") if isinstance(ingress_options.get("thread_context"), dict) else {} assert thread_context["reply_to_message_id"] == "om_plan_1" assert thread_context["root_message_id"] == "om_plan_1" assert called["reply_chat"] == "oc_plan_1" assert isinstance(called["reply_card"], dict) assert called["reply_card"]["header"]["title"]["content"] == "AI PMO 需求收敛" @pytest.mark.asyncio async def test_handle_card_action_trigger_queue_continue_is_ignored(monkeypatch) -> None: called: dict[str, object] = {} class DummyDB: async def commit(self) -> None: called["committed"] = True async def rollback(self) -> None: called["rolled_back"] = True service = FeishuIntegrationService(db=DummyDB()) service.tenant_id = 7 async def _fake_reply_text(**kwargs): called["reply_text"] = kwargs.get("text") monkeypatch.setattr(service, "_safe_reply_text", _fake_reply_text) result = await service._handle_card_action_trigger( { "open_message_id": "om_queue_1", "open_chat_id": "oc_queue_1", "operator": {"open_id": "ou_queue", "name": "队列用户"}, "action": {"value": {"action": "continue", "queue_item_id": 201}}, } ) assert result["code"] == 0 assert result["msg"] == "card_action_ignored" data = result.get("data") if isinstance(result.get("data"), dict) else {} assert data.get("reason") == "missing_work_item_id" assert "reply_text" not in called @pytest.mark.asyncio async def test_route_scene_reply_sends_requirement_plan_followup_as_card(monkeypatch) -> None: calls: dict[str, object] = {} class FakeRouting(FeishuReceiveRoutingMixin): def _build_capability_reply(self, message_content: str): # noqa: ANN001 _ = message_content return None def _build_action_card_from_capture_result(self, *, capture_result, mention_open_id, mention_name): # noqa: ANN001 _ = (mention_open_id, mention_name) if str(capture_result.get("reply_template_key") or "").strip().lower() == "requirement_plan_followup": return { "header": {"title": {"tag": "plain_text", "content": "AI PMO 需求收敛"}}, "elements": [], } return None async def _safe_reply_card(self, **kwargs): # noqa: ANN001 calls["reply_card"] = kwargs async def _safe_reply_text(self, **kwargs): # noqa: ANN001 calls["reply_text"] = kwargs def _emit_state_transition(self, **kwargs): # noqa: ANN001 calls["state_transition"] = kwargs def _should_allow_group_qa_reply(self, **kwargs): # noqa: ANN001 _ = kwargs return True routing = FakeRouting() result = await routing._route_scene_reply( scene="qa", result={ "reply_template_key": "requirement_plan_followup", "work_item_id": 88, "direct_reply_text": "C. 混合增强路径\n\n⭐ 当前我更推荐:C", }, message_content="C", chat_id="chat-plan-card", chat_type="group", sender_open_id="ou-plan", sender_name="计划用户", mentioned_bot_names=[], thread_context=None, trace_id="trace-plan-card", message_id="msg-plan-card", perf_t0=None, ) assert result == "qa_action_card" assert "reply_card" in calls assert "reply_text" not in calls card_content = calls["reply_card"]["card_content"] if isinstance(calls["reply_card"], dict) else {} assert card_content["header"]["title"]["content"] == "AI PMO 需求收敛"