"""钉钉自定义机器人通知渠道测试。""" import base64 import hashlib import hmac from types import SimpleNamespace from unittest.mock import Mock from urllib.parse import parse_qs, urlsplit from app.modules.dingtalk import DingTalkModule from app.modules.dingtalk.dingtalk import DingTalk from app.schemas.message import Message from app.schemas.notification import ChannelCapability, ChannelCapabilityManager from app.schemas.types import MessageType, NotificationChannel def _response(payload: dict, status_code: int = 200) -> Mock: """构造带关闭能力的 requests.Response 测试替身。""" response = Mock(status_code=status_code) response.json.return_value = payload return response def test_signed_webhook_preserves_access_token_and_uses_official_signature() -> None: """签名应使用毫秒时间戳、换行分隔串和 HMAC-SHA256。""" client = DingTalk( DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=token", DINGTALK_SECRET="SEC-test", ) signed_url = client.build_request_url(timestamp=1720000000123) query = parse_qs(urlsplit(signed_url).query) expected = base64.b64encode( hmac.new( b"SEC-test", b"1720000000123\nSEC-test", digestmod=hashlib.sha256, ).digest() ).decode("utf-8") assert query == { "access_token": ["token"], "timestamp": ["1720000000123"], "sign": [expected], } def test_send_msg_posts_markdown_and_accepts_dingtalk_success() -> None: """普通通知应保留标题、正文、图片和详情链接并检查 errcode。""" client = DingTalk( DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=token" ) response = _response({"errcode": 0, "errmsg": "ok"}) client._req = Mock() client._req.post_res.return_value = response assert client.send_msg( title="整理完成", text="电影已入库", image="https://example.com/poster.jpg", link="https://example.com/history", ) is True request = client._req.post_res.call_args.kwargs assert request["url"].endswith("access_token=token") assert request["json"]["msgtype"] == "markdown" assert request["json"]["markdown"]["title"] == "整理完成" markdown = request["json"]["markdown"]["text"] assert "### 整理完成" in markdown assert "电影已入库" in markdown assert "![图片](https://example.com/poster.jpg)" in markdown assert "[查看详情](https://example.com/history)" in markdown response.close.assert_called_once_with() def test_send_msg_rejects_http_and_business_failures() -> None: """HTTP 失败或钉钉业务错误都不能被误判为发送成功。""" client = DingTalk( DINGTALK_WEBHOOK="https://oapi.dingtalk.com/robot/send?access_token=token" ) client._req = Mock() client._req.post_res.side_effect = [ _response({}, status_code=500), _response({"errcode": 310000, "errmsg": "keywords not in content"}), ] assert client.send_msg(title="测试") is False assert client.send_msg(title="测试") is False def test_module_routes_matching_notifications_to_dingtalk_client(monkeypatch) -> None: """模块应沿通知来源和消息类型契约向对应钉钉实例发送。""" module = DingTalkModule() module._channel = NotificationChannel.DingTalk client = Mock() config = SimpleNamespace(name="家庭群", switchs=[MessageType.Organize.value]) monkeypatch.setattr(module, "get_configs", lambda: {config.name: config}) monkeypatch.setattr(module, "get_config", lambda name=None: config) monkeypatch.setattr(module, "get_instance", lambda name=None: client) module.post_message( Message( channel=NotificationChannel.DingTalk, source="家庭群", mtype=MessageType.Organize, title="整理完成", text="测试内容", ) ) client.send_msg.assert_called_once() def test_dingtalk_channel_declares_markdown_image_and_link_capabilities() -> None: """能力表应允许通用消息层保留钉钉支持的富文本字段。""" for capability in ( ChannelCapability.MARKDOWN, ChannelCapability.IMAGES, ChannelCapability.LINKS, ): assert ChannelCapabilityManager.supports_capability( NotificationChannel.DingTalk, capability )