Files
MoviePilot/tests/test_notification_optional_payloads.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

136 lines
6.5 KiB
Python

"""
验证通知渠道对缺失资源、附件类型及可选实例的兼容处理。
"""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.modules.dingtalk import DingTalkModule
from app.modules.discord import DiscordModule
from app.modules.feishu.module import FeishuModule
from app.modules.qqbot.module import QQBotModule
from app.modules.qqbot.qqbot import QQBot
from app.modules.slack.slack import Slack
from app.modules.synologychat.synologychat import SynologyChat
from app.modules.vocechat import VoceChatModule
from app.modules.vocechat.vocechat import VoceChat
from app.modules.wechat import WechatModule
from app.modules.wechat.wechatbot import WeChatBot
from app.modules.wechatclawbot import WechatClawBotModule
from app.schemas.message import Message, MessageResponse
from app.schemas.types import NotificationChannel
@pytest.mark.parametrize("client_class", [QQBot, SynologyChat, VoceChat, WeChatBot, Slack])
def test_torrent_notifications_skip_missing_torrent_and_preserve_valid_items(client_class, monkeypatch):
"""
列表中的缺失资源不阻断有效候选发送,也不会把空上下文渲染为一条种子。
"""
client = object.__new__(client_class)
sent = Mock(return_value=True)
if client_class is QQBot:
monkeypatch.setattr(client, "send_msg", sent)
elif client_class is SynologyChat:
client._webhook_url = "https://chat.test"
client._token = "test"
monkeypatch.setattr(client, "_SynologyChat__send_request", sent)
elif client_class is VoceChat:
client._client = Mock()
monkeypatch.setattr(client, "_VoceChat__send_request", sent)
elif client_class is WeChatBot:
monkeypatch.setattr(client, "_send_markdown", sent)
else:
client._client = SimpleNamespace(chat_postMessage=sent)
valid = Context(torrent_info=TorrentInfo(title="Demo.2026.1080p", site_name="ExampleSite", size=1024, seeders=3))
assert client.send_torrents_msg([Context(), valid], userid="1", title="候选") is True
assert sent.call_count == 1
assert "ExampleSite" in str(sent.call_args)
assert str(sent.call_args).count("ExampleSite") == 1
def test_dingtalk_missing_torrent_does_not_hide_later_candidate(monkeypatch):
"""
钉钉 Markdown 列表跳过空资源后继续展示可点击的有效候选。
"""
module = object.__new__(DingTalkModule)
sent = Mock()
monkeypatch.setattr(module, "_post_list_message", sent)
message = Message(title="候选")
module.post_torrents_message(message, [Context(), Context(torrent_info=TorrentInfo(
title="Demo", page_url="https://site.test/details/1",
))])
sent.assert_called_once_with(message, "2. [Demo](https://site.test/details/1)")
@pytest.mark.parametrize(("module_class", "scheme", "method", "module_path"), [
(DiscordModule, "discord", "download_discord_file_bytes", "app.modules.discord"),
(QQBotModule, "qq", "download_qq_file_bytes", "app.modules.qqbot.module"),
])
@pytest.mark.parametrize("payload", [b"attachment", "invalid text", None])
def test_attachment_downloads_require_bytes(module_class, scheme, method, module_path, payload, monkeypatch):
"""
附件传输仅返回原始字节,错误文本或空响应不会进入语音与文件处理流程。
"""
module = object.__new__(module_class)
monkeypatch.setattr(module, "get_config", Mock(return_value=SimpleNamespace(name="source")))
request = SimpleNamespace(get_res=Mock(return_value=SimpleNamespace(content=payload)))
monkeypatch.setattr(f"{module_path}.RequestUtils", Mock(return_value=request))
result = getattr(module, method)(f"{scheme}://file/https://cdn.test/file", "source")
assert result == (payload if isinstance(payload, bytes) else None)
request.get_res.assert_called_once_with("https://cdn.test/file")
@pytest.mark.parametrize("reference", ["wxwork://media_id/image", "wxbot://image/image"])
@pytest.mark.parametrize("payload", ["data:image/png;base64,c2FmZQ==", b"invalid"])
def test_wechat_images_only_expose_data_url_text(reference, payload, monkeypatch):
"""
应用与机器人图片下载结果必须是文本地址,字节结果不能作为 data URL 外泄。
"""
module = object.__new__(WechatModule)
client = SimpleNamespace(download_media_to_data_url=Mock(return_value=payload), download_image_to_data_url=Mock(return_value=payload))
monkeypatch.setattr(module, "get_config", Mock(return_value=SimpleNamespace(name="source")))
monkeypatch.setattr(module, "get_instance", Mock(return_value=client))
assert module.download_wechat_image_to_data_url(reference, "source") == (payload if isinstance(payload, str) else None)
assert module._commands_enabled(None) is False
@pytest.mark.parametrize("module_class", [VoceChatModule, WechatClawBotModule])
def test_notification_without_active_client_does_not_send(module_class, monkeypatch):
"""
配置存在而客户端尚未激活时,列表通知停止发送,不访问可选实例属性。
"""
module = object.__new__(module_class)
monkeypatch.setattr(module, "get_configs", Mock(return_value={"source": SimpleNamespace(name="source")}))
monkeypatch.setattr(module, "check_message", Mock(return_value=True))
instance = Mock(return_value=None)
monkeypatch.setattr(module, "get_instance", instance)
module.post_medias_message(Message(title="媒体"), [MediaInfo(title="Demo")])
assert instance.call_count == 1
def test_feishu_invalid_stream_state_does_not_finalize_card(monkeypatch):
"""
非字典流式卡片状态不能被当作可关闭的消息,避免发送错误的卡片 ID。
"""
module = object.__new__(FeishuModule)
module._channel = NotificationChannel.Feishu
client = Mock()
monkeypatch.setattr(module, "get_instance", client)
response = MessageResponse(channel=NotificationChannel.Feishu, metadata={"feishu_streaming": ["invalid"]})
assert module.finalize_message(response) is False
client.assert_not_called()
@pytest.mark.parametrize(("manifest", "expected"), [("{\"features\": {}}", {"features": {}}), ([], None)])
def test_slack_manifest_export_requires_object(manifest, expected):
"""
Slack 清单支持 JSON 文本与对象,错误集合类型不会进入命令更新流程。
"""
client = object.__new__(Slack)
client._app_id = "app"
client._manifest_client = SimpleNamespace(apps_manifest_export=Mock(return_value={"ok": True, "manifest": manifest}))
assert client._export_manifest() == expected