deer-flow/backend/tests/test_qq_notifications.py
Totoro cb90f5dca4
feat(channels): control IM notifications and add WeChat/QQ delivery (#6850)
* feat(channels): control IM notifications and add WeChat/QQ delivery

* fix(scheduler): skip notification preference locks without recipients

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 11:14:48 +08:00

261 lines
10 KiB
Python

"""QQ proactive C2C notifications: serialized HTTP, no real provider calls."""
from __future__ import annotations
import asyncio
import inspect
import json
import time
import traceback
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
from app.channels.base import ChannelUnavailable
from app.channels.message_bus import MessageBus
from app.channels.qq import QQAPIError, QQChannel, _ReplyContext
pytestmark = pytest.mark.asyncio
def binding(**overrides):
return {
"owner_user_id": "owner",
"provider": "qq",
"status": "connected",
"external_account_id": "user-openid",
"workspace_id": "app1:c2c",
"metadata": {"chat_type": "c2c"},
**overrides,
}
@asynccontextmanager
async def connected(handler=None, *, bindings=None):
requests = []
async def respond(request):
requests.append(request)
if handler is not None:
response = handler(request)
if inspect.isawaitable(response):
response = await response
return response
if request.url.host == "bots.qq.com":
return httpx.Response(200, json={"access_token": "fixture-token", "expires_in": 7200})
return httpx.Response(200, json={"id": "notification-id"})
repo = SimpleNamespace(list_connections=AsyncMock(return_value=[binding()] if bindings is None else bindings))
channel = QQChannel(MessageBus(), {"app_id": "app1", "client_secret": "fixture-secret", "connection_repo": repo})
channel._http = httpx.AsyncClient(transport=httpx.MockTransport(respond), timeout=15, trust_env=False)
channel._running = True
channel._ready.set()
channel._listener = asyncio.create_task(asyncio.Event().wait())
try:
yield channel, requests
finally:
await channel.stop()
async def notify(channel, *, owner="owner", target="user-openid", text="任务已完成。\nOpen DeerFlow for details."):
await channel.send_notification_for_owner(owner_user_id=owner, target=target, text_markdown=text)
async def test_private_notification_serializes_without_passive_message_context():
async with connected() as (channel, requests):
context = _ReplyContext("/unused/passive/path", time.monotonic() - 1, 4, sequence=4)
channel._replies[("c2c:user-openid", "old-message")] = context
await notify(channel)
channel._connection_repo.list_connections.assert_awaited_once_with("owner")
assert len(requests) == 2
token, message = requests
assert json.loads(token.content) == {"appId": "app1", "clientSecret": "fixture-secret"}
assert str(message.url) == "https://api.sgroup.qq.com/v2/users/user-openid/messages"
assert message.headers["Authorization"] == "QQBot fixture-token"
assert message.headers["X-Union-Appid"] == "app1"
assert message.extensions["timeout"] == {"connect": 15, "read": 15, "write": 15, "pool": 15}
assert json.loads(message.content) == {"msg_type": 0, "content": "任务已完成。\nOpen DeerFlow for details."}
assert context.sequence == 4
assert len(channel._replies) == 1
async def test_notification_waits_for_message_ack():
sending, release = asyncio.Event(), asyncio.Event()
async def handler(request):
sending.set()
await release.wait()
return httpx.Response(200, json={"id": "notification-id"})
async with connected(handler) as (channel, requests):
channel._access_token = "fixture-token"
channel._token_expires_at = time.monotonic() + 60
task = asyncio.create_task(notify(channel))
try:
await asyncio.wait_for(sending.wait(), 1)
assert not task.done()
release.set()
await task
assert len(requests) == 1
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.parametrize(
"override",
[
{"owner_user_id": "other-owner"},
{"provider": "feishu"},
{"status": "revoked"},
{"external_account_id": "other-user"},
{"workspace_id": "another-app:c2c"},
{"workspace_id": "app1:group:group-id", "metadata": {"chat_type": "group"}},
{"metadata": {"chat_type": "group"}},
{"metadata": {}},
],
)
async def test_notification_refuses_non_private_or_wrong_owner_binding(override):
async with connected(bindings=[binding(**override)]) as (channel, requests):
with pytest.raises(QQAPIError, match="private.*connected"):
await notify(channel)
assert requests == []
async def test_notification_rechecks_binding_on_every_attempt():
async with connected() as (channel, requests):
await notify(channel)
channel._connection_repo.list_connections.return_value = []
with pytest.raises(QQAPIError, match="private.*connected"):
await notify(channel)
assert len(requests) == 2
assert channel._connection_repo.list_connections.await_count == 2
@pytest.mark.parametrize("case", ["lookup-error", "missing-owner", "missing-repo"])
async def test_missing_binding_verification_parks_without_send(case, caplog):
async with connected() as (channel, requests):
owner = "owner"
if case == "lookup-error":
channel._connection_repo.list_connections.side_effect = OSError("sensitive-owner-detail")
elif case == "missing-owner":
owner = None
else:
channel._connection_repo = None
with pytest.raises(ChannelUnavailable) as caught:
await notify(channel, owner=owner)
assert requests == []
assert "sensitive-owner-detail" not in "".join(traceback.format_exception(caught.value)) + caplog.text
@pytest.mark.parametrize("condition", ["stopped", "not-ready", "listener-ended", "http-closed", "http-missing"])
async def test_notification_parks_without_live_transport(condition):
async with connected() as (channel, requests):
if condition == "stopped":
channel._running = False
elif condition == "not-ready":
channel._ready.clear()
elif condition == "listener-ended":
channel._listener.cancel()
await asyncio.gather(channel._listener, return_exceptions=True)
elif condition == "http-closed":
await channel._http.aclose()
else:
await channel._http.aclose()
channel._http = None
with pytest.raises(ChannelUnavailable):
await notify(channel)
assert requests == []
@pytest.mark.parametrize("stage", ["token", "message"])
@pytest.mark.parametrize("error_type", [httpx.ConnectError, httpx.ReadTimeout])
async def test_transport_error_parks_without_retry_or_sensitive_details(stage, error_type, caplog):
def handler(request):
raise error_type("sensitive-provider-detail", request=request)
async with connected(handler) as (channel, requests):
if stage == "message":
channel._access_token = "fixture-token"
channel._token_expires_at = time.monotonic() + 60
with pytest.raises(ChannelUnavailable) as caught:
await notify(channel)
assert len(requests) == 1
rendered = "".join(traceback.format_exception(caught.value)) + caplog.text
assert "sensitive-provider-detail" not in rendered
assert "fixture-secret" not in rendered
assert "fixture-token" not in rendered
@pytest.mark.parametrize(
("status", "payload"),
[
(403, {"err_code": 40054013, "message": "sensitive-rejection-body"}),
(429, {"err_code": 40034100, "message": "sensitive-rejection-body"}),
(200, {"code": 40034105, "id": "not-an-ack", "message": "sensitive-rejection-body"}),
(200, {"err_code": 40054013, "id": "not-an-ack", "message": "sensitive-rejection-body"}),
(200, {}),
(200, {"id": ""}),
(200, {"id": 12}),
(200, {"id": " "}),
],
)
async def test_rejection_or_missing_ack_counts_as_failure(status, payload, caplog):
async with connected(lambda request: httpx.Response(status, json=payload)) as (channel, requests):
channel._access_token = "fixture-token"
channel._token_expires_at = time.monotonic() + 60
with pytest.raises(QQAPIError) as caught:
await notify(channel)
assert not isinstance(caught.value, ChannelUnavailable)
assert len(requests) == 1
assert "sensitive-rejection-body" not in "".join(traceback.format_exception(caught.value)) + caplog.text
async def test_auth_refresh_keeps_proactive_payload_and_no_passive_budget():
token_requests, message_requests = [], []
def handler(request):
if request.url.host == "bots.qq.com":
token_requests.append(request)
return httpx.Response(200, json={"access_token": f"token-{len(token_requests)}", "expires_in": 7200})
message_requests.append(request)
return httpx.Response(401 if len(message_requests) == 1 else 200, json={"id": "notification-id"})
async with connected(handler) as (channel, requests):
await notify(channel)
assert len(token_requests) == 2
assert len(message_requests) == 2
assert message_requests[0].content == message_requests[1].content
assert message_requests[1].headers["Authorization"] == "QQBot token-2"
assert not channel._replies
async def test_group_notification_remains_unsupported():
async with connected() as (channel, requests):
with pytest.raises(NotImplementedError):
await channel.send_group_notification_for_owner(owner_user_id="owner", target="group-binding", text_markdown="Done")
assert requests == []
async def test_notification_cancellation_propagates_without_retry():
sending = asyncio.Event()
async def handler(request):
sending.set()
await asyncio.Event().wait()
async with connected(handler) as (channel, requests):
channel._access_token = "fixture-token"
channel._token_expires_at = time.monotonic() + 60
task = asyncio.create_task(notify(channel))
try:
await asyncio.wait_for(sending.wait(), 1)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert len(requests) == 1