690b903f85
* chore: add pytest-asyncio in auto mode * test: migrate channel and stream tests to native async Convert run_async() wrapper tests to plain 'async def test_*' under pytest-asyncio auto mode. collect_events() in stream_v3_fakes becomes a coroutine awaited at every call site. * test: migrate command and model/middleware tests to native async Convert run_async() wrappers (import, alias, and fixture forms) to plain 'async def test_*'. Multi-call tests merge onto one loop as sequential awaits; none asserted on loop identity. * test: migrate TUI, notifier, gateway, and session tests to native async TUI/notifier/gateway files convert run_async wrappers to plain async tests. test_sessions.py's unittest.TestCase classes move to unittest.IsolatedAsyncioTestCase (pytest-asyncio does not await async methods on plain TestCase; converting blindly would have made ~70 tests silently vacuous). Its setUpClass keeps a one-shot asyncio.run() since IsolatedAsyncioTestCase has no async class-level hook. TestLoadingWidget in test_tui_widgets.py drops its TestCase base for the same reason. * test: replace direct asyncio.run() calls with native async tests Convert tests that called asyncio.run() (directly or via a local _run helper) to plain 'async def test_*'; delete the local helpers. * test: drop undeclared anyio markers and delete run_async helper The @pytest.mark.anyio tests relied on anyio being a transitive dep of httpx; auto-mode pytest-asyncio collects them natively. run_async() and its fixture are unreferenced after the migration, so remove them — pytest-asyncio's per-test loop teardown covers the pending-task cancellation the helper existed for (verified: full suite runs with no 'Event loop is closed' errors or destroyed-task warnings).
473 lines
18 KiB
Python
473 lines
18 KiB
Python
"""Tests for QQ channel markdown send behavior."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from EvoScientist.channels.base import OutboundMessage
|
|
from EvoScientist.channels.qq.channel import (
|
|
QQChannel,
|
|
QQConfig,
|
|
_build_qq_keyboard,
|
|
)
|
|
|
|
|
|
class TestQQChannelSend:
|
|
@staticmethod
|
|
def _make_ready_channel() -> QQChannel:
|
|
channel = QQChannel(QQConfig(app_id="id", app_secret="secret"))
|
|
channel._running = True
|
|
channel._client = MagicMock()
|
|
channel._client.api = MagicMock()
|
|
channel._client.api.post_c2c_message = AsyncMock()
|
|
channel._client.api.post_group_message = AsyncMock()
|
|
return channel
|
|
|
|
async def test_send_prefers_native_markdown_for_c2c(self):
|
|
channel = self._make_ready_channel()
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="openid",
|
|
content="## Title\n\n- item",
|
|
metadata={
|
|
"chat_id": "openid",
|
|
"event_id": "evt_1",
|
|
"msg_type": "c2c",
|
|
},
|
|
)
|
|
|
|
assert await channel.send(msg) is True
|
|
|
|
channel._client.api.post_c2c_message.assert_awaited_once()
|
|
sent = channel._client.api.post_c2c_message.await_args.kwargs
|
|
assert sent["openid"] == "openid"
|
|
assert sent["msg_type"] == 2
|
|
assert sent["markdown"] == {"content": "## Title\n\n- item"}
|
|
assert sent["msg_id"] == "evt_1"
|
|
assert sent["msg_seq"] == 1
|
|
assert "content" not in sent
|
|
|
|
async def test_send_falls_back_to_plain_text_when_markdown_send_fails(self):
|
|
channel = self._make_ready_channel()
|
|
channel._trace_event = MagicMock(side_effect=RuntimeError("trace failed"))
|
|
channel._client.api.post_c2c_message = AsyncMock(
|
|
side_effect=[TypeError("unexpected keyword argument 'markdown'"), None]
|
|
)
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="openid",
|
|
content="## Title\n\n- item",
|
|
metadata={
|
|
"chat_id": "openid",
|
|
"event_id": "evt_2",
|
|
"msg_type": "c2c",
|
|
},
|
|
)
|
|
|
|
assert await channel.send(msg) is True
|
|
|
|
assert channel._client.api.post_c2c_message.await_count == 2
|
|
first = channel._client.api.post_c2c_message.await_args_list[0].kwargs
|
|
second = channel._client.api.post_c2c_message.await_args_list[1].kwargs
|
|
|
|
assert first["msg_type"] == 2
|
|
assert first["markdown"] == {"content": "## Title\n\n- item"}
|
|
|
|
assert second["msg_type"] == 0
|
|
assert second["content"] == "Title\n\n• item"
|
|
assert second["msg_id"] == "evt_2"
|
|
# Plain fallback consumes a fresh msg_seq — QQ may have already
|
|
# counted the failed markdown attempt, so re-using seq would
|
|
# trigger "duplicate msg_seq".
|
|
assert second["msg_seq"] == 2
|
|
|
|
async def test_send_does_not_fallback_on_transport_error(self):
|
|
channel = self._make_ready_channel()
|
|
|
|
async def _send_once(coro_factory, max_retries=3):
|
|
return await coro_factory()
|
|
|
|
channel._send_with_retry = _send_once
|
|
channel._client.api.post_c2c_message = AsyncMock(
|
|
side_effect=RuntimeError("upstream service unavailable")
|
|
)
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="openid",
|
|
content="## Title\n\n- item",
|
|
metadata={
|
|
"chat_id": "openid",
|
|
"event_id": "evt_3",
|
|
"msg_type": "c2c",
|
|
},
|
|
)
|
|
|
|
assert await channel.send(msg) is False
|
|
channel._client.api.post_c2c_message.assert_awaited_once()
|
|
sent = channel._client.api.post_c2c_message.await_args.kwargs
|
|
assert sent["msg_type"] == 2
|
|
assert "content" not in sent
|
|
|
|
async def test_send_does_not_fallback_when_transport_error_mentions_markdown(self):
|
|
"""A transport-layer error whose message incidentally contains the word
|
|
"markdown" must NOT be reclassified as a markdown compatibility failure,
|
|
otherwise genuine send failures get silently swallowed as plain-text."""
|
|
channel = self._make_ready_channel()
|
|
|
|
async def _send_once(coro_factory, max_retries=3):
|
|
return await coro_factory()
|
|
|
|
channel._send_with_retry = _send_once
|
|
channel._client.api.post_c2c_message = AsyncMock(
|
|
side_effect=ConnectionError(
|
|
"failed to post markdown message: ConnectionReset"
|
|
)
|
|
)
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="openid",
|
|
content="## Title",
|
|
metadata={
|
|
"chat_id": "openid",
|
|
"event_id": "evt_transport",
|
|
"msg_type": "c2c",
|
|
},
|
|
)
|
|
|
|
assert await channel.send(msg) is False
|
|
channel._client.api.post_c2c_message.assert_awaited_once()
|
|
|
|
async def test_send_falls_back_on_qq_server_error_code(self):
|
|
"""QQ server-side markdown errors (e.g. 304014 template not configured)
|
|
should trigger plain-text fallback with a fresh msg_seq."""
|
|
channel = self._make_ready_channel()
|
|
channel._client.api.post_c2c_message = AsyncMock(
|
|
side_effect=[
|
|
RuntimeError(
|
|
'{"code": 304014, "message": "markdown template not configured"}'
|
|
),
|
|
None,
|
|
]
|
|
)
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="openid",
|
|
content="## Title",
|
|
metadata={
|
|
"chat_id": "openid",
|
|
"event_id": "evt_4",
|
|
"msg_type": "c2c",
|
|
},
|
|
)
|
|
|
|
assert await channel.send(msg) is True
|
|
|
|
assert channel._client.api.post_c2c_message.await_count == 2
|
|
first = channel._client.api.post_c2c_message.await_args_list[0].kwargs
|
|
second = channel._client.api.post_c2c_message.await_args_list[1].kwargs
|
|
|
|
assert first["msg_type"] == 2
|
|
assert first["msg_seq"] == 1
|
|
assert second["msg_type"] == 0
|
|
# Fresh seq on fallback — avoids QQ "duplicate msg_seq" rejection.
|
|
assert second["msg_seq"] == 2
|
|
|
|
|
|
class TestQQKeyboardBuilder:
|
|
def test_basic_buttons(self):
|
|
kb = _build_qq_keyboard(
|
|
[
|
|
{"text": "Approve", "value": "1", "type": "primary"},
|
|
{"text": "Reject", "value": "2", "type": "danger"},
|
|
]
|
|
)
|
|
rows = kb["content"]["rows"]
|
|
assert len(rows) == 2 # one button per row
|
|
approve_btn = rows[0]["buttons"][0]
|
|
assert approve_btn["render_data"]["label"] == "Approve"
|
|
assert approve_btn["render_data"]["style"] == 1 # primary
|
|
assert approve_btn["action"]["type"] == 1 # callback
|
|
assert approve_btn["action"]["data"] == "1"
|
|
# "danger" maps to grey (style 0) — QQ has no danger style
|
|
assert rows[1]["buttons"][0]["render_data"]["style"] == 0
|
|
|
|
def test_default_value_uses_label(self):
|
|
kb = _build_qq_keyboard([{"text": "OK"}])
|
|
assert kb["content"]["rows"][0]["buttons"][0]["action"]["data"] == "OK"
|
|
|
|
def test_skips_empty_label(self):
|
|
kb = _build_qq_keyboard(
|
|
[
|
|
{"text": "", "value": "skip"},
|
|
{"text": "Keep", "value": "k"},
|
|
]
|
|
)
|
|
rows = kb["content"]["rows"]
|
|
assert len(rows) == 1
|
|
assert rows[0]["buttons"][0]["render_data"]["label"] == "Keep"
|
|
|
|
def test_returns_none_when_no_valid_buttons(self):
|
|
assert _build_qq_keyboard([]) is None
|
|
assert _build_qq_keyboard([{"text": ""}]) is None
|
|
|
|
def test_explicit_id_preserved(self):
|
|
kb = _build_qq_keyboard([{"text": "Go", "value": "go", "id": "custom_id"}])
|
|
assert kb["content"]["rows"][0]["buttons"][0]["id"] == "custom_id"
|
|
|
|
def test_non_string_value_coerced(self):
|
|
kb = _build_qq_keyboard([{"text": "OK", "value": 42}])
|
|
assert kb["content"]["rows"][0]["buttons"][0]["action"]["data"] == "42"
|
|
|
|
|
|
class TestQQSendWithButtons:
|
|
"""Send path attaches `keyboard` to markdown payload for C2C messages."""
|
|
|
|
@staticmethod
|
|
def _make_channel() -> QQChannel:
|
|
channel = QQChannel(QQConfig(app_id="id", app_secret="secret"))
|
|
channel._running = True
|
|
channel._client = MagicMock()
|
|
channel._client.api = MagicMock()
|
|
channel._client.api.post_c2c_message = AsyncMock()
|
|
channel._client.api.post_group_message = AsyncMock()
|
|
return channel
|
|
|
|
async def test_c2c_send_attaches_keyboard(self):
|
|
channel = self._make_channel()
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="openid",
|
|
content="Pick:",
|
|
metadata={
|
|
"chat_id": "openid",
|
|
"event_id": "evt_btn",
|
|
"msg_type": "c2c",
|
|
"buttons": [
|
|
{"text": "Approve", "value": "1", "type": "primary"},
|
|
{"text": "Reject", "value": "2"},
|
|
],
|
|
},
|
|
)
|
|
assert await channel.send(msg) is True
|
|
|
|
sent = channel._client.api.post_c2c_message.await_args.kwargs
|
|
assert sent["msg_type"] == 2
|
|
assert "keyboard" in sent
|
|
rows = sent["keyboard"]["content"]["rows"]
|
|
assert rows[0]["buttons"][0]["action"]["data"] == "1"
|
|
assert rows[1]["buttons"][0]["action"]["data"] == "2"
|
|
|
|
async def test_group_send_does_not_attach_keyboard(self):
|
|
"""Group keyboards are out of scope — silently dropped."""
|
|
channel = self._make_channel()
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="group_openid",
|
|
content="Pick:",
|
|
metadata={
|
|
"chat_id": "group_openid",
|
|
"event_id": "evt_group",
|
|
"msg_type": "group",
|
|
"buttons": [{"text": "Approve", "value": "1"}],
|
|
},
|
|
)
|
|
assert await channel.send(msg) is True
|
|
sent = channel._client.api.post_group_message.await_args.kwargs
|
|
assert "keyboard" not in sent
|
|
|
|
async def test_fallback_appends_button_hint_when_keyboard_present(self):
|
|
"""If markdown send fails and we fall back to plain text, the
|
|
keyboard is lost — append a textual hint so the user still has
|
|
a way to reply (the values still pass `_parse_approval_reply`).
|
|
"""
|
|
channel = self._make_channel()
|
|
channel._client.api.post_c2c_message = AsyncMock(
|
|
side_effect=[
|
|
RuntimeError(
|
|
'{"code": 304014, "message": "markdown template not configured"}'
|
|
),
|
|
None,
|
|
]
|
|
)
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="openid",
|
|
content="Pick:",
|
|
metadata={
|
|
"chat_id": "openid",
|
|
"event_id": "evt_fb",
|
|
"msg_type": "c2c",
|
|
"buttons": [
|
|
{"text": "Approve", "value": "1"},
|
|
{"text": "Reject", "value": "2"},
|
|
],
|
|
},
|
|
)
|
|
assert await channel.send(msg) is True
|
|
|
|
plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs
|
|
assert plain_call["msg_type"] == 0
|
|
# Fallback content includes hint mapping value→label so the user
|
|
# can type "1"/"2" without the original button UI.
|
|
assert "1=Approve" in plain_call["content"]
|
|
assert "2=Reject" in plain_call["content"]
|
|
|
|
async def test_fallback_hint_handles_non_string_button_value(self):
|
|
"""Regression: integer/None button values must not crash the
|
|
plain-text fallback (the keyboard builder already coerces them)."""
|
|
channel = self._make_channel()
|
|
channel._client.api.post_c2c_message = AsyncMock(
|
|
side_effect=[
|
|
RuntimeError('{"code": 304014, "message": "template not configured"}'),
|
|
None,
|
|
]
|
|
)
|
|
msg = OutboundMessage(
|
|
channel="qq",
|
|
chat_id="openid",
|
|
content="Pick:",
|
|
metadata={
|
|
"chat_id": "openid",
|
|
"event_id": "evt_coerce",
|
|
"msg_type": "c2c",
|
|
"buttons": [
|
|
{"text": "OK", "value": 42}, # int
|
|
{"text": "Cancel"}, # value omitted → defaults to label
|
|
],
|
|
},
|
|
)
|
|
assert await channel.send(msg) is True
|
|
plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs
|
|
assert "42=OK" in plain_call["content"]
|
|
assert "Cancel=Cancel" in plain_call["content"]
|
|
|
|
|
|
class TestQQInteractionCallback:
|
|
"""`_on_interaction` should publish click as InboundMessage to the bus
|
|
(skipping debounce) and ACK the interaction."""
|
|
|
|
@staticmethod
|
|
def _make_channel() -> QQChannel:
|
|
from EvoScientist.channels.bus.events import InboundMessage
|
|
|
|
channel = QQChannel(QQConfig(app_id="id", app_secret="secret"))
|
|
channel._running = True
|
|
channel._client = MagicMock()
|
|
channel._client.api = MagicMock()
|
|
channel._client.api.on_interaction_result = AsyncMock()
|
|
|
|
async def _fake_build(raw):
|
|
channel._captured_raw = raw
|
|
return InboundMessage(
|
|
channel="qq",
|
|
sender_id=raw.sender_id,
|
|
chat_id=raw.chat_id,
|
|
content=raw.text,
|
|
metadata=raw.metadata,
|
|
message_id=raw.message_id,
|
|
is_group=raw.is_group,
|
|
was_mentioned=raw.was_mentioned,
|
|
)
|
|
|
|
channel._build_inbound_async = _fake_build
|
|
channel._captured_raw = None
|
|
channel._bus = MagicMock()
|
|
channel._bus.publish_inbound = AsyncMock()
|
|
return channel
|
|
|
|
@staticmethod
|
|
def _make_interaction(button_data="1", button_id="btn_0", user_openid="u_xxx"):
|
|
resolved = MagicMock(
|
|
button_id=button_id,
|
|
button_data=button_data,
|
|
message_id="msg_orig",
|
|
user_id=None,
|
|
feature_id=None,
|
|
)
|
|
data = MagicMock(type=None, resolved=resolved)
|
|
interaction = MagicMock(
|
|
id="intr_1",
|
|
user_openid=user_openid,
|
|
group_openid=None,
|
|
data=data,
|
|
)
|
|
return interaction
|
|
|
|
async def test_click_publishes_to_bus_with_button_data(self):
|
|
channel = self._make_channel()
|
|
await channel._on_interaction(self._make_interaction("1"))
|
|
|
|
channel._bus.publish_inbound.assert_awaited_once()
|
|
inbound = channel._bus.publish_inbound.await_args[0][0]
|
|
assert inbound.content == "1"
|
|
assert inbound.sender_id == "u_xxx"
|
|
assert inbound.chat_id == "u_xxx"
|
|
assert inbound.metadata["button_click"] is True
|
|
assert inbound.metadata["button_value"] == "1"
|
|
assert inbound.metadata["msg_type"] == "c2c"
|
|
|
|
async def test_click_acks_interaction(self):
|
|
channel = self._make_channel()
|
|
await channel._on_interaction(self._make_interaction("1"))
|
|
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0)
|
|
|
|
async def test_click_bypasses_debounce(self):
|
|
"""Click never hits queue_message (debounce buffer)."""
|
|
channel = self._make_channel()
|
|
channel.queue_message = AsyncMock()
|
|
await channel._on_interaction(self._make_interaction("3"))
|
|
channel.queue_message.assert_not_called()
|
|
channel._bus.publish_inbound.assert_awaited_once()
|
|
|
|
async def test_group_interaction_ignored(self):
|
|
"""No user_openid → group/guild click → don't publish."""
|
|
channel = self._make_channel()
|
|
intr = self._make_interaction(user_openid="")
|
|
intr.group_openid = "group_xxx"
|
|
await channel._on_interaction(intr)
|
|
channel._bus.publish_inbound.assert_not_called()
|
|
# ACK still fires — it runs first, before the group-skip return.
|
|
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0)
|
|
|
|
async def test_click_dropped_when_middleware_rejects(self):
|
|
channel = self._make_channel()
|
|
channel._build_inbound_async = AsyncMock(return_value=None)
|
|
await channel._on_interaction(self._make_interaction("1"))
|
|
channel._bus.publish_inbound.assert_not_called()
|
|
# ACK still fires (we don't want the user staring at a stuck button)
|
|
channel._client.api.on_interaction_result.assert_awaited_once()
|
|
|
|
async def test_empty_button_data_falls_back_to_button_id(self):
|
|
channel = self._make_channel()
|
|
await channel._on_interaction(
|
|
self._make_interaction(button_data="", button_id="btn_3")
|
|
)
|
|
inbound = channel._bus.publish_inbound.await_args[0][0]
|
|
assert inbound.content == "btn_3"
|
|
|
|
async def test_ack_fires_even_when_handler_throws(self):
|
|
"""ACK must run before downstream processing so the QQ button UI
|
|
stays responsive even if middleware/bus crashes."""
|
|
channel = self._make_channel()
|
|
channel._build_inbound_async = AsyncMock(side_effect=RuntimeError("boom"))
|
|
# Should not raise — handler swallows downstream errors.
|
|
await channel._on_interaction(self._make_interaction("1"))
|
|
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0)
|
|
|
|
async def test_button_value_metadata_is_string_coerced(self):
|
|
"""Regression: metadata['button_value'] must be a string (was raw)."""
|
|
channel = self._make_channel()
|
|
resolved = MagicMock(button_id="btn_0", button_data=42, message_id="msg_orig")
|
|
data = MagicMock(type=None, resolved=resolved)
|
|
intr = MagicMock(id="intr_1", user_openid="u_x", group_openid=None, data=data)
|
|
await channel._on_interaction(intr)
|
|
inbound = channel._bus.publish_inbound.await_args[0][0]
|
|
assert inbound.content == "42"
|
|
assert inbound.metadata["button_value"] == "42"
|
|
|
|
|
|
class TestQQCapabilitiesButtons:
|
|
def test_inline_buttons_enabled(self):
|
|
from EvoScientist.channels.capabilities import QQ
|
|
|
|
assert QQ.inline_buttons is True
|
|
assert QQ.supports("inline_buttons") is True
|