hermes-agent/tests/gateway/test_wecom.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
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).
2026-07-29 13:10:23 -07:00

485 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""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"
)