PMFlow/backend/tests/test_feishu_card_action_con...

377 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"] == "ai_pmo_ui"
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 需求收敛"