Files
dy/backend/tests/test_conversation_poll_bandwidth.py
T
2026-08-27 18:32:03 +08:00

444 lines
16 KiB
Python

from __future__ import annotations
import asyncio
import os
import sys
import unittest
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
BACKEND_DIR = Path(__file__).resolve().parents[1]
os.environ["KEFU_DB_TYPE"] = "sqlite"
os.environ["KEFU_DATABASE_URL"] = ""
os.environ["KEFU_DB_PATH"] = str(BACKEND_DIR / "kefu.db")
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
from rpa_engine.douyin_im.session import DouyinImSession
from rpa_engine.douyin_im.service import DouyinImService, _conversation_poll_timing
from rpa_engine.douyin_im import service as service_module
class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
def _make_client(self) -> DouyinImHttpClient:
return DouyinImHttpClient(
DouyinImSession(cookies={"sessionid": "test"}, my_uid=10001),
account_id=9,
)
async def test_inbox_is_fetched_with_exactly_one_protobuf_request(self):
"""imapi 只认 protobuf;轮询一次就只该发一个请求。"""
client = self._make_client()
client.fetch_inbox_messages = AsyncMock(return_value=[])
client._request = AsyncMock()
self.assertEqual(await client.get_conversations(), [])
client.fetch_inbox_messages.assert_awaited_once()
# 不能再退回 JSON 的 /v1/conversation/list:那个请求恒被抖音拒绝。
client._request.assert_not_awaited()
async def test_transport_failure_is_recorded_and_returns_empty(self):
client = self._make_client()
client.fetch_inbox_messages = AsyncMock(side_effect=RuntimeError("boom"))
with self.assertLogs("douyin_im.http", level="WARNING"):
self.assertEqual(await client.get_conversations(), [])
self.assertIn("boom", client.last_error)
async def test_inbox_messages_group_into_one_row_per_conversation(self):
client = self._make_client()
client.fetch_inbox_messages = AsyncMock(
return_value=[
{
"conversation_id": "0:1:10001:20001",
"server_message_id": "700",
"conversation_short_id": "555",
"message_type": 7,
"sender": "20001",
"content": '{"text":"旧"}',
},
{
"conversation_id": "0:1:10001:20001",
"server_message_id": "900",
"conversation_short_id": "555",
"message_type": 7,
"sender": "20001",
"content": '{"text":"新"}',
},
{
"conversation_id": "0:1:10001:20002",
"server_message_id": "800",
"message_type": 7,
"sender": "20002",
"content": '{"text":"另一个"}',
},
]
)
rows = await client.get_conversations(enrich_profiles=False)
by_id = {r["conversation_id"]: r for r in rows}
self.assertEqual(len(rows), 2)
# 同一会话只保留 server_message_id 最大的那条
self.assertEqual(by_id["0:1:10001:20001"]["server_message_id"], "900")
self.assertIn("新", by_id["0:1:10001:20001"]["content"])
# peer_uid 由 conversation_id 推导,不能直接取 sender(可能是自己)
self.assertEqual(by_id["0:1:10001:20002"]["peer_uid"], "20002")
# 顺手缓存 short_id,发送时就不必再 create 一次会话
self.assertEqual(
client.session.conv_meta["0:1:10001:20001"]["conversation_short_id"],
"555",
)
def test_websocket_reconciliation_is_slow_and_http_fallback_stays_fast(self):
with patch.dict(
os.environ,
{
"KEFU_WS_RECONCILE_INTERVAL_SECONDS": "120",
"KEFU_HTTP_POLL_INTERVAL_SECONDS": "15",
},
):
ws_interval, ws_stagger = _conversation_poll_timing(123, True)
http_interval, http_stagger = _conversation_poll_timing(123, False)
self.assertEqual(ws_interval, 120)
self.assertEqual(http_interval, 15)
self.assertGreaterEqual(ws_stagger, 0)
self.assertLess(ws_stagger, ws_interval)
self.assertGreaterEqual(http_stagger, 0)
self.assertLess(http_stagger, http_interval)
def test_poll_interval_expands_to_the_configured_population_budget(self):
with patch.dict(
os.environ,
{
"KEFU_WS_RECONCILE_INTERVAL_SECONDS": "120",
"KEFU_HTTP_POLL_INTERVAL_SECONDS": "15",
"KEFU_WS_POLL_BUDGET_RPS": "1",
"KEFU_HTTP_POLL_BUDGET_RPS": "1",
},
):
ws_interval, _ = _conversation_poll_timing(
123,
True,
population=500,
)
http_interval, _ = _conversation_poll_timing(
123,
False,
population=500,
)
self.assertEqual(ws_interval, 500)
self.assertEqual(http_interval, 500)
def test_initial_unread_concurrency_is_configurable_and_bounded(self):
with patch.dict(
os.environ,
{"KEFU_INITIAL_UNREAD_CONCURRENCY": "4"},
):
self.assertEqual(service_module._initial_unread_concurrency(), 4)
with patch.dict(
os.environ,
{"KEFU_INITIAL_UNREAD_CONCURRENCY": "999"},
):
self.assertEqual(service_module._initial_unread_concurrency(), 8)
async def test_service_poll_uses_one_conversation_request_without_unread_probe(self):
class _Controller:
@asynccontextmanager
async def background_slot(self, *_args, **_kwargs):
yield
class _HttpClient:
def __init__(self):
self.get_conversations = AsyncMock(return_value=[])
self.conversation_list_unsupported = False
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
http = _HttpClient()
service = DouyinImService(
session=DouyinImSession(cookies={"sessionid": "test"}, my_uid=10001),
match_reply=AsyncMock(return_value=None),
log_fn=AsyncMock(),
account_id=9,
)
service._index_conversations = AsyncMock()
with (
patch.object(service_module, "get_traffic_controller", return_value=_Controller()),
patch.object(service_module, "DouyinImHttpClient", return_value=http),
):
await service._poll_conversations()
http.get_conversations.assert_awaited_once_with(enrich_profiles=False)
service._index_conversations.assert_awaited_once_with(
[],
enrich_profiles=False,
)
async def test_poll_only_handles_unread_or_a_genuinely_changed_preview(self):
class _Controller:
def __init__(self):
self.startup_flags = []
@asynccontextmanager
async def background_slot(self, *_args, **kwargs):
self.startup_flags.append(bool(kwargs.get("startup")))
yield
snapshots = [
[
{
"conversation_id": "0:1:10001:20001",
"peer_uid": "20001",
"sender_name": "历史会话",
"sender_avatar": "https://example.test/a.png",
"content": "历史消息",
"unread_count": 0,
},
{
"conversation_id": "0:1:10001:20002",
"peer_uid": "20002",
"sender_name": "未读会话",
"sender_avatar": "https://example.test/b.png",
"content": "新消息",
"unread_count": 1,
},
],
[
{
"conversation_id": "0:1:10001:20001",
"peer_uid": "20001",
"sender_name": "历史会话",
"sender_avatar": "https://example.test/a.png",
"content": "历史消息",
"unread_count": 0,
},
{
"conversation_id": "0:1:10001:20002",
"peer_uid": "20002",
"sender_name": "未读会话",
"sender_avatar": "https://example.test/b.png",
"content": "新消息",
"unread_count": 0,
},
],
[
{
"conversation_id": "0:1:10001:20001",
"peer_uid": "20001",
"sender_name": "历史会话",
"sender_avatar": "https://example.test/a.png",
"content": "真正发生变化",
"unread_count": 0,
},
{
"conversation_id": "0:1:10001:20002",
"peer_uid": "20002",
"sender_name": "未读会话",
"sender_avatar": "https://example.test/b.png",
"content": "新消息",
"unread_count": 0,
},
],
]
class _HttpClient:
def __init__(self):
self.get_conversations = AsyncMock(side_effect=snapshots)
self.conversation_list_unsupported = False
self.enter_count = 0
self.exit_count = 0
async def __aenter__(self):
self.enter_count += 1
return self
async def __aexit__(self, *_args):
self.exit_count += 1
return False
http = _HttpClient()
service = DouyinImService(
session=DouyinImSession(cookies={"sessionid": "test"}, my_uid=10001),
match_reply=AsyncMock(return_value=None),
log_fn=AsyncMock(),
account_id=9,
)
service._handle_incoming = AsyncMock()
controller = _Controller()
with (
patch.object(
service_module,
"get_traffic_controller",
return_value=controller,
),
patch.object(service_module, "DouyinImHttpClient", return_value=http) as factory,
):
# Startup indexes both previews, but only the unread conversation
# is allowed to enter the reply path.
await service._poll_conversations(initial=True)
self.assertEqual(service._handle_incoming.await_count, 1)
self.assertEqual(
service._handle_incoming.await_args.args[0]["peer_uid"],
"20002",
)
# The first normal reconciliation sees the exact same previews;
# it must not merely defer a historical-message reply explosion.
service._handle_incoming.reset_mock()
await service._poll_conversations()
service._handle_incoming.assert_not_awaited()
# A real preview transition is processed even if unread_count is
# unavailable/zero on the upstream response.
await service._poll_conversations()
service._handle_incoming.assert_awaited_once()
self.assertEqual(
service._handle_incoming.await_args.args[0]["peer_uid"],
"20001",
)
self.assertEqual(factory.call_count, 3)
self.assertEqual(http.enter_count, 3)
self.assertEqual(http.exit_count, 3)
self.assertEqual(controller.startup_flags, [True, False, False])
async def test_ready_is_not_blocked_by_slow_initial_unread_handler(self):
events: list[str] = []
ready = asyncio.Event()
handler_started = asyncio.Event()
handler_cancelled = asyncio.Event()
never_release = asyncio.Event()
def on_ready():
events.append("ready")
ready.set()
async def slow_handler(_message):
events.append("handler")
handler_started.set()
try:
await never_release.wait()
except asyncio.CancelledError:
handler_cancelled.set()
raise
class _WsClient:
connected = False
def __init__(self, *_args, **_kwargs):
self.start = AsyncMock()
self.stop = AsyncMock()
service = DouyinImService(
session=DouyinImSession(cookies={"sessionid": "test"}, my_uid=10001),
match_reply=AsyncMock(return_value=None),
log_fn=AsyncMock(),
account_id=901,
on_ready=on_ready,
)
service._verify_account_uid = AsyncMock()
service._poll_conversations = AsyncMock(
return_value=[
{
"conversation_id": "0:1:10001:29001",
"content": "startup unread",
"unread_count": 1,
}
]
)
service._handle_incoming = AsyncMock(side_effect=slow_handler)
service._reply_queue.start = AsyncMock()
service._reply_queue.stop = AsyncMock()
with (
patch.object(service_module, "DouyinImWsClient", _WsClient),
patch.object(service_module, "ensure_frontier_ws"),
patch.object(service_module.system_logger, "record"),
patch(
"rpa_engine.douyin_im.emoji_pack.is_fresh",
return_value=True,
),
):
run_task = asyncio.create_task(service.run())
try:
# If initial unread were still processed inline, this wait
# would time out because slow_handler never completes.
await asyncio.wait_for(ready.wait(), timeout=0.3)
await asyncio.wait_for(handler_started.wait(), timeout=0.3)
self.assertEqual(events[:2], ["ready", "handler"])
service._poll_conversations.assert_awaited_once_with(
initial=True,
defer_handlers=True,
)
# Account stop cancels its active deferred handler instead of
# leaving work detached from the service lifecycle.
await asyncio.wait_for(service.stop(), timeout=0.5)
self.assertTrue(handler_cancelled.is_set())
finally:
run_task.cancel()
await asyncio.gather(run_task, return_exceptions=True)
await service_module._shutdown_initial_unread_dispatcher()
async def test_initial_unread_dispatcher_has_process_wide_concurrency_limit(self):
dispatcher = service_module._InitialUnreadDispatcher(concurrency=2)
active = 0
maximum_active = 0
processed: list[int] = []
two_started = asyncio.Event()
release = asyncio.Event()
def make_service(account_id: int):
async def handle(_message):
nonlocal active, maximum_active
active += 1
maximum_active = max(maximum_active, active)
if active == 2:
two_started.set()
try:
await release.wait()
processed.append(account_id)
finally:
active -= 1
return SimpleNamespace(
account_id=account_id,
_running=True,
_handle_incoming=handle,
)
services = [make_service(index + 1) for index in range(6)]
try:
for service in services:
await dispatcher.submit(service, [{"unread_count": 1}])
await asyncio.wait_for(two_started.wait(), timeout=0.3)
await asyncio.sleep(0.03)
self.assertEqual(maximum_active, 2)
self.assertEqual(processed, [])
release.set()
await asyncio.wait_for(dispatcher.join(), timeout=0.5)
self.assertEqual(maximum_active, 2)
self.assertCountEqual(processed, range(1, 7))
finally:
await dispatcher.stop()
if __name__ == "__main__":
unittest.main()