mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
485 lines
18 KiB
Python
485 lines
18 KiB
Python
"""Tests for the WeCom platform adapter."""
|
||
|
||
import asyncio
|
||
import base64
|
||
import os
|
||
import socket
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from gateway.config import PlatformConfig
|
||
from gateway.platforms.base import SendResult
|
||
|
||
|
||
class TestWeComRequirements:
|
||
def test_returns_false_without_aiohttp(self, monkeypatch):
|
||
monkeypatch.setattr("plugins.platforms.wecom.adapter.AIOHTTP_AVAILABLE", False)
|
||
monkeypatch.setattr("plugins.platforms.wecom.adapter.HTTPX_AVAILABLE", True)
|
||
from plugins.platforms.wecom.adapter import check_wecom_requirements
|
||
|
||
assert check_wecom_requirements() is False
|
||
|
||
|
||
class TestWeComAdapterInit:
|
||
def test_declares_non_editable_message_capability(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
assert WeComAdapter.SUPPORTS_MESSAGE_EDITING is False
|
||
|
||
|
||
class TestWeComConnect:
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_connect_records_handshake_failure_details(self, monkeypatch):
|
||
import plugins.platforms.wecom.adapter as wecom_module
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
class DummyClient:
|
||
async def aclose(self):
|
||
return None
|
||
|
||
monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True)
|
||
monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True)
|
||
monkeypatch.setattr(
|
||
wecom_module,
|
||
"httpx",
|
||
SimpleNamespace(AsyncClient=lambda **kwargs: DummyClient()),
|
||
)
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(enabled=True, extra={"bot_id": "bot-1", "secret": "secret-1"})
|
||
)
|
||
adapter._open_connection = AsyncMock(side_effect=RuntimeError("invalid secret (errcode=40013)"))
|
||
|
||
success = await adapter.connect()
|
||
|
||
assert success is False
|
||
assert adapter.has_fatal_error is True
|
||
assert adapter.fatal_error_code == "wecom_connect_error"
|
||
assert "invalid secret" in (adapter.fatal_error_message or "")
|
||
|
||
|
||
class TestWeComQrScan:
|
||
@patch("plugins.platforms.wecom.adapter.time")
|
||
@patch("plugins.platforms.wecom.adapter.json.loads")
|
||
@patch("plugins.platforms.wecom.adapter.logger")
|
||
@patch("urllib.request.urlopen")
|
||
@patch("urllib.request.Request")
|
||
def test_qr_scan_timeout_uses_monotonic_clock(
|
||
self,
|
||
mock_request,
|
||
mock_urlopen,
|
||
_mock_logger,
|
||
mock_json_loads,
|
||
mock_time,
|
||
):
|
||
from plugins.platforms.wecom.adapter import qr_scan_for_bot_info
|
||
|
||
generate_resp = MagicMock()
|
||
generate_resp.read.return_value = b'{"data":{"scode":"abc","auth_url":"https://example.com/qr"}}'
|
||
generate_resp.__enter__.return_value = generate_resp
|
||
generate_resp.__exit__.return_value = False
|
||
|
||
poll_resp = MagicMock()
|
||
poll_resp.read.return_value = b'{"data":{"status":"pending"}}'
|
||
poll_resp.__enter__.return_value = poll_resp
|
||
poll_resp.__exit__.return_value = False
|
||
|
||
mock_urlopen.side_effect = [generate_resp, poll_resp]
|
||
mock_json_loads.side_effect = [
|
||
{"data": {"scode": "abc", "auth_url": "https://example.com/qr"}},
|
||
{"data": {"status": "pending"}},
|
||
]
|
||
mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1]
|
||
mock_time.time.side_effect = [1000, 900, 901, 902]
|
||
mock_time.sleep = MagicMock()
|
||
|
||
with patch("builtins.print"), patch.dict("sys.modules", {"qrcode": None}):
|
||
result = qr_scan_for_bot_info(timeout_seconds=1)
|
||
|
||
assert result is None
|
||
assert mock_urlopen.call_count == 2
|
||
|
||
|
||
class TestWeComReplyMode:
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_image_file_uses_passive_reply_media_when_reply_context_exists(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._reply_req_ids["msg-1"] = "req-1"
|
||
adapter._prepare_outbound_media = AsyncMock(
|
||
return_value={
|
||
"data": b"image-bytes",
|
||
"content_type": "image/png",
|
||
"file_name": "demo.png",
|
||
"detected_type": "image",
|
||
"final_type": "image",
|
||
"rejected": False,
|
||
"reject_reason": None,
|
||
"downgraded": False,
|
||
"downgrade_note": None,
|
||
}
|
||
)
|
||
adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "image"})
|
||
adapter._send_reply_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "req-1"}, "errcode": 0}
|
||
)
|
||
|
||
result = await adapter.send_image_file("chat-123", "/tmp/demo.png", reply_to="msg-1")
|
||
|
||
assert result.success is True
|
||
adapter._send_reply_request.assert_awaited_once()
|
||
args = adapter._send_reply_request.await_args.args
|
||
assert args[0] == "req-1"
|
||
assert args[1] == {"msgtype": "image", "image": {"media_id": "media-1"}}
|
||
|
||
|
||
class TestExtractText:
|
||
|
||
def test_extracts_mixed_text(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
body = {
|
||
"msgtype": "mixed",
|
||
"mixed": {
|
||
"msg_item": [
|
||
{"msgtype": "text", "text": {"content": "part1"}},
|
||
{"msgtype": "image", "image": {"url": "https://example.com/x.png"}},
|
||
{"msgtype": "text", "text": {"content": "part2"}},
|
||
]
|
||
},
|
||
}
|
||
text, _reply_text = WeComAdapter._extract_text(body)
|
||
assert text == "part1\npart2"
|
||
|
||
|
||
class TestCallbackDispatch:
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("cmd", ["aibot_msg_callback", "aibot_callback"])
|
||
async def test_dispatch_accepts_new_and_legacy_callback_cmds(self, cmd):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._on_message = AsyncMock()
|
||
|
||
await adapter._dispatch_payload({"cmd": cmd, "headers": {"req_id": "req-1"}, "body": {}})
|
||
|
||
adapter._on_message.assert_awaited_once()
|
||
|
||
|
||
class TestPolicyHelpers:
|
||
|
||
def test_dm_allowlist_honors_env_only_allowed_users(self, monkeypatch):
|
||
"""Env-only setup (WECOM_DM_POLICY + WECOM_ALLOWED_USERS, no config
|
||
``extra``) must populate the DM allowlist. Otherwise ``dm_policy:
|
||
allowlist`` runs with an empty allowlist and drops every listed user
|
||
at intake — the documented env vars become no-ops."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
monkeypatch.setenv("WECOM_DM_POLICY", "allowlist")
|
||
monkeypatch.setenv("WECOM_ALLOWED_USERS", "user-1, user-2")
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
|
||
assert adapter._dm_policy == "allowlist"
|
||
assert adapter._allow_from == ["user-1", "user-2"]
|
||
assert adapter._is_dm_allowed("user-1") is True
|
||
assert adapter._is_dm_allowed("user-2") is True
|
||
assert adapter._is_dm_allowed("stranger") is False
|
||
|
||
|
||
def test_pairing_group_policy_blocks_without_explicit_group_allow_from(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(enabled=True, extra={"group_policy": "pairing"})
|
||
)
|
||
|
||
assert adapter._is_group_allowed("group-1", "user-1") is False
|
||
|
||
|
||
class TestMediaHelpers:
|
||
def test_detect_wecom_media_type(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
assert WeComAdapter._detect_wecom_media_type("image/png") == "image"
|
||
assert WeComAdapter._detect_wecom_media_type("video/mp4") == "video"
|
||
assert WeComAdapter._detect_wecom_media_type("audio/amr") == "voice"
|
||
assert WeComAdapter._detect_wecom_media_type("application/pdf") == "file"
|
||
|
||
def test_voice_non_amr_downgrades_to_file(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
result = WeComAdapter._apply_file_size_limits(128, "voice", "audio/mpeg")
|
||
|
||
assert result["final_type"] == "file"
|
||
assert result["downgraded"] is True
|
||
assert "AMR" in (result["downgrade_note"] or "")
|
||
|
||
|
||
class TestMediaUpload:
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_download_remote_bytes_blocks_connect_time_rebind(self, monkeypatch):
|
||
import httpcore
|
||
from httpcore._backends.auto import AutoBackend
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
from tools.url_safety import SSRFConnectionBlocked
|
||
|
||
for proxy_var in (
|
||
"HTTP_PROXY",
|
||
"HTTPS_PROXY",
|
||
"ALL_PROXY",
|
||
"http_proxy",
|
||
"https_proxy",
|
||
"all_proxy",
|
||
):
|
||
monkeypatch.delenv(proxy_var, raising=False)
|
||
|
||
answers = iter(("93.184.216.34", "169.254.169.254"))
|
||
|
||
def fake_getaddrinfo(_host, port, *_args, **_kwargs):
|
||
ip = next(answers)
|
||
return [
|
||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0))
|
||
]
|
||
|
||
connect_attempts = []
|
||
|
||
async def fake_connect_tcp(
|
||
_self,
|
||
host,
|
||
port,
|
||
timeout=None,
|
||
local_address=None,
|
||
socket_options=None,
|
||
):
|
||
connect_attempts.append((host, port))
|
||
raise httpcore.ConnectError("stop before network")
|
||
|
||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||
monkeypatch.setattr(AutoBackend, "connect_tcp", fake_connect_tcp)
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
with pytest.raises(SSRFConnectionBlocked):
|
||
await adapter._download_remote_bytes(
|
||
"http://rebind.example/file.bin", max_bytes=1024
|
||
)
|
||
|
||
assert connect_attempts == []
|
||
|
||
|
||
class TestSend:
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_voice_sends_caption_and_downgrade_note(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._prepare_outbound_media = AsyncMock(
|
||
return_value={
|
||
"data": b"voice-bytes",
|
||
"content_type": "audio/mpeg",
|
||
"file_name": "voice.mp3",
|
||
"detected_type": "voice",
|
||
"final_type": "file",
|
||
"rejected": False,
|
||
"reject_reason": None,
|
||
"downgraded": True,
|
||
"downgrade_note": "语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
|
||
}
|
||
)
|
||
adapter._upload_media_bytes = AsyncMock(return_value={"media_id": "media-1", "type": "file"})
|
||
adapter._send_media_message = AsyncMock(return_value={"headers": {"req_id": "req-media"}, "errcode": 0})
|
||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="msg-1"))
|
||
|
||
result = await adapter.send_voice("chat-123", "/tmp/voice.mp3", caption="listen")
|
||
|
||
assert result.success is True
|
||
adapter._send_media_message.assert_awaited_once_with("chat-123", "file", "media-1")
|
||
assert adapter.send.await_count == 2
|
||
adapter.send.assert_any_await(chat_id="chat-123", content="listen", reply_to=None)
|
||
adapter.send.assert_any_await(
|
||
chat_id="chat-123",
|
||
content="ℹ️ 语音格式 audio/mpeg 不支持,企微仅支持 AMR 格式,已转为文件格式发送",
|
||
reply_to=None,
|
||
)
|
||
|
||
|
||
class TestInboundMessages:
|
||
@pytest.mark.asyncio
|
||
async def test_on_message_builds_event(self):
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(
|
||
enabled=True,
|
||
extra={"group_policy": "allowlist", "group_allow_from": ["group-1"]},
|
||
)
|
||
)
|
||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||
adapter.handle_message = AsyncMock()
|
||
adapter._extract_media = AsyncMock(return_value=(["/tmp/test.png"], ["image/png"]))
|
||
|
||
payload = {
|
||
"cmd": "aibot_msg_callback",
|
||
"headers": {"req_id": "req-1"},
|
||
"body": {
|
||
"msgid": "msg-1",
|
||
"chatid": "group-1",
|
||
"chattype": "group",
|
||
"from": {"userid": "user-1"},
|
||
"msgtype": "text",
|
||
"text": {"content": "hello"},
|
||
},
|
||
}
|
||
|
||
await adapter._on_message(payload)
|
||
|
||
adapter.handle_message.assert_awaited_once()
|
||
event = adapter.handle_message.await_args.args[0]
|
||
assert event.text == "hello"
|
||
assert event.source.chat_id == "group-1"
|
||
assert event.source.user_id == "user-1"
|
||
assert event.media_urls == ["/tmp/test.png"]
|
||
assert event.media_types == ["image/png"]
|
||
|
||
|
||
class TestWeComZombieSessionFix:
|
||
"""Tests for PR #11572 — device_id, markdown reply, group req_id fallback."""
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_on_message_does_not_cache_blocked_sender_req_id(self):
|
||
"""Blocked chats shouldn't populate the proactive-send fallback cache."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(
|
||
PlatformConfig(
|
||
enabled=True,
|
||
extra={"group_policy": "allowlist", "group_allow_from": ["group-ok"]},
|
||
)
|
||
)
|
||
adapter.handle_message = AsyncMock()
|
||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||
|
||
payload = {
|
||
"cmd": "aibot_msg_callback",
|
||
"headers": {"req_id": "req-abc"},
|
||
"body": {
|
||
"msgid": "msg-1",
|
||
"chatid": "group-blocked",
|
||
"chattype": "group",
|
||
"from": {"userid": "user-1"},
|
||
"msgtype": "text",
|
||
"text": {"content": "hi"},
|
||
},
|
||
}
|
||
|
||
await adapter._on_message(payload)
|
||
adapter.handle_message.assert_not_awaited()
|
||
assert "group-blocked" not in adapter._last_chat_req_ids
|
||
|
||
def test_remember_chat_req_id_is_bounded(self):
|
||
from plugins.platforms.wecom.adapter import DEDUP_MAX_SIZE, WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
for i in range(DEDUP_MAX_SIZE + 50):
|
||
adapter._remember_chat_req_id(f"chat-{i}", f"req-{i}")
|
||
assert len(adapter._last_chat_req_ids) <= DEDUP_MAX_SIZE
|
||
# The most recently remembered chat must still be present.
|
||
latest = f"chat-{DEDUP_MAX_SIZE + 49}"
|
||
assert adapter._last_chat_req_ids[latest] == f"req-{DEDUP_MAX_SIZE + 49}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_proactive_group_send_falls_back_to_cached_req_id(self):
|
||
"""Sending into a group without reply_to should use the last cached
|
||
req_id via APP_CMD_RESPONSE — WeCom AI Bots cannot initiate APP_CMD_SEND
|
||
in group chats (errcode 600039)."""
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._last_chat_req_ids["group-1"] = "inbound-req-42"
|
||
adapter._send_reply_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "inbound-req-42"}, "errcode": 0}
|
||
)
|
||
adapter._send_request = AsyncMock(
|
||
return_value={"headers": {"req_id": "new"}, "errcode": 0}
|
||
)
|
||
|
||
result = await adapter.send("group-1", "ping", reply_to=None)
|
||
|
||
assert result.success is True
|
||
# Must route through reply (APP_CMD_RESPONSE), not proactive send.
|
||
adapter._send_reply_request.assert_awaited_once()
|
||
adapter._send_request.assert_not_awaited()
|
||
args = adapter._send_reply_request.await_args.args
|
||
assert args[0] == "inbound-req-42"
|
||
assert args[1]["msgtype"] == "markdown"
|
||
assert args[1]["markdown"]["content"] == "ping"
|
||
|
||
|
||
class TestTextBatchFlushRace:
|
||
"""Regression tests for the cancel-delivery race in _flush_text_batch.
|
||
|
||
When asyncio.sleep() fires and Task.cancel() is called before the task
|
||
runs, CPython sets _must_cancel but cannot cancel the already-done sleep
|
||
future. CancelledError is then delivered at the *next* await
|
||
(handle_message), after the task has already popped the event — the
|
||
superseding task sees an empty batch and silently drops the message.
|
||
The fix adds a synchronous task-registry check between the sleep and
|
||
the pop so a superseded task returns before touching the event.
|
||
"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_superseded_task_does_not_pop_or_process_event(self):
|
||
"""A flush task that has been superseded must leave the event in the
|
||
batch dict for the new task to handle."""
|
||
from gateway.platforms.base import MessageEvent, MessageType
|
||
from plugins.platforms.wecom.adapter import WeComAdapter
|
||
|
||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||
adapter._text_batch_delay_seconds = 0
|
||
|
||
key = "test-session"
|
||
event = MessageEvent(text="hello", message_type=MessageType.TEXT)
|
||
adapter._pending_text_batches[key] = event
|
||
|
||
handle_calls = []
|
||
|
||
async def fake_handle(evt):
|
||
handle_calls.append(evt)
|
||
|
||
adapter.handle_message = fake_handle
|
||
|
||
# Create T1 and register it.
|
||
t1 = asyncio.create_task(adapter._flush_text_batch(key))
|
||
adapter._pending_text_batch_tasks[key] = t1
|
||
|
||
# Simulate T2 superseding T1 before T1 wakes from sleep.
|
||
t2 = asyncio.create_task(asyncio.sleep(0.2))
|
||
adapter._pending_text_batch_tasks[key] = t2
|
||
|
||
# Yield long enough for T1's sleep(0) to complete and T1 to run.
|
||
await asyncio.sleep(0.05)
|
||
|
||
t2.cancel()
|
||
try:
|
||
await t2
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
# T1 must have returned without processing or removing the event.
|
||
assert handle_calls == [], "superseded task must not call handle_message"
|
||
assert adapter._pending_text_batches.get(key) is event, (
|
||
"superseded task must not pop the event"
|
||
)
|
||
|