Files
daily_stock_analysis/tests/test_notification_sender.py

2022 lines
84 KiB
Python

# -*- coding: utf-8 -*-
"""
Unit tests for src.notification_sender module.
Tests sender classes in isolation (config, request shape, error handling).
Does not duplicate test_notification.py which tests NotificationService.send() flow.
"""
import base64
import hashlib
import hmac
import json
import os
import sys
import unittest
from email.header import decode_header, make_header
from email.utils import parseaddr
from types import SimpleNamespace
from unittest import mock
from typing import Optional
import requests
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from src.config import Config
from src.notification_sender import (
AstrbotSender,
CustomWebhookSender,
DiscordSender,
EmailSender,
FeishuSender,
GotifySender,
NtfySender,
PushoverSender,
PushplusSender,
Serverchan3Sender,
SlackSender,
TelegramSender,
WechatSender,
WECHAT_IMAGE_MAX_BYTES,
)
def _config(**overrides):
"""Minimal Config for sender tests."""
return Config(stock_list=[], **overrides)
def _response(status_code: int, json_body: Optional[dict] = None):
resp = mock.MagicMock()
resp.status_code = status_code
if status_code == 200:
resp.text = "ok"
else:
resp.text = "error"
if json_body is not None:
resp.json.return_value = json_body
return resp
def _sdk_response(success: bool, *, code: int = 0, msg: str = "ok", log_id: str = "log-id"):
resp = mock.MagicMock()
resp.success.return_value = success
resp.code = code
resp.msg = msg
resp.get_log_id.return_value = log_id
return resp
def _fake_feishu_client(*side_effects):
create = mock.Mock()
if side_effects:
create.side_effect = list(side_effects)
client = SimpleNamespace(
im=SimpleNamespace(
v1=SimpleNamespace(
message=SimpleNamespace(create=create)
)
)
)
return client, create
class TestDiscordSender(unittest.TestCase):
"""Unit tests for DiscordSender."""
def test_send_returns_false_when_not_configured(self):
cfg = _config()
sender = DiscordSender(cfg)
result = sender.send_to_discord("hello")
self.assertFalse(result)
def test_is_discord_configured_webhook_only(self):
cfg = _config(discord_webhook_url="https://discord.com/webhook/1")
sender = DiscordSender(cfg)
self.assertTrue(sender._is_discord_configured())
def test_is_discord_configured_bot_only(self):
cfg = _config(discord_bot_token="T", discord_main_channel_id="123")
sender = DiscordSender(cfg)
self.assertTrue(sender._is_discord_configured())
def test_is_discord_configured_neither(self):
cfg = _config()
sender = DiscordSender(cfg)
self.assertFalse(sender._is_discord_configured())
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_webhook_success_builds_correct_payload(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(discord_webhook_url="https://discord.com/webhook/1")
sender = DiscordSender(cfg)
result = sender.send_to_discord("content")
self.assertTrue(result)
mock_post.assert_called_once()
call_kw = mock_post.call_args[1]
self.assertEqual(call_kw["json"]["content"], "content")
self.assertIn("username", call_kw["json"])
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_text_payload(self, mock_post):
mock_post.return_value = _response(204)
cfg = _config(discord_webhook_url="https://discord.com/webhook/1")
sender = DiscordSender(cfg)
result = sender.send_to_discord("[dsa-market-region]: # (cn)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
call_kw = mock_post.call_args[1]
self.assertNotIn("[dsa-market-region]", call_kw["json"]["content"])
self.assertIn("# 🎯 Market Review", call_kw["json"]["content"])
self.assertIn("Body", call_kw["json"]["content"])
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_preserves_standard_markdown_reference_definitions(self, mock_post):
mock_post.return_value = _response(204)
cfg = _config(discord_webhook_url="https://discord.com/webhook/1")
sender = DiscordSender(cfg)
content = "[详情][ref]\n\n[ref]: https://example.com/report"
result = sender.send_to_discord(content)
self.assertTrue(result)
call_kw = mock_post.call_args[1]
self.assertEqual(call_kw["json"]["content"], content)
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_webhook_http_error_returns_false(self, mock_post):
mock_post.return_value = _response(400)
cfg = _config(discord_webhook_url="https://discord.com/webhook/1")
sender = DiscordSender(cfg)
result = sender.send_to_discord("content")
self.assertFalse(result)
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_bot_success_uses_channel_url(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(discord_bot_token="TOKEN", discord_main_channel_id="CH123")
sender = DiscordSender(cfg)
result = sender.send_to_discord("content")
self.assertTrue(result)
self.assertIn("discord.com/api/v10/channels/CH123/messages", mock_post.call_args[0][0])
call_kw = mock_post.call_args[1]
self.assertEqual(call_kw["headers"]["Authorization"], "Bot TOKEN")
@mock.patch("src.notification_sender.discord_sender.time.sleep", return_value=None)
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_webhook_long_content_sends_all_chunks(self, mock_post, mock_sleep):
mock_post.return_value = _response(204)
cfg = _config(discord_webhook_url="https://discord.com/webhook/1", discord_max_words=2000)
sender = DiscordSender(cfg)
result = sender.send_to_discord("A" * 6000)
self.assertTrue(result)
self.assertGreater(mock_post.call_count, 1)
self.assertEqual(mock_sleep.call_count, mock_post.call_count - 1)
payload_lengths = [len(call.kwargs["json"]["content"]) for call in mock_post.call_args_list]
self.assertTrue(all(length <= 2000 for length in payload_lengths))
@mock.patch("src.notification_sender.discord_sender.time.sleep", return_value=None)
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_webhook_long_content_retries_429_and_continues(self, mock_post, mock_sleep):
mock_post.side_effect = [
_response(204),
_response(429, {"retry_after": 0}),
_response(204),
_response(204),
_response(204),
]
cfg = _config(discord_webhook_url="https://discord.com/webhook/1", discord_max_words=2000)
sender = DiscordSender(cfg)
result = sender.send_to_discord("A" * 6000)
self.assertTrue(result)
self.assertEqual(mock_post.call_count, 5)
mock_sleep.assert_any_call(0.0)
@mock.patch("src.notification_sender.discord_sender.time.sleep", return_value=None)
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_webhook_long_content_does_not_short_circuit_after_failed_chunk(self, mock_post, _mock_sleep):
mock_post.side_effect = [
_response(204),
_response(400),
_response(204),
_response(204),
]
cfg = _config(discord_webhook_url="https://discord.com/webhook/1", discord_max_words=2000)
sender = DiscordSender(cfg)
result = sender.send_to_discord("A" * 6000)
self.assertFalse(result)
self.assertEqual(mock_post.call_count, 4)
@mock.patch("src.notification_sender.discord_sender.time.sleep", return_value=None)
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_webhook_long_content_continues_after_request_exception(self, mock_post, _mock_sleep):
cfg = _config(discord_webhook_url="https://discord.com/webhook/1", discord_max_words=2000)
sender = DiscordSender(cfg)
content = "A" * 6000
chunk_count = len(sender._split_discord_content(content))
request_error = requests.exceptions.ChunkedEncodingError("broken response")
mock_post.side_effect = (
[_response(204)]
+ [request_error] * 3
+ [_response(204)] * (chunk_count - 2)
)
result = sender.send_to_discord(content)
self.assertFalse(result)
self.assertEqual(mock_post.call_count, chunk_count + 2)
@mock.patch("src.notification_sender.discord_sender.time.sleep", return_value=None)
@mock.patch("src.notification_sender.discord_sender.requests.post")
def test_send_bot_clamps_configured_limit_to_discord_content_limit(self, mock_post, _mock_sleep):
mock_post.return_value = _response(200)
cfg = _config(
discord_bot_token="TOKEN",
discord_main_channel_id="CH123",
discord_max_words=5000,
)
sender = DiscordSender(cfg)
result = sender.send_to_discord("A" * 4500)
self.assertTrue(result)
self.assertGreater(mock_post.call_count, 1)
payload_lengths = [len(call.kwargs["json"]["content"]) for call in mock_post.call_args_list]
self.assertTrue(all(length <= 2000 for length in payload_lengths))
class TestWechatSender(unittest.TestCase):
"""Unit tests for WechatSender."""
def test_send_returns_false_when_no_webhook_url(self):
cfg = _config()
sender = WechatSender(cfg)
result = sender.send_to_wechat("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.wechat_sender.requests.post")
def test_send_success_returns_true(self, mock_post):
mock_post.return_value = _response(200, {"errcode": 0})
cfg = _config(wechat_webhook_url="https://wechat.example/hook")
sender = WechatSender(cfg)
result = sender.send_to_wechat("hello")
self.assertTrue(result)
@mock.patch("src.notification_sender.wechat_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_text_payload(self, mock_post):
mock_post.return_value = _response(200, {"errcode": 0})
cfg = _config(wechat_webhook_url="https://wechat.example/hook", wechat_msg_type="text")
sender = WechatSender(cfg)
result = sender.send_to_wechat("[dsa-market-region]: # (us)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["msgtype"], "text")
self.assertNotIn("[dsa-market-region]", payload["text"]["content"])
self.assertIn("# 🎯 Market Review", payload["text"]["content"])
self.assertIn("Body", payload["text"]["content"])
def test_gen_wechat_payload_markdown(self):
cfg = _config(wechat_webhook_url="u", wechat_msg_type="markdown")
sender = WechatSender(cfg)
payload = sender._gen_wechat_payload("## title\nbody")
self.assertEqual(payload["msgtype"], "markdown")
self.assertEqual(payload["markdown"]["content"], "## title\nbody")
def test_gen_wechat_payload_text(self):
cfg = _config(wechat_webhook_url="u", wechat_msg_type="text")
sender = WechatSender(cfg)
payload = sender._gen_wechat_payload("plain")
self.assertEqual(payload["msgtype"], "text")
self.assertEqual(payload["text"]["content"], "plain")
def test_gen_wechat_payload_text_strips_hidden_market_region_metadata(self):
cfg = _config(wechat_webhook_url="u", wechat_msg_type="text")
sender = WechatSender(cfg)
payload = sender._gen_wechat_payload("[dsa-market-region]: # (us)\n\n# 🎯 Market Review\n\nBody")
self.assertEqual(payload["msgtype"], "text")
self.assertNotIn("[dsa-market-region]", payload["text"]["content"])
self.assertIn("# 🎯 Market Review", payload["text"]["content"])
self.assertIn("Body", payload["text"]["content"])
@mock.patch("src.notification_sender.wechat_sender.requests.post")
def test_send_wechat_image_over_limit_returns_false(self, mock_post):
cfg = _config(wechat_webhook_url="https://wechat.example/hook")
sender = WechatSender(cfg)
big = b"x" * (WECHAT_IMAGE_MAX_BYTES + 1)
result = sender._send_wechat_image(big)
self.assertFalse(result)
mock_post.assert_not_called()
class TestFeishuSender(unittest.TestCase):
"""Unit tests for FeishuSender."""
def test_send_returns_false_when_no_webhook_url(self):
cfg = _config()
sender = FeishuSender(cfg)
result = sender.send_to_feishu("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_send_success_returns_true(self, mock_post):
mock_post.return_value = _response(200, {"code": 0})
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
result = sender.send_to_feishu("hello")
self.assertTrue(result)
@mock.patch.object(FeishuSender, "_send_via_webhook", return_value=True)
def test_send_strips_hidden_market_region_metadata_before_routing(self, mock_send):
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
result = sender.send_to_feishu("[dsa-market-region]: # (us)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
mock_send.assert_called_once_with("# 🎯 Market Review\n\nBody", timeout_seconds=None)
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_send_http_error_returns_false(self, mock_post):
mock_post.return_value = _response(400)
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
result = sender.send_to_feishu("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.feishu_sender.time.time", return_value=1700000000)
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_send_with_secret_and_keyword_builds_signed_payload(self, mock_post, _mock_time):
mock_post.return_value = _response(200, {"code": 0})
cfg = _config(
feishu_webhook_url="https://feishu.example/hook",
feishu_webhook_secret="secret-token",
feishu_webhook_keyword="股票日报",
)
sender = FeishuSender(cfg)
result = sender.send_to_feishu("hello")
self.assertTrue(result)
mock_post.assert_called_once()
payload = mock_post.call_args.kwargs["json"]
self.assertEqual(payload["timestamp"], "1700000000")
expected_sign = base64.b64encode(
hmac.new(
b"1700000000\nsecret-token",
digestmod=hashlib.sha256,
).digest()
).decode("utf-8")
self.assertEqual(payload["sign"], expected_sign)
self.assertEqual(
payload["card"]["elements"][0]["text"]["content"],
"股票日报\nhello",
)
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_send_uses_legacy_feishu_report_formatter(self, mock_post):
mock_post.return_value = _response(200, {"code": 0})
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
content = (
"# 日报\n\n"
"## 📊 分析结果摘要\n\n"
"| 股票 | 信号 |\n"
"| --- | --- |\n"
"| 600519 | 强势 |\n\n"
"[详情](https://example.com/report)"
)
result = sender.send_to_feishu(content)
self.assertTrue(result)
payload = mock_post.call_args.kwargs["json"]
rendered = payload["card"]["elements"][0]["text"]["content"]
self.assertIn("**日报**", rendered)
self.assertIn("**📊 分析结果摘要**", rendered)
self.assertIn("• 股票:600519 | 信号:强势", rendered)
self.assertIn("[详情](https://example.com/report)", rendered)
self.assertNotIn("| --- |", rendered)
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_send_error_response_returns_false(self, mock_post):
mock_post.return_value = _response(200, {"code": 19024, "msg": "keyword not found"})
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
result = sender.send_to_feishu("hello")
self.assertFalse(result)
self.assertEqual(mock_post.call_count, 2)
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_send_with_keyword_that_leaves_too_little_chunk_budget_returns_false(self, mock_post):
cfg = _config(
feishu_webhook_url="https://feishu.example/hook",
feishu_webhook_keyword="abcd",
feishu_max_bytes=60,
)
sender = FeishuSender(cfg)
result = sender.send_to_feishu("x" * 100)
self.assertFalse(result)
mock_post.assert_not_called()
# ------------------------------------------------------------------
# App Bot mode tests
# ------------------------------------------------------------------
def test_app_bot_returns_false_when_no_app_credentials(self):
"""send_to_feishu returns False when app credentials are missing."""
cfg = _config(feishu_chat_id="oc_chat")
sender = FeishuSender(cfg)
self.assertFalse(sender.send_to_feishu("hello"))
def test_app_bot_returns_false_when_no_chat_id(self):
"""send_to_feishu returns False when feishu_chat_id is missing."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
)
sender = FeishuSender(cfg)
self.assertFalse(sender.send_to_feishu("hello"))
def test_app_bot_success_via_card(self):
"""send_to_feishu sends an interactive card via App Bot on success."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
dummy_client = object()
with mock.patch.object(FeishuSender, "_ensure_app_client", return_value=dummy_client), \
mock.patch.object(FeishuSender, "_app_send_raw", return_value=True) as mock_raw:
result = sender.send_to_feishu("**hello** world")
self.assertTrue(result)
mock_raw.assert_called_once()
self.assertIs(mock_raw.call_args[0][0], dummy_client)
# call_args[0] = (client, msg_type, content_json)
msg_type = mock_raw.call_args[0][1]
content_json = mock_raw.call_args[0][2]
self.assertEqual(msg_type, "interactive")
self.assertIn("**hello**", content_json)
def test_app_bot_card_fallback_to_text_on_formatted_content(self):
"""App Bot falls back to text when interactive card fails."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
with mock.patch.object(FeishuSender, "_ensure_app_client", return_value=object()), \
mock.patch.object(FeishuSender, "_app_send_raw", side_effect=[False, True]) as mock_raw:
result = sender.send_to_feishu("hello world")
self.assertTrue(result)
self.assertEqual(mock_raw.call_count, 2)
# call_args_list[0][0] = (client, msg_type, content_json)
self.assertEqual(mock_raw.call_args_list[0][0][1], "interactive")
self.assertEqual(mock_raw.call_args_list[1][0][1], "text")
def test_app_bot_card_first_success_no_fallback(self):
"""App Bot sends interactive card successfully and does not try text."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
with mock.patch.object(FeishuSender, "_ensure_app_client", return_value=object()), \
mock.patch.object(FeishuSender, "_app_send_raw", return_value=True) as mock_raw:
result = sender.send_to_feishu("**bold** text")
self.assertTrue(result)
mock_raw.assert_called_once()
# call_args_list[0][0][1] = msg_type, [0][0][2] = content_json
self.assertEqual(mock_raw.call_args_list[0][0][1], "interactive")
self.assertIn("**bold**", mock_raw.call_args_list[0][0][2])
@mock.patch("src.notification_sender.feishu_sender.requests.post")
@mock.patch.object(FeishuSender, "_app_send_raw", return_value=True)
def test_webhook_takes_precedence_over_app_bot(self, mock_app_raw, mock_webhook_post):
"""When both webhook URL and App Bot credentials are configured, webhook is used."""
mock_webhook_post.return_value = _response(200, {"code": 0})
cfg = _config(
feishu_webhook_url="https://feishu.example/hook",
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
result = sender.send_to_feishu("hello")
self.assertTrue(result)
mock_webhook_post.assert_called_once()
mock_app_raw.assert_not_called()
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_webhook_does_not_require_sdk_when_app_bot_is_also_configured(self, mock_webhook_post):
"""Webhook precedence keeps SDK absence from breaking existing delivery."""
mock_webhook_post.return_value = _response(200, {"code": 0})
cfg = _config(
feishu_webhook_url="https://feishu.example/hook",
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
with mock.patch("src.notification_sender.feishu_sender.FEISHU_SDK_AVAILABLE", False), \
mock.patch.object(FeishuSender, "_ensure_app_client", side_effect=AssertionError("SDK should not be used")):
result = sender.send_to_feishu("hello")
self.assertTrue(result)
mock_webhook_post.assert_called_once()
def test_app_bot_missing_sdk_logs_standard_requirements_install(self):
"""App Bot SDK absence fails closed with the standard project install hint."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
with mock.patch("src.notification_sender.feishu_sender.FEISHU_SDK_AVAILABLE", False), \
self.assertLogs("src.notification_sender.feishu_sender", level="WARNING") as logs:
result = sender.send_to_feishu("hello")
self.assertFalse(result)
install_hints = [
line
for line in logs.output
if "pip install -r requirements.txt" in line
]
self.assertEqual(install_hints, logs.output)
self.assertEqual(len(install_hints), 1)
def test_app_bot_chunking_long_content(self):
"""Long content is chunked for App Bot."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
feishu_max_bytes=200,
)
sender = FeishuSender(cfg)
with mock.patch.object(FeishuSender, "_ensure_app_client", return_value=object()), \
mock.patch.object(FeishuSender, "_app_send_raw", return_value=False) as mock_raw, \
mock.patch("src.notification_sender.feishu_sender.time.sleep") as mock_sleep:
result = sender.send_to_feishu("A" * 500)
self.assertFalse(result) # All chunks fail
self.assertGreater(mock_raw.call_count, 1)
self.assertEqual(
[item.args[0] for item in mock_sleep.call_args_list],
[1, 1, 1],
)
@mock.patch.object(FeishuSender, "_app_send_raw", return_value=True)
def test_app_bot_request_shape_interactive(self, mock_raw):
"""_app_send_once constructs interactive card payload with lark_md."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
result = sender._app_send_once(object(), "**bold** text")
self.assertTrue(result)
call = mock_raw.call_args
self.assertEqual(call[0][1], "interactive") # msg_type
card = json.loads(call[0][2])
self.assertEqual(card["header"]["title"]["content"], "股票智能分析报告")
self.assertEqual(card["elements"][0]["text"]["tag"], "lark_md")
self.assertIn("**bold**", card["elements"][0]["text"]["content"])
@mock.patch.object(FeishuSender, "_app_send_raw")
def test_app_bot_request_shape_text_fallback(self, mock_raw):
"""_app_send_once falls back to text payload when card fails."""
mock_raw.side_effect = [False, True]
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
result = sender._app_send_once(object(), "plain text")
self.assertTrue(result)
self.assertEqual(mock_raw.call_count, 2)
# Second call is text fallback
second_call = mock_raw.call_args_list[1]
self.assertEqual(second_call[0][1], "text")
text_content = json.loads(second_call[0][2])
self.assertIn("plain text", text_content["text"])
@mock.patch("src.notification_sender.feishu_sender.uuid_mod.uuid4", return_value="uuid-open-id")
def test_app_bot_request_includes_receive_id_type(self, _mock_uuid4):
"""_app_send_raw request builder passes receive_id_type and request body fields."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="ou_user",
feishu_receive_id_type="open_id",
)
sender = FeishuSender(cfg)
client, create = _fake_feishu_client(_sdk_response(True))
result = sender._app_send_raw(client, "text", json.dumps({"text": "hi"}))
self.assertTrue(result)
create.assert_called_once()
req = create.call_args[0][0]
self.assertEqual(req.receive_id_type, "open_id")
self.assertEqual(req.request_body.receive_id, "ou_user")
self.assertEqual(req.request_body.msg_type, "text")
self.assertEqual(json.loads(req.request_body.content), {"text": "hi"})
self.assertEqual(req.request_body.uuid, "uuid-open-id")
@mock.patch("src.notification_sender.feishu_sender.uuid_mod.uuid4")
def test_app_bot_idempotency_uuid_per_call(self, mock_uuid4):
"""Each _app_send_raw invocation gets a fresh UUID."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
client, create = _fake_feishu_client(_sdk_response(True), _sdk_response(True))
mock_uuid4.side_effect = ["aaaa-bbbb-cccc", "dddd-eeee-ffff"]
sender._app_send_raw(client, "text", json.dumps({"text": "a"}))
sender._app_send_raw(client, "text", json.dumps({"text": "b"}))
self.assertEqual(create.call_count, 2)
call1_req = create.call_args_list[0][0][0]
call2_req = create.call_args_list[1][0][0]
self.assertEqual(call1_req.request_body.uuid, "aaaa-bbbb-cccc")
self.assertEqual(call2_req.request_body.uuid, "dddd-eeee-ffff")
@mock.patch("src.notification_sender.feishu_sender.time.sleep")
def test_app_bot_retries_sdk_response_failure(self, mock_sleep):
"""_app_send_raw retries failed SDK responses and stops after success."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
client, create = _fake_feishu_client(
_sdk_response(False, code=999, msg="temporary"),
_sdk_response(True),
)
result = sender._app_send_raw(client, "text", json.dumps({"text": "retry"}))
self.assertTrue(result)
self.assertEqual(create.call_count, 2)
mock_sleep.assert_called_once()
@mock.patch("src.notification_sender.feishu_sender.time.sleep")
def test_app_bot_retries_sdk_exception(self, mock_sleep):
"""_app_send_raw retries exceptions raised by the SDK create call."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
client, create = _fake_feishu_client(RuntimeError("network"), _sdk_response(True))
result = sender._app_send_raw(client, "text", json.dumps({"text": "retry"}))
self.assertTrue(result)
self.assertEqual(create.call_count, 2)
mock_sleep.assert_called_once()
@mock.patch("src.notification_sender.feishu_sender.time.sleep")
def test_app_bot_first_success_does_not_retry(self, mock_sleep):
"""_app_send_raw does not retry after the first successful SDK response."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
client, create = _fake_feishu_client(_sdk_response(True))
result = sender._app_send_raw(client, "text", json.dumps({"text": "once"}))
self.assertTrue(result)
create.assert_called_once()
mock_sleep.assert_not_called()
@mock.patch("src.notification_sender.feishu_sender.time.sleep")
@mock.patch("src.notification_sender.feishu_sender.CreateMessageRequest.builder", side_effect=RuntimeError("bad builder"))
def test_app_bot_builder_failure_does_not_retry(self, _mock_builder, mock_sleep):
"""Request builder failures are not treated as transient send failures."""
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
client, create = _fake_feishu_client(_sdk_response(True))
result = sender._app_send_raw(client, "text", json.dumps({"text": "bad"}))
self.assertFalse(result)
create.assert_not_called()
mock_sleep.assert_not_called()
# ------------------------------------------------------------------
# send_feishu_file tests
# ------------------------------------------------------------------
@mock.patch("src.notification_sender.feishu_sender.Path")
def test_send_feishu_file_returns_false_when_file_not_found(self, mock_path_cls):
"""send_feishu_file returns False when the file does not exist."""
mock_path = mock_path_cls.return_value
mock_path.is_file.return_value = False
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
result = sender.send_feishu_file("/nonexistent/report.md")
self.assertFalse(result)
@mock.patch("src.notification_sender.feishu_sender.Path")
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_send_feishu_file_webhook_reads_file_and_sends_content(self, mock_post, mock_path_cls):
"""Webhook fallback reads the file and sends its content as a message."""
mock_post.return_value = _response(200, {"code": 0})
mock_path = mock_path_cls.return_value
mock_path.is_file.return_value = True
mock_path.name = "report_20260705.md"
mock_path.suffix = ".md"
mock_path.read_text.return_value = "# Test Report\n\nHello"
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
result = sender.send_feishu_file("/tmp/report_20260705.md")
self.assertTrue(result)
mock_post.assert_called_once()
payload = mock_post.call_args.kwargs["json"]
rendered = payload["card"]["elements"][0]["text"]["content"]
self.assertIn("📄 Markdown 文件内容: report_20260705.md", rendered)
self.assertIn("**Test Report**", rendered)
@mock.patch("src.notification_sender.feishu_sender.Path")
@mock.patch("src.notification_sender.feishu_sender.requests.post")
def test_send_feishu_file_webhook_strips_hidden_metadata_from_file_content(self, mock_post, mock_path_cls):
mock_post.return_value = _response(200, {"code": 0})
mock_path = mock_path_cls.return_value
mock_path.is_file.return_value = True
mock_path.name = "report.md"
mock_path.suffix = ".md"
mock_path.read_text.return_value = "[dsa-market-region]: # (cn)\n\n# Test Report\n\nHello"
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
result = sender.send_feishu_file("/tmp/report.md")
self.assertTrue(result)
rendered = mock_post.call_args.kwargs["json"]["card"]["elements"][0]["text"]["content"]
self.assertNotIn("[dsa-market-region]", rendered)
self.assertIn("**Test Report**", rendered)
@mock.patch("src.notification_sender.feishu_sender.Path")
def test_send_feishu_file_webhook_unreadable_file_returns_false(self, mock_path_cls):
"""Webhook fallback returns False when the file cannot be read."""
mock_path = mock_path_cls.return_value
mock_path.is_file.return_value = True
mock_path.read_text.side_effect = OSError("Permission denied")
cfg = _config(feishu_webhook_url="https://feishu.example/hook")
sender = FeishuSender(cfg)
result = sender.send_feishu_file("/tmp/protected.md")
self.assertFalse(result)
@mock.patch("src.notification_sender.feishu_sender.Path")
def test_send_feishu_file_app_bot_missing_chat_id_returns_false(self, mock_path_cls):
"""App Bot mode returns False when chat_id is not configured."""
mock_path = mock_path_cls.return_value
mock_path.is_file.return_value = True
cfg = _config(feishu_app_id="cli_app", feishu_app_secret="secret")
sender = FeishuSender(cfg)
result = sender.send_feishu_file("/tmp/report.md")
self.assertFalse(result)
@mock.patch("src.notification_sender.feishu_sender._CreateFileRequestBody", create=True)
@mock.patch("src.notification_sender.feishu_sender._CreateFileRequest", create=True)
@mock.patch("src.notification_sender.feishu_sender.FEISHU_FILE_SDK_AVAILABLE", True)
@mock.patch("src.notification_sender.feishu_sender.Path")
def test_send_feishu_file_app_bot_uploads_file_and_sends_message(self, *_):
"""App Bot uploads the file via SDK and sends a file message."""
mock_path = mock.MagicMock()
mock_path.is_file.return_value = True
mock_path.name = "report.md"
mock_path.suffix = ".md"
mock_path.open.return_value.__enter__.return_value = mock.MagicMock()
with mock.patch("src.notification_sender.feishu_sender.Path", return_value=mock_path):
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
dummy_client = mock.MagicMock()
file_resp = mock.MagicMock()
file_resp.success.return_value = True
file_resp.code = 0
file_resp.msg = "ok"
file_resp.data.file_key = "file_key_abc"
dummy_client.im.v1.file.create.return_value = file_resp
with mock.patch.object(FeishuSender, "_ensure_app_client", return_value=dummy_client), \
mock.patch.object(FeishuSender, "_app_send_raw", return_value=True) as mock_raw:
result = sender.send_feishu_file("/tmp/report.md")
self.assertTrue(result)
dummy_client.im.v1.file.create.assert_called_once()
mock_raw.assert_called_once()
self.assertEqual(mock_raw.call_args[0][1], "file")
content_json = json.loads(mock_raw.call_args[0][2])
self.assertEqual(content_json["file_key"], "file_key_abc")
@mock.patch("src.notification_sender.feishu_sender._CreateFileRequestBody", create=True)
@mock.patch("src.notification_sender.feishu_sender._CreateFileRequest", create=True)
@mock.patch("src.notification_sender.feishu_sender.FEISHU_FILE_SDK_AVAILABLE", True)
@mock.patch("src.notification_sender.feishu_sender.tempfile.NamedTemporaryFile")
def test_send_feishu_file_app_bot_strips_hidden_metadata_before_upload(self, mock_tmpfile, *_):
mock_path = mock.MagicMock()
mock_path.is_file.return_value = True
mock_path.name = "report.md"
mock_path.suffix = ".md"
mock_path.read_text.return_value = "[dsa-market-region]: # (cn)\n\n# Test Report\n\nHello"
temp_handle = mock.MagicMock()
temp_handle.name = "/tmp/feishu_sanitized.md"
temp_handle.__enter__.return_value = temp_handle
temp_handle.__exit__.return_value = None
mock_tmpfile.return_value = temp_handle
sanitized_path = mock.MagicMock()
sanitized_path.name = "feishu_sanitized.md"
sanitized_path.suffix = ".md"
sanitized_path.open.return_value.__enter__.return_value = mock.MagicMock()
sanitized_path.unlink.return_value = None
with mock.patch("src.notification_sender.feishu_sender.Path", side_effect=[mock_path, sanitized_path]):
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
dummy_client = mock.MagicMock()
file_resp = mock.MagicMock()
file_resp.success.return_value = True
file_resp.code = 0
file_resp.msg = "ok"
file_resp.data.file_key = "file_key_abc"
dummy_client.im.v1.file.create.return_value = file_resp
with mock.patch.object(FeishuSender, "_ensure_app_client", return_value=dummy_client), \
mock.patch.object(FeishuSender, "_app_send_raw", return_value=True):
result = sender.send_feishu_file("/tmp/report.md")
self.assertTrue(result)
temp_handle.write.assert_called_once()
written_content = temp_handle.write.call_args.args[0]
self.assertNotIn("[dsa-market-region]", written_content)
self.assertIn("# Test Report", written_content)
sanitized_path.open.assert_called_once()
sanitized_path.unlink.assert_called_once()
@mock.patch("src.notification_sender.feishu_sender._CreateFileRequestBody", create=True)
@mock.patch("src.notification_sender.feishu_sender._CreateFileRequest", create=True)
@mock.patch("src.notification_sender.feishu_sender.FEISHU_FILE_SDK_AVAILABLE", True)
def test_send_feishu_file_app_bot_upload_failure_returns_false(self, *_):
"""App Bot returns False when the file upload fails."""
mock_path = mock.MagicMock()
mock_path.is_file.return_value = True
mock_path.name = "report.md"
mock_path.suffix = ".md"
mock_path.open.return_value.__enter__.return_value = mock.MagicMock()
with mock.patch("src.notification_sender.feishu_sender.Path", return_value=mock_path):
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
dummy_client = mock.MagicMock()
file_resp = mock.MagicMock()
file_resp.success.return_value = False
file_resp.code = 999
file_resp.msg = "upload failed"
dummy_client.im.v1.file.create.return_value = file_resp
with mock.patch.object(FeishuSender, "_ensure_app_client", return_value=dummy_client):
result = sender.send_feishu_file("/tmp/report.md")
self.assertFalse(result)
@mock.patch("src.notification_sender.feishu_sender._CreateFileRequestBody", create=True)
@mock.patch("src.notification_sender.feishu_sender._CreateFileRequest", create=True)
@mock.patch("src.notification_sender.feishu_sender.FEISHU_FILE_SDK_AVAILABLE", True)
def test_send_feishu_file_app_bot_missing_file_key_returns_false(self, *_):
"""App Bot returns False when upload succeeds but file_key is missing."""
mock_path = mock.MagicMock()
mock_path.is_file.return_value = True
mock_path.name = "report.md"
mock_path.suffix = ".md"
mock_path.open.return_value.__enter__.return_value = mock.MagicMock()
with mock.patch("src.notification_sender.feishu_sender.Path", return_value=mock_path):
cfg = _config(
feishu_app_id="cli_app",
feishu_app_secret="secret",
feishu_chat_id="oc_chat",
)
sender = FeishuSender(cfg)
dummy_client = mock.MagicMock()
file_resp = mock.MagicMock()
file_resp.success.return_value = True
file_resp.data = None
dummy_client.im.v1.file.create.return_value = file_resp
with mock.patch.object(FeishuSender, "_ensure_app_client", return_value=dummy_client):
result = sender.send_feishu_file("/tmp/report.md")
self.assertFalse(result)
def test_file_upload_sdk_classes_exist_non_mock(self):
"""Smoke: lark-oapi SDK actually exports CreateFileRequest/RequestBody."""
from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody
self.assertTrue(hasattr(CreateFileRequest, 'builder'))
self.assertTrue(hasattr(CreateFileRequestBody, 'builder'))
class TestEmailSender(unittest.TestCase):
"""Unit tests for EmailSender (config and receiver logic; send path covered via service)."""
def test_send_returns_false_when_not_configured(self):
cfg = _config()
sender = EmailSender(cfg)
result = sender.send_to_email("body")
self.assertFalse(result)
def test_get_receivers_for_stocks_no_groups_returns_default(self):
cfg = _config(
email_sender="a@qq.com",
email_password="p",
email_receivers=["b@qq.com", "c@qq.com"],
)
sender = EmailSender(cfg)
self.assertEqual(
sender.get_receivers_for_stocks(["000001"]),
["b@qq.com", "c@qq.com"],
)
def test_get_receivers_for_stocks_with_matching_group(self):
cfg = _config(
email_sender="a@qq.com",
email_password="p",
email_receivers=["default@qq.com"],
stock_email_groups=[(["000001", "600519"], ["group1@qq.com"])],
)
sender = EmailSender(cfg)
self.assertEqual(
sender.get_receivers_for_stocks(["000001"]),
["group1@qq.com"],
)
def test_get_receivers_for_stocks_no_match_falls_back_to_default(self):
cfg = _config(
email_sender="a@qq.com",
email_password="p",
email_receivers=["default@qq.com"],
stock_email_groups=[(["000001"], ["group@qq.com"])],
)
sender = EmailSender(cfg)
self.assertEqual(
sender.get_receivers_for_stocks(["999999"]),
["default@qq.com"],
)
def test_get_all_email_receivers_returns_union(self):
cfg = _config(
email_sender="a@qq.com",
email_password="p",
email_receivers=["default@qq.com"],
stock_email_groups=[
(["000001"], ["g1@qq.com"]),
(["600519"], ["g2@qq.com"]),
],
)
sender = EmailSender(cfg)
receivers = sender.get_all_email_receivers()
self.assertIn("g1@qq.com", receivers)
self.assertIn("g2@qq.com", receivers)
self.assertIn("default@qq.com", receivers)
@mock.patch("smtplib.SMTP_SSL")
def test_send_to_email_encodes_non_ascii_sender_name(self, mock_smtp_ssl):
cfg = _config(
email_sender="a@qq.com",
email_password="p",
email_receivers=["b@qq.com"],
email_sender_name="daily_stock_analysis股票分析助手",
)
sender = EmailSender(cfg)
result = sender.send_to_email("body", subject="测试主题")
self.assertTrue(result)
server = mock_smtp_ssl.return_value
server.send_message.assert_called_once()
msg = server.send_message.call_args[0][0]
realname, addr = parseaddr(msg["From"])
self.assertEqual(addr, "a@qq.com")
self.assertEqual(
str(make_header(decode_header(realname))),
"daily_stock_analysis股票分析助手",
)
server.quit.assert_called_once()
@mock.patch("smtplib.SMTP_SSL")
def test_send_to_email_strips_hidden_market_metadata(self, mock_smtp_ssl):
cfg = _config(
email_sender="a@qq.com",
email_password="p",
email_receivers=["b@qq.com"],
)
sender = EmailSender(cfg)
result = sender.send_to_email("[dsa-market-region]: # (cn)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
msg = mock_smtp_ssl.return_value.send_message.call_args[0][0]
payloads = msg.get_payload()
self.assertNotIn("[dsa-market-region]", payloads[0].get_payload(decode=True).decode("utf-8"))
self.assertNotIn("[dsa-market-region]", payloads[1].get_payload(decode=True).decode("utf-8"))
@mock.patch("smtplib.SMTP_SSL")
def test_send_image_email_encodes_non_ascii_sender_name(self, mock_smtp_ssl):
cfg = _config(
email_sender="a@qq.com",
email_password="p",
email_receivers=["b@qq.com"],
email_sender_name="daily_stock_analysis股票分析助手",
)
sender = EmailSender(cfg)
result = sender._send_email_with_inline_image(b"PNG_BYTES", receivers=["b@qq.com"])
self.assertTrue(result)
server = mock_smtp_ssl.return_value
server.send_message.assert_called_once()
msg = server.send_message.call_args[0][0]
realname, addr = parseaddr(msg["From"])
self.assertEqual(addr, "a@qq.com")
self.assertEqual(
str(make_header(decode_header(realname))),
"daily_stock_analysis股票分析助手",
)
server.quit.assert_called_once()
class TestNtfySender(unittest.TestCase):
"""Unit tests for NtfySender."""
def test_send_returns_false_when_not_configured(self):
cfg = _config()
sender = NtfySender(cfg)
result = sender.send_to_ntfy("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.ntfy_sender.requests.post")
def test_send_success_uses_json_publish_with_topic_endpoint(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(
ntfy_url="https://ntfy.sh/dsa-topic",
ntfy_token="secret-token",
webhook_verify_ssl=False,
)
sender = NtfySender(cfg)
result = sender.send_to_ntfy("正文 **Markdown**", title="中文标题", timeout_seconds=5)
self.assertTrue(result)
mock_post.assert_called_once()
self.assertEqual(mock_post.call_args.args[0], "https://ntfy.sh")
call_kw = mock_post.call_args.kwargs
self.assertEqual(
call_kw["json"],
{
"topic": "dsa-topic",
"title": "中文标题",
"message": "正文 **Markdown**",
"markdown": True,
},
)
self.assertEqual(call_kw["headers"]["Authorization"], "Bearer secret-token")
self.assertEqual(call_kw["timeout"], 5)
self.assertFalse(call_kw["verify"])
@mock.patch("src.notification_sender.ntfy_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_message(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(ntfy_url="https://ntfy.sh/dsa-topic")
sender = NtfySender(cfg)
result = sender.send_to_ntfy("[dsa-market-region]: # (cn)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
self.assertEqual(
mock_post.call_args.kwargs["json"]["message"],
"# 🎯 Market Review\n\nBody",
)
@mock.patch("src.notification_sender.ntfy_sender.requests.post")
def test_send_supports_self_hosted_path_prefix(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(ntfy_url="https://example.com/ntfy/dsa-topic")
sender = NtfySender(cfg)
result = sender.send_to_ntfy("body", title="title")
self.assertTrue(result)
self.assertEqual(mock_post.call_args.args[0], "https://example.com/ntfy")
self.assertEqual(mock_post.call_args.kwargs["json"]["topic"], "dsa-topic")
@mock.patch("src.notification_sender.ntfy_sender.requests.post")
def test_send_returns_false_when_url_has_no_topic(self, mock_post):
cfg = _config(ntfy_url="https://ntfy.sh")
sender = NtfySender(cfg)
result = sender.send_to_ntfy("body")
self.assertFalse(result)
mock_post.assert_not_called()
@mock.patch("src.notification_sender.ntfy_sender.requests.post")
def test_send_returns_false_when_url_scheme_is_not_http(self, mock_post):
cfg = _config(ntfy_url="ftp://ntfy.example/dsa-topic")
sender = NtfySender(cfg)
result = sender.send_to_ntfy("body")
self.assertFalse(result)
mock_post.assert_not_called()
@mock.patch("src.notification_sender.ntfy_sender.requests.post")
def test_send_http_error_returns_false(self, mock_post):
mock_post.return_value = _response(500)
cfg = _config(ntfy_url="https://ntfy.sh/dsa-topic")
sender = NtfySender(cfg)
result = sender.send_to_ntfy("body")
self.assertFalse(result)
@mock.patch("src.notification_sender.ntfy_sender.requests.post")
def test_send_timeout_does_not_log_token_value(self, mock_post):
mock_post.side_effect = requests.exceptions.Timeout("secret-token")
cfg = _config(ntfy_url="https://ntfy.sh/dsa-topic", ntfy_token="secret-token")
sender = NtfySender(cfg)
with self.assertLogs("src.notification_sender.ntfy_sender", level="ERROR") as captured:
result = sender.send_to_ntfy("body")
self.assertFalse(result)
self.assertNotIn("secret-token", "\n".join(captured.output))
class TestGotifySender(unittest.TestCase):
"""Unit tests for GotifySender."""
def test_send_returns_false_when_not_configured(self):
cfg = _config()
sender = GotifySender(cfg)
result = sender.send_to_gotify("hello")
self.assertFalse(result)
def test_send_returns_false_when_token_is_blank(self):
cfg = _config(gotify_url="https://gotify.example", gotify_token=" ")
sender = GotifySender(cfg)
result = sender.send_to_gotify("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.gotify_sender.requests.post")
def test_send_success_uses_json_payload_and_header_auth(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(
gotify_url="https://gotify.example",
gotify_token="secret-token",
webhook_verify_ssl=False,
)
sender = GotifySender(cfg)
result = sender.send_to_gotify("正文 **Markdown**", title="中文标题", timeout_seconds=5)
self.assertTrue(result)
mock_post.assert_called_once()
self.assertEqual(mock_post.call_args.args[0], "https://gotify.example/message")
call_kw = mock_post.call_args.kwargs
self.assertEqual(
call_kw["json"],
{
"title": "中文标题",
"message": "正文 **Markdown**",
"extras": {
"client::display": {
"contentType": "text/markdown",
},
},
},
)
self.assertEqual(call_kw["headers"]["X-Gotify-Key"], "secret-token")
self.assertNotIn("secret-token", mock_post.call_args.args[0])
self.assertEqual(call_kw["timeout"], 5)
self.assertFalse(call_kw["verify"])
@mock.patch("src.notification_sender.gotify_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_message(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(gotify_url="https://gotify.example", gotify_token="secret-token")
sender = GotifySender(cfg)
result = sender.send_to_gotify("[dsa-market-region]: # (cn)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
self.assertEqual(
mock_post.call_args.kwargs["json"]["message"],
"# 🎯 Market Review\n\nBody",
)
@mock.patch("src.notification_sender.gotify_sender.requests.post")
def test_send_supports_reverse_proxy_path_prefix(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(gotify_url="https://example.com/gotify", gotify_token="secret-token")
sender = GotifySender(cfg)
result = sender.send_to_gotify("body", title="title")
self.assertTrue(result)
self.assertEqual(mock_post.call_args.args[0], "https://example.com/gotify/message")
@mock.patch("src.notification_sender.gotify_sender.requests.post")
def test_send_returns_false_when_url_already_includes_message_endpoint(self, mock_post):
cfg = _config(gotify_url="https://gotify.example/message", gotify_token="secret-token")
sender = GotifySender(cfg)
result = sender.send_to_gotify("body")
self.assertFalse(result)
mock_post.assert_not_called()
@mock.patch("src.notification_sender.gotify_sender.requests.post")
def test_send_timeout_does_not_log_token_value(self, mock_post):
mock_post.side_effect = requests.exceptions.Timeout("secret-token")
cfg = _config(gotify_url="https://gotify.example", gotify_token="secret-token")
sender = GotifySender(cfg)
with self.assertLogs("src.notification_sender.gotify_sender", level="ERROR") as captured:
result = sender.send_to_gotify("body")
self.assertFalse(result)
self.assertNotIn("secret-token", "\n".join(captured.output))
class TestAstrbotSender(unittest.TestCase):
"""Unit tests for AstrbotSender."""
def test_send_returns_false_when_no_url(self):
cfg = _config()
sender = AstrbotSender(cfg)
result = sender.send_to_astrbot("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.astrbot_sender.requests.post")
def test_send_success_returns_true(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(astrbot_url="https://astrbot.example/api")
sender = AstrbotSender(cfg)
result = sender.send_to_astrbot("hello")
self.assertTrue(result)
self.assertEqual(mock_post.call_args[0][0], "https://astrbot.example/api")
@mock.patch("src.notification_sender.astrbot_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_before_html_conversion(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(astrbot_url="https://astrbot.example/api")
sender = AstrbotSender(cfg)
result = sender.send_to_astrbot("[dsa-market-region]: # (us)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
payload = mock_post.call_args.kwargs["json"]["content"]
self.assertNotIn("[dsa-market-region]", payload)
self.assertIn("Market Review", payload)
self.assertIn("Body", payload)
class TestCustomWebhookSender(unittest.TestCase):
"""Unit tests for CustomWebhookSender."""
def test_send_returns_false_when_no_urls(self):
cfg = _config()
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_send_success_payload_has_text_and_content(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(custom_webhook_urls=["https://example.com/webhook"])
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom("hello")
self.assertTrue(result)
body = mock_post.call_args[1]["data"].decode("utf-8")
self.assertIn("hello", body)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_payload(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(custom_webhook_urls=["https://example.com/webhook"])
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom("[dsa-market-region]: # (cn)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
body = mock_post.call_args.kwargs["data"].decode("utf-8")
self.assertNotIn("[dsa-market-region]", body)
self.assertIn("Market Review", body)
self.assertIn("Body", body)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_send_returns_true_when_one_custom_webhook_succeeds(self, mock_post):
mock_post.side_effect = [_response(500), _response(200)]
cfg = _config(
custom_webhook_urls=[
"https://example.com/fail",
"https://example.com/ok",
]
)
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom("hello")
self.assertTrue(result)
self.assertEqual(mock_post.call_count, 2)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_test_custom_webhooks_returns_ordered_attempts(self, mock_post):
mock_post.side_effect = [_response(500), _response(200)]
cfg = _config(
custom_webhook_urls=[
"https://example.com/fail?access_token=secret",
"https://example.com/ok",
]
)
sender = CustomWebhookSender(cfg)
attempts = sender.test_custom_webhooks("hello", timeout_seconds=7)
self.assertEqual(len(attempts), 2)
self.assertFalse(attempts[0]["success"])
self.assertTrue(attempts[1]["success"])
self.assertEqual(attempts[0]["http_status"], 500)
self.assertEqual(mock_post.call_args_list[0].kwargs["timeout"], 7)
def test_bark_payload_shape_is_stable(self):
sender = CustomWebhookSender(_config())
payload = sender._build_custom_webhook_payload("https://api.day.app/key", "hello")
self.assertEqual(
payload,
{
"title": "股票分析报告",
"body": "hello",
"group": "stock",
},
)
def test_bark_payload_truncates_long_content(self):
sender = CustomWebhookSender(_config())
payload = sender._build_custom_webhook_payload("https://api.day.app/key", "x" * 5000)
self.assertEqual(len(payload["body"]), 4000)
self.assertEqual(payload["body"], "x" * 4000)
def test_custom_body_template_overrides_bark_auto_payload(self):
cfg = _config(
custom_webhook_body_template=(
'{"title":$title_json,"body":$content_json,"sound":"bell"}'
),
)
sender = CustomWebhookSender(cfg)
payload = sender._build_custom_webhook_payload("https://api.day.app/key", "hello")
self.assertEqual(
payload,
{
"title": "股票分析报告",
"body": "hello",
"sound": "bell",
},
)
self.assertNotIn("group", payload)
def test_custom_body_template_json_placeholders_escape_content(self):
cfg = _config(
custom_webhook_body_template=(
'{"title":$title_json,"content":$content_json}'
),
)
sender = CustomWebhookSender(cfg)
payload = sender._build_custom_webhook_payload(
"https://example.com/webhook",
'line 1\nline "2"',
)
self.assertEqual(
payload,
{
"title": "股票分析报告",
"content": 'line 1\nline "2"',
},
)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_send_uses_custom_body_template(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(
custom_webhook_urls=["https://example.com/webhook"],
custom_webhook_body_template='{"msg_type":"text","content":$content_json}',
)
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom('hello "world"')
self.assertTrue(result)
body = mock_post.call_args[1]["data"].decode("utf-8")
self.assertEqual(
json.loads(body),
{"msg_type": "text", "content": 'hello "world"'},
)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_dingtalk_send_uses_custom_body_template(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(
custom_webhook_urls=["https://oapi.dingtalk.com/robot/send?access_token=token"],
custom_webhook_body_template='{"msgtype":"text","text":{"content":$content_json}}',
)
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom("hello dingtalk")
self.assertTrue(result)
mock_post.assert_called_once()
body = mock_post.call_args[1]["data"].decode("utf-8")
self.assertEqual(
json.loads(body),
{"msgtype": "text", "text": {"content": "hello dingtalk"}},
)
@mock.patch("time.sleep", return_value=None)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_dingtalk_template_failure_falls_back_to_chunked_send(
self, mock_post, _mock_sleep
):
mock_post.side_effect = [_response(400), _response(200), _response(200), _response(200)]
cfg = _config(
custom_webhook_urls=["https://oapi.dingtalk.com/robot/send?access_token=token"],
custom_webhook_body_template='{"msgtype":"text","text":{"content":$content_json}}',
)
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom("A" * 40000)
self.assertTrue(result)
self.assertGreater(mock_post.call_count, 1)
first_body = json.loads(mock_post.call_args_list[0].kwargs["data"].decode("utf-8"))
fallback_body = json.loads(mock_post.call_args_list[1].kwargs["data"].decode("utf-8"))
self.assertEqual(first_body["msgtype"], "text")
self.assertEqual(fallback_body["msgtype"], "markdown")
self.assertIn("markdown", fallback_body)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_invalid_custom_body_template_falls_back(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(
custom_webhook_urls=["https://example.com/webhook"],
custom_webhook_body_template='{"content": $content',
)
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom("hello")
self.assertTrue(result)
body = mock_post.call_args[1]["data"].decode("utf-8")
self.assertIn("hello", body)
@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_non_object_custom_body_template_falls_back(self, mock_post):
mock_post.return_value = _response(200)
cfg = _config(
custom_webhook_urls=["https://example.com/webhook"],
custom_webhook_body_template='["not", "object"]',
)
sender = CustomWebhookSender(cfg)
result = sender.send_to_custom("hello")
self.assertTrue(result)
body = json.loads(mock_post.call_args[1]["data"].decode("utf-8"))
self.assertEqual(body["content"], "hello")
self.assertEqual(body["message"], "hello")
class TestPushoverSender(unittest.TestCase):
"""Unit tests for PushoverSender."""
def test_send_returns_false_when_not_configured(self):
cfg = _config()
sender = PushoverSender(cfg)
result = sender.send_to_pushover("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.pushover_sender.requests.post")
def test_send_success_returns_true(self, mock_post):
mock_post.return_value = _response(200, {"status": 1})
cfg = _config(pushover_user_key="U", pushover_api_token="T")
sender = PushoverSender(cfg)
result = sender.send_to_pushover("hello")
self.assertTrue(result)
call_data = mock_post.call_args[1]["data"]
self.assertEqual(call_data["user"], "U")
self.assertEqual(call_data["token"], "T")
@mock.patch("src.notification_sender.pushover_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_plain_text(self, mock_post):
mock_post.return_value = _response(200, {"status": 1})
cfg = _config(pushover_user_key="U", pushover_api_token="T")
sender = PushoverSender(cfg)
result = sender.send_to_pushover("[dsa-market-region]: # (us)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
call_data = mock_post.call_args[1]["data"]
self.assertNotIn("[dsa-market-region]", call_data["message"])
self.assertIn("🎯 Market Review", call_data["message"])
self.assertIn("Body", call_data["message"])
@mock.patch("time.sleep")
@mock.patch("src.notification_sender.pushover_sender.requests.post")
def test_send_chunked_uses_test_timeout(self, mock_post, _mock_sleep):
mock_post.return_value = _response(200, {"status": 1})
cfg = _config(pushover_user_key="U", pushover_api_token="T")
sender = PushoverSender(cfg)
result = sender.send_to_pushover("\n\n".join(["A" * 800, "B" * 800, "C" * 800]), timeout_seconds=9)
self.assertTrue(result)
self.assertGreaterEqual(mock_post.call_count, 2)
self.assertTrue(all(call.kwargs["timeout"] == 9 for call in mock_post.call_args_list))
class TestPushplusSender(unittest.TestCase):
"""Unit tests for PushplusSender."""
def test_send_returns_false_when_no_token(self):
cfg = _config()
sender = PushplusSender(cfg)
result = sender.send_to_pushplus("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.pushplus_sender.requests.post")
def test_send_success_returns_true(self, mock_post):
mock_post.return_value = _response(200, {"code": 200})
cfg = _config(pushplus_token="TOKEN")
sender = PushplusSender(cfg)
result = sender.send_to_pushplus("hello")
self.assertTrue(result)
@mock.patch("src.notification_sender.pushplus_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_content(self, mock_post):
mock_post.return_value = _response(200, {"code": 200})
cfg = _config(pushplus_token="TOKEN")
sender = PushplusSender(cfg)
result = sender.send_to_pushplus("[dsa-market-region]: # (cn)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
self.assertEqual(
mock_post.call_args.kwargs["json"]["content"],
"# 🎯 Market Review\n\nBody",
)
@mock.patch("src.notification_sender.pushplus_sender.time.sleep")
@mock.patch("src.notification_sender.pushplus_sender.requests.post")
def test_send_long_message_chunks_pushplus_requests(self, mock_post, _mock_sleep):
mock_post.return_value = _response(200, {"code": 200})
cfg = _config(pushplus_token="TOKEN")
sender = PushplusSender(cfg)
result = sender.send_to_pushplus("A" * 25000)
self.assertTrue(result)
self.assertGreaterEqual(mock_post.call_count, 2)
class TestServerchan3Sender(unittest.TestCase):
"""Unit tests for Serverchan3Sender."""
def test_send_returns_false_when_no_sendkey(self):
cfg = _config()
sender = Serverchan3Sender(cfg)
result = sender.send_to_serverchan3("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.serverchan3_sender.requests.post")
def test_send_success_returns_true(self, mock_post):
mock_post.return_value = _response(200, {"code": 0})
cfg = _config(serverchan3_sendkey="SCT123")
sender = Serverchan3Sender(cfg)
result = sender.send_to_serverchan3("hello")
self.assertTrue(result)
@mock.patch("src.notification_sender.serverchan3_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_desp(self, mock_post):
mock_post.return_value = _response(200, {"code": 0})
cfg = _config(serverchan3_sendkey="SCT123")
sender = Serverchan3Sender(cfg)
result = sender.send_to_serverchan3("[dsa-market-region]: # (us)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
self.assertEqual(
mock_post.call_args.kwargs["json"]["desp"],
"# 🎯 Market Review\n\nBody",
)
class TestSlackSender(unittest.TestCase):
"""Unit tests for SlackSender."""
def test_send_returns_false_when_not_configured(self):
cfg = _config()
sender = SlackSender(cfg)
result = sender.send_to_slack("hello")
self.assertFalse(result)
def test_is_slack_configured_webhook_only(self):
cfg = _config(slack_webhook_url="https://hooks.slack.com/services/T/B/xxx")
sender = SlackSender(cfg)
self.assertTrue(sender._is_slack_configured())
def test_is_slack_configured_bot_only(self):
cfg = _config(slack_bot_token="xoxb-test", slack_channel_id="C123")
sender = SlackSender(cfg)
self.assertTrue(sender._is_slack_configured())
def test_is_slack_configured_neither(self):
cfg = _config()
sender = SlackSender(cfg)
self.assertFalse(sender._is_slack_configured())
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_webhook_success(self, mock_post):
resp = mock.MagicMock()
resp.status_code = 200
resp.text = "ok"
mock_post.return_value = resp
cfg = _config(slack_webhook_url="https://hooks.slack.com/services/T/B/xxx")
sender = SlackSender(cfg)
result = sender.send_to_slack("hello")
self.assertTrue(result)
mock_post.assert_called_once()
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_webhook_http_error_returns_false(self, mock_post):
resp = mock.MagicMock()
resp.status_code = 400
resp.text = "invalid_payload"
mock_post.return_value = resp
cfg = _config(slack_webhook_url="https://hooks.slack.com/services/T/B/xxx")
sender = SlackSender(cfg)
result = sender.send_to_slack("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_bot_success(self, mock_post):
mock_post.return_value = _response(200, {"ok": True})
cfg = _config(slack_bot_token="xoxb-test", slack_channel_id="C123")
sender = SlackSender(cfg)
result = sender.send_to_slack("hello")
self.assertTrue(result)
self.assertIn("chat.postMessage", mock_post.call_args[0][0])
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_bot_error_returns_false(self, mock_post):
mock_post.return_value = _response(200, {"ok": False, "error": "channel_not_found"})
cfg = _config(slack_bot_token="xoxb-test", slack_channel_id="C123")
sender = SlackSender(cfg)
result = sender.send_to_slack("hello")
self.assertFalse(result)
def test_build_blocks_splits_long_content(self):
cfg = _config(slack_webhook_url="https://hooks.slack.com/services/T/B/xxx")
sender = SlackSender(cfg)
content = "A" * 6500 # > 3000 * 2, should produce 3 blocks
blocks = sender._build_blocks(content)
self.assertEqual(len(blocks), 3)
self.assertEqual(blocks[0]["type"], "section")
self.assertEqual(blocks[0]["text"]["type"], "mrkdwn")
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_preserves_legacy_text_payload(self, mock_post):
resp = mock.MagicMock()
resp.status_code = 200
resp.text = "ok"
mock_post.return_value = resp
cfg = _config(slack_webhook_url="https://hooks.slack.com/services/T/B/xxx")
sender = SlackSender(cfg)
result = sender.send_to_slack("## 日报\n\n[详情](https://example.com/report)")
self.assertTrue(result)
payload = json.loads(mock_post.call_args.kwargs["data"].decode("utf-8"))
self.assertIn("## 日报", payload["text"])
self.assertIn("[详情](https://example.com/report)", payload["text"])
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_text_payload(self, mock_post):
resp = mock.MagicMock()
resp.status_code = 200
resp.text = "ok"
mock_post.return_value = resp
cfg = _config(slack_webhook_url="https://hooks.slack.com/services/T/B/xxx")
sender = SlackSender(cfg)
result = sender.send_to_slack("[dsa-market-region]: # (us)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
payload = json.loads(mock_post.call_args.kwargs["data"].decode("utf-8"))
self.assertNotIn("[dsa-market-region]", payload["text"])
self.assertIn("# 🎯 Market Review", payload["text"])
self.assertIn("Body", payload["text"])
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_preserves_standard_markdown_reference_definitions(self, mock_post):
resp = mock.MagicMock()
resp.status_code = 200
resp.text = "ok"
mock_post.return_value = resp
cfg = _config(slack_webhook_url="https://hooks.slack.com/services/T/B/xxx")
sender = SlackSender(cfg)
content = "[详情][ref]\n\n[ref]: https://example.com/report"
result = sender.send_to_slack(content)
self.assertTrue(result)
payload = json.loads(mock_post.call_args.kwargs["data"].decode("utf-8"))
self.assertIn("[详情][ref]", payload["text"])
self.assertIn("[ref]: https://example.com/report", payload["text"])
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_text_prefers_bot_when_both_configured(self, mock_post):
"""When both webhook and bot are configured, text must go via bot
so it lands in the same channel as images."""
mock_post.return_value = _response(200, {"ok": True})
cfg = _config(
slack_webhook_url="https://hooks.slack.com/services/T/B/xxx",
slack_bot_token="xoxb-test",
slack_channel_id="C123",
)
sender = SlackSender(cfg)
result = sender.send_to_slack("hello")
self.assertTrue(result)
self.assertIn("chat.postMessage", mock_post.call_args[0][0])
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_image_bot_success(self, mock_post):
# Mock three sequential calls: getUploadURLExternal, PUT upload, completeUploadExternal
mock_post.side_effect = [
_response(200, {"ok": True, "upload_url": "https://files.slack.com/upload/v1/test", "file_id": "F123"}),
_response(200, {}),
_response(200, {"ok": True}),
]
cfg = _config(slack_bot_token="xoxb-test", slack_channel_id="C123")
sender = SlackSender(cfg)
result = sender._send_slack_image(b"PNG_BYTES")
self.assertTrue(result)
self.assertEqual(mock_post.call_count, 3)
self.assertIn("getUploadURLExternal", mock_post.call_args_list[0][0][0])
# Step 2: upload must send raw bytes (not multipart) to match declared length
upload_call_kwargs = mock_post.call_args_list[1][1]
self.assertEqual(upload_call_kwargs.get("data"), b"PNG_BYTES")
self.assertNotIn("files", upload_call_kwargs)
self.assertIn("completeUploadExternal", mock_post.call_args_list[2][0][0])
@mock.patch("src.notification_sender.slack_sender.requests.post")
def test_send_image_fallback_to_text_when_no_bot(self, mock_post):
resp = mock.MagicMock()
resp.status_code = 200
resp.text = "ok"
mock_post.return_value = resp
cfg = _config(slack_webhook_url="https://hooks.slack.com/services/T/B/xxx")
sender = SlackSender(cfg)
result = sender._send_slack_image(b"PNG_BYTES", fallback_content="fallback text")
self.assertTrue(result)
class TestTelegramSender(unittest.TestCase):
"""Unit tests for TelegramSender."""
def test_send_returns_false_when_not_configured(self):
cfg = _config()
sender = TelegramSender(cfg)
result = sender.send_to_telegram("hello")
self.assertFalse(result)
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_success_returns_true(self, mock_post):
mock_post.return_value = _response(200, {"ok": True})
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
result = sender.send_to_telegram("hello")
self.assertTrue(result)
self.assertIn("sendMessage", mock_post.call_args[0][0])
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_retries_plain_text_when_markdown_http_400(self, mock_post):
markdown_error = _response(400)
markdown_error.text = (
'{"ok":false,"error_code":400,"description":"Bad Request: can\'t parse entities"}'
)
plain_text_success = _response(200, {"ok": True})
mock_post.side_effect = [markdown_error, plain_text_success]
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
result = sender.send_to_telegram("*ST宝实")
self.assertTrue(result)
self.assertEqual(mock_post.call_count, 2)
first_payload = mock_post.call_args_list[0][1]["json"]
second_payload = mock_post.call_args_list[1][1]["json"]
self.assertEqual(first_payload["parse_mode"], "Markdown")
self.assertNotIn("parse_mode", second_payload)
self.assertEqual(second_payload["text"], "*ST宝实")
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_plain_text_fallback_keeps_original_text_after_legacy_markdown_error(self, mock_post):
markdown_error = _response(400)
markdown_error.text = (
'{"ok":false,"error_code":400,"description":"Bad Request: can\'t parse entities"}'
)
plain_text_success = _response(200, {"ok": True})
mock_post.side_effect = [markdown_error, plain_text_success]
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
content = "关注 **AAPL** (未闭合)"
result = sender.send_to_telegram(content)
self.assertTrue(result)
first_payload = mock_post.call_args_list[0][1]["json"]
second_payload = mock_post.call_args_list[1][1]["json"]
self.assertEqual(first_payload["text"], "关注 *AAPL* \\(未闭合\\)")
self.assertEqual(second_payload["text"], content)
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_uses_legacy_telegram_report_formatter(self, mock_post):
mock_post.return_value = _response(200, {"ok": True})
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
content = (
"# 日报\n\n"
"## 📊 分析结果摘要\n\n"
"| 股票 | 信号 |\n"
"| --- | --- |\n"
"| 600519 | 强势 |\n\n"
"[详情](https://example.com/report)"
)
result = sender.send_to_telegram(content)
self.assertTrue(result)
payload = mock_post.call_args.kwargs["json"]
rendered = payload["text"]
self.assertIn("日报", rendered)
self.assertIn("📊 分析结果摘要", rendered)
self.assertIn("| 股票 | 信号 |", rendered)
self.assertIn("[详情](https://example.com/report)", rendered)
self.assertNotIn("# 日报", rendered)
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_strips_hidden_market_region_metadata_from_text_payload(self, mock_post):
mock_post.return_value = _response(200, {"ok": True})
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
result = sender.send_to_telegram("[dsa-market-region]: # (us)\n\n# 🎯 Market Review\n\nBody")
self.assertTrue(result)
payload = mock_post.call_args.kwargs["json"]
self.assertNotIn("[dsa-market-region]", payload["text"])
self.assertIn("🎯 Market Review", payload["text"])
self.assertIn("Body", payload["text"])
@mock.patch.object(TelegramSender, "_send_telegram_message", return_value=True)
def test_send_telegram_chunked_splits_large_reason_without_delimiter(self, mock_send_telegram_message):
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
long_reason = "原因过长需分段测试:" + ("A" * 5000)
content = f"**AI 决策信号**\n- 理由: {long_reason}\n"
result = sender._send_telegram_chunked(
"http://api.telegram.org",
"CHAT",
sender._convert_to_telegram_markdown(content),
max_length=4096,
timeout_seconds=3,
)
self.assertTrue(result)
self.assertGreaterEqual(len(mock_send_telegram_message.call_args_list), 2)
for call in mock_send_telegram_message.call_args_list:
sent_text = call.args[2]
self.assertLessEqual(len(sent_text), 4096)
@mock.patch.object(TelegramSender, "_send_telegram_message", return_value=True)
def test_send_to_telegram_chunked_path_with_long_reason_without_delimiter(self, mock_send_telegram_message):
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
long_reason = "原因过长需分段测试:" + ("B" * 5000)
content = f"**AI 决策信号**\n- 理由: {long_reason}\n"
result = sender.send_to_telegram(content)
self.assertTrue(result)
self.assertGreaterEqual(len(mock_send_telegram_message.call_args_list), 2)
for call in mock_send_telegram_message.call_args_list:
sent_text = call.args[2]
self.assertLessEqual(len(sent_text), 4096)
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_chunks_by_final_markdown_payload_length(self, mock_post):
mock_post.return_value = _response(200, {"ok": True})
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
content = "(" * 4090
result = sender.send_to_telegram(content)
self.assertTrue(result)
self.assertGreaterEqual(mock_post.call_count, 2)
payload_texts = [call.kwargs["json"]["text"] for call in mock_post.call_args_list]
self.assertTrue(all(len(text) <= 4096 for text in payload_texts))
self.assertEqual("".join(payload_texts), sender._convert_to_telegram_markdown(content))
@mock.patch("src.notification_sender.telegram_sender.requests.post")
def test_send_plain_text_fallback_handles_non_json_200(self, mock_post):
markdown_error = _response(400)
markdown_error.text = (
'{"ok":false,"error_code":400,"description":"Bad Request: can\'t parse entities"}'
)
plain_text_non_json = _response(200)
plain_text_non_json.text = "upstream proxy error"
plain_text_non_json.json.side_effect = ValueError("invalid json")
mock_post.side_effect = [markdown_error, plain_text_non_json]
cfg = _config(telegram_bot_token="BOT", telegram_chat_id="CHAT")
sender = TelegramSender(cfg)
result = sender.send_to_telegram("*ST宝实")
self.assertFalse(result)
self.assertEqual(mock_post.call_count, 2)
if __name__ == "__main__":
unittest.main()