Files
MoviePilot/tests/test_message_ingress.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

349 lines
11 KiB
Python
Raw Permalink 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.
"""
多消息渠道复用统一宿主回环入口的契约测试。
"""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import parse_qs, urlparse
import pytest
from app.application.messaging import ingress
from app.modules.discord import discord as discord_module
from app.modules.feishu import feishu as feishu_module
from app.modules.qqbot import qqbot as qqbot_module
from app.modules.slack import slack as slack_module
from app.modules.telegram import telegram as telegram_module
from app.modules.wechat import wechatbot as wechat_module
from app.modules.wechatclawbot import wechatclawbot as clawbot_module
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class _FakeMessageIngressPort:
"""
返回固定状态码并记录同步、异步回环投递。
"""
def __init__(self, status_code=200) -> None:
"""
保存固定状态码。
"""
self.status_code = status_code
self.sync_calls = []
self.async_calls = []
def post(self, url, payload, *, headers, timeout):
"""
记录同步投递并返回固定状态码。
"""
self.sync_calls.append((url, payload, headers, timeout))
return self.status_code
async def async_post(self, url, payload, *, headers, timeout):
"""
记录异步投递并返回固定状态码。
"""
self.async_calls.append((url, payload, headers, timeout))
return self.status_code
@pytest.fixture(autouse=True)
def restore_message_ingress_port():
"""
隔离消息回环端口并在用例结束后恢复。
"""
previous = ingress.configure_message_ingress_port(_FakeMessageIngressPort())
yield
ingress.reset_message_ingress_port(previous)
def _patch_ingress_settings(monkeypatch, **values):
"""
通过只读运行配置端口注入回环入口测试设置。
"""
settings = SimpleNamespace(**values)
monkeypatch.setattr(
ingress,
"get_runtime_setting",
lambda key: getattr(settings, key),
)
def test_forward_message_to_host_keeps_token_out_of_url(monkeypatch):
"""
统一入口仅编码来源参数,并通过请求头传递凭据和 payload 副本。
"""
port = _FakeMessageIngressPort()
ingress.configure_message_ingress_port(port)
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value")
payload = {"text": "hello"}
assert ingress.forward_message_to_host(
payload,
"channel & one",
timeout=9,
) is True
url, forwarded, headers, timeout = port.sync_calls[0]
assert urlparse(url).path == "/api/v1/message"
assert parse_qs(urlparse(url).query) == {
"source": ["channel & one"],
}
assert headers == {"X-API-KEY": "token value"}
assert forwarded == {"text": "hello"}
assert forwarded is not payload
assert timeout == 9
@pytest.mark.parametrize("status_code", [None, 400, 500])
def test_forward_message_to_host_rejects_unconfirmed_response(
monkeypatch,
status_code,
):
"""
本地入口无响应或返回错误状态时不得宣称渠道消息已接收。
"""
ingress.configure_message_ingress_port(_FakeMessageIngressPort(status_code))
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token")
assert ingress.forward_message_to_host({}, "channel") is False
def test_forward_message_to_host_fails_closed_when_port_is_unconfigured(monkeypatch):
"""
组合根未装配时公开入口稳定拒绝,不得懒加载具体 Adapter。
"""
ingress.reset_message_ingress_port()
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token")
assert ingress.forward_message_to_host({}, "channel") is False
@pytest.mark.asyncio
async def test_async_forward_message_to_host_uses_same_contract(monkeypatch):
"""
自有事件循环的渠道必须复用同一 URL、payload 和确认规则。
"""
port = _FakeMessageIngressPort()
ingress.configure_message_ingress_port(port)
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value")
assert await ingress.async_forward_message_to_host(
{"text": "hello"},
"discord & one",
timeout=10,
) is True
url, payload, headers, timeout = port.async_calls[0]
assert parse_qs(urlparse(url).query) == {
"source": ["discord & one"],
}
assert headers == {"X-API-KEY": "token value"}
assert payload == {"text": "hello"}
assert timeout == 10
def test_startup_message_ingress_adapter_closes_sync_response(monkeypatch):
"""
生产同步注入适配层必须负责关闭 HTTP 响应。
"""
from app.startup.composition import network as network_composition
response = SimpleNamespace(status_code=202, close=MagicMock())
request = MagicMock()
request.post_res.return_value = response
monkeypatch.setattr(
network_composition,
"RequestUtils",
MagicMock(return_value=request),
)
status_code = network_composition._MessageIngressAdapter().post(
"http://127.0.0.1/message",
{"text": "hello"},
headers={"X-API-KEY": "secret"},
timeout=9,
)
assert status_code == 202
network_composition.RequestUtils.assert_called_once_with(
timeout=9,
headers={"X-API-KEY": "secret"},
)
response.close.assert_called_once_with()
@pytest.mark.asyncio
async def test_startup_message_ingress_adapter_closes_async_response(monkeypatch):
"""
生产异步注入适配层必须负责关闭 HTTP 响应。
"""
from app.startup.composition import network as network_composition
response = SimpleNamespace(status_code=202, aclose=AsyncMock())
request = MagicMock()
request.post_res = AsyncMock(return_value=response)
monkeypatch.setattr(
network_composition,
"AsyncRequestUtils",
MagicMock(return_value=request),
)
status_code = await network_composition._MessageIngressAdapter().async_post(
"http://127.0.0.1/message",
{"text": "hello"},
headers={"X-API-KEY": "secret"},
timeout=10,
)
assert status_code == 202
network_composition.AsyncRequestUtils.assert_called_once_with(
timeout=10,
headers={"X-API-KEY": "secret"},
)
response.aclose.assert_awaited_once_with()
def test_submit_message_to_host_copies_payload_and_reports_admission_failure():
"""
异步渠道提交时冻结顶层 payload,执行器拒绝任务则返回 False。
"""
submitted = []
def submit(function, *args, **kwargs):
"""
记录受管执行器收到的函数和参数。
"""
submitted.append((function, args, kwargs))
payload = {"text": "before"}
assert ingress.submit_message_to_host(
payload,
"channel",
submit=submit,
) is True
payload["text"] = "after"
assert submitted[0][0] is ingress.forward_message_to_host
assert submitted[0][1] == ({"text": "before"}, "channel")
assert submitted[0][2] == {"timeout": 15}
def reject(*_args, **_kwargs):
"""
模拟生命周期关闭后的执行器拒绝新任务。
"""
raise RuntimeError("executor closed")
assert ingress.submit_message_to_host({}, "channel", submit=reject) is False
@pytest.mark.parametrize(
("module", "client_type", "source_attr"),
[
(feishu_module, feishu_module.Feishu, "_name"),
(qqbot_module, qqbot_module.QQBot, "_config_name"),
(wechat_module, wechat_module.WeChatBot, "_config_name"),
],
)
def test_threaded_channels_submit_through_managed_executor(
monkeypatch,
module,
client_type,
source_attr,
):
"""
原裸线程渠道必须把回环任务交给共享 ThreadHelper。
"""
calls = []
executor = SimpleNamespace(submit=lambda *_args, **_kwargs: None)
def submit_message(payload, source, *, submit, timeout=15):
"""
记录渠道传给统一提交边界的参数。
"""
calls.append((payload, source, submit, timeout))
return True
monkeypatch.setattr(module, "ThreadHelper", lambda: executor)
monkeypatch.setattr(module, "submit_message_to_host", submit_message)
client = object.__new__(client_type)
setattr(client, source_attr, "channel-main")
assert client._forward_to_message_chain({"text": "hello"}) is True
assert calls == [({"text": "hello"}, "channel-main", executor.submit, 15)]
@pytest.mark.parametrize(
("module", "client_type", "source_attr"),
[
(telegram_module, telegram_module.Telegram, "_config_name"),
(clawbot_module, clawbot_module.WechatClawBot, "_config_name"),
],
)
def test_sync_channels_forward_through_shared_ingress(
monkeypatch,
module,
client_type,
source_attr,
):
"""
同步轮询渠道必须复用统一回环请求和确认语义。
"""
forward = MagicMock(return_value=True)
monkeypatch.setattr(module, "forward_message_to_host", forward)
client = object.__new__(client_type)
setattr(client, source_attr, "channel-main")
assert client._forward_to_message_chain({"text": "hello"}) is True
forward.assert_called_once_with({"text": "hello"}, "channel-main")
def test_slack_preserves_callback_timeout_through_shared_ingress(monkeypatch):
"""
Slack action 的历史长超时必须继续传给统一入口。
"""
forward = MagicMock(return_value=True)
monkeypatch.setattr(slack_module, "forward_message_to_host", forward)
client = object.__new__(slack_module.Slack)
client._config_name = "slack-main"
assert client._forward_to_message_chain({"action": "run"}, timeout=60) is True
forward.assert_called_once_with(
{"action": "run"},
"slack-main",
timeout=60,
)
@pytest.mark.asyncio
async def test_discord_uses_shared_async_ingress(monkeypatch):
"""
Discord 自有事件循环不得继续维护独立 httpx 回环实现。
"""
forward = AsyncMock(return_value=True)
monkeypatch.setattr(discord_module, "async_forward_message_to_host", forward)
client = object.__new__(discord_module.Discord)
client._config_name = "discord-main"
await client._post_to_ds({"text": "hello"})
forward.assert_awaited_once_with(
{"text": "hello"},
"discord-main",
timeout=10,
)
def test_message_modules_cannot_reimplement_loopback_endpoint():
"""
消息模块不得重新拼接宿主 URL,新增渠道必须复用统一 ingress。
"""
violations = []
for path in (PROJECT_ROOT / "app" / "modules").rglob("*.py"):
if "/api/v1/message" in path.read_text(encoding="utf-8-sig"):
violations.append(path.relative_to(PROJECT_ROOT).as_posix())
assert violations == []