更新
This commit is contained in:
@@ -5,7 +5,7 @@ import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -20,7 +20,7 @@ if str(BACKEND_DIR) not in sys.path:
|
||||
|
||||
import main
|
||||
from models.database import Base
|
||||
from models.models import Account
|
||||
from models.models import Account, MessageLog
|
||||
|
||||
|
||||
class _CountResult:
|
||||
@@ -43,6 +43,160 @@ class _RowsResult:
|
||||
|
||||
|
||||
class AccountPaginationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_legacy_cookie_sync_only_reads_accounts_missing_db_cookie(self):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
session_factory = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
try:
|
||||
async with session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Account(
|
||||
id=2301,
|
||||
username="already-in-db",
|
||||
cookie_data='{"cookies": [{"value": "large"}]}',
|
||||
im_session_data="x" * 100_000,
|
||||
),
|
||||
Account(id=2302, username="legacy-file", cookie_data=None),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
def read_cookie(account_id: int):
|
||||
self.assertEqual(account_id, 2302)
|
||||
return '{"cookies": [{"value": "migrated"}]}'
|
||||
|
||||
with (
|
||||
patch.object(main, "AsyncSessionLocal", session_factory),
|
||||
patch.object(main, "read_cookie_file", side_effect=read_cookie) as read_file,
|
||||
patch.object(main, "get_cookie_path", return_value="legacy-2302.json"),
|
||||
):
|
||||
await main._sync_legacy_cookie_files()
|
||||
|
||||
read_file.assert_called_once_with(2302)
|
||||
async with session_factory() as db:
|
||||
migrated = await db.get(Account, 2302)
|
||||
self.assertEqual(
|
||||
migrated.cookie_data,
|
||||
'{"cookies": [{"value": "migrated"}]}',
|
||||
)
|
||||
self.assertEqual(migrated.cookie_path, "legacy-2302.json")
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
async def test_account_edit_invalidates_running_follow_config_cache(self):
|
||||
account = SimpleNamespace(id=2201)
|
||||
db = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
|
||||
worker = SimpleNamespace(invalidate_follow_welcome_config=MagicMock())
|
||||
original_workers = main.manager.workers
|
||||
main.manager.workers = {2201: worker}
|
||||
try:
|
||||
with (
|
||||
patch.object(main, "get_owned_account", AsyncMock(return_value=account)),
|
||||
patch.object(main, "_build_account_response", return_value={"id": 2201}),
|
||||
):
|
||||
response = await main.update_account(
|
||||
account_id=2201,
|
||||
body=main.AccountUpdate(follow_welcome_enabled=True),
|
||||
db=db,
|
||||
user=SimpleNamespace(id=7, role="operator"),
|
||||
)
|
||||
|
||||
self.assertEqual(response, {"id": 2201})
|
||||
worker.invalidate_follow_welcome_config.assert_called_once_with()
|
||||
finally:
|
||||
main.manager.workers = original_workers
|
||||
|
||||
async def test_log_stats_uses_one_aggregate_and_respects_ownership(self):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
session_factory = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
try:
|
||||
async with session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Account(id=2101, owner_id=7, status="offline"),
|
||||
Account(id=2102, owner_id=8, status="offline"),
|
||||
MessageLog(account_id=2101, status="received"),
|
||||
MessageLog(account_id=2101, status="replied"),
|
||||
MessageLog(account_id=2102, status="replied"),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch.object(db, "execute", wraps=db.execute) as execute:
|
||||
stats = await main.get_logs_stats(
|
||||
account_id=None,
|
||||
db=db,
|
||||
user=SimpleNamespace(id=7, role="user"),
|
||||
)
|
||||
|
||||
self.assertEqual(stats, {"total": 2, "replied": 1})
|
||||
self.assertEqual(execute.await_count, 1)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
async def test_account_options_returns_lightweight_runtime_fields(self):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
session_factory = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
original_workers = main.manager.workers
|
||||
main.manager.workers = {2001: SimpleNamespace(is_running=True)}
|
||||
try:
|
||||
async with session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Account(
|
||||
id=2001,
|
||||
owner_id=7,
|
||||
username="owned",
|
||||
status="offline",
|
||||
cookie_data='{"cookies": []}',
|
||||
im_session_data="x" * 100_000,
|
||||
reply_cooldown_seconds=12,
|
||||
),
|
||||
Account(
|
||||
id=2002,
|
||||
owner_id=8,
|
||||
username="other",
|
||||
status="online",
|
||||
cookie_data='{"cookies": []}',
|
||||
),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
options = await main.get_account_options(
|
||||
db=db,
|
||||
user=SimpleNamespace(id=7, role="user"),
|
||||
)
|
||||
|
||||
self.assertEqual(len(options), 1)
|
||||
self.assertEqual(options[0].id, 2001)
|
||||
self.assertEqual(options[0].status, "online")
|
||||
self.assertTrue(options[0].has_cookie)
|
||||
self.assertEqual(options[0].reply_cooldown_seconds, 12)
|
||||
self.assertEqual(options[0].reply_cooldown_effective, 12)
|
||||
self.assertFalse(hasattr(options[0], "im_session_data"))
|
||||
finally:
|
||||
main.manager.workers = original_workers
|
||||
await engine.dispose()
|
||||
|
||||
async def test_paginated_list_counts_then_loads_only_current_page(self):
|
||||
page_rows = [
|
||||
SimpleNamespace(id=10, status="offline"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
@@ -32,6 +33,219 @@ def _fake_db(rows):
|
||||
|
||||
|
||||
class BatchStartApiTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_shutdown_continues_when_batch_queue_cleanup_times_out(self):
|
||||
original_workers = main.manager.workers
|
||||
original_flush_task = main._system_log_flush_task
|
||||
main.manager.workers = {}
|
||||
main._system_log_flush_task = None
|
||||
stop_started = asyncio.Event()
|
||||
|
||||
async def blocked_batch_stop():
|
||||
stop_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"KEFU_BATCH_STOP_TIMEOUT_SECONDS": "1"},
|
||||
),
|
||||
patch.object(
|
||||
main.batch_start_queue,
|
||||
"stop",
|
||||
AsyncMock(side_effect=blocked_batch_stop),
|
||||
),
|
||||
patch(
|
||||
"rpa_engine.douyin_im.traffic_control.shutdown_traffic_controller",
|
||||
AsyncMock(),
|
||||
) as stop_traffic,
|
||||
):
|
||||
await asyncio.wait_for(main.shutdown(), timeout=2.0)
|
||||
|
||||
self.assertTrue(stop_started.is_set())
|
||||
stop_traffic.assert_awaited_once_with()
|
||||
finally:
|
||||
main.manager.workers = original_workers
|
||||
main._system_log_flush_task = original_flush_task
|
||||
|
||||
async def test_shutdown_stops_many_accounts_with_bounded_parallelism(self):
|
||||
active = 0
|
||||
maximum_active = 0
|
||||
stopped: list[int] = []
|
||||
original_workers = main.manager.workers
|
||||
original_flush_task = main._system_log_flush_task
|
||||
main.manager.workers = {
|
||||
account_id: SimpleNamespace(is_running=True)
|
||||
for account_id in range(601, 613)
|
||||
}
|
||||
main._system_log_flush_task = None
|
||||
|
||||
async def stop_worker(account_id: int):
|
||||
nonlocal active, maximum_active
|
||||
active += 1
|
||||
maximum_active = max(maximum_active, active)
|
||||
try:
|
||||
await asyncio.sleep(0.005)
|
||||
stopped.append(account_id)
|
||||
main.manager.workers.pop(account_id, None)
|
||||
return True
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"KEFU_SHUTDOWN_CONCURRENCY": "3",
|
||||
"KEFU_SHUTDOWN_TIMEOUT_SECONDS": "5",
|
||||
},
|
||||
),
|
||||
patch.object(main.batch_start_queue, "stop", AsyncMock()),
|
||||
patch.object(main.manager, "stop_worker", AsyncMock(side_effect=stop_worker)),
|
||||
patch(
|
||||
"rpa_engine.douyin_im.traffic_control.shutdown_traffic_controller",
|
||||
AsyncMock(),
|
||||
),
|
||||
):
|
||||
await main.shutdown()
|
||||
|
||||
self.assertEqual(len(stopped), 12)
|
||||
self.assertEqual(maximum_active, 3)
|
||||
finally:
|
||||
main.manager.workers = original_workers
|
||||
main._system_log_flush_task = original_flush_task
|
||||
|
||||
async def test_worker_manager_waits_for_full_ready_and_reuses_validation(self):
|
||||
worker = SimpleNamespace(
|
||||
is_running=True,
|
||||
start=AsyncMock(),
|
||||
wait_until_ready=AsyncMock(),
|
||||
)
|
||||
manager = main.WorkerManager()
|
||||
|
||||
with patch.object(main, "DouyinWorker", return_value=worker) as worker_factory:
|
||||
started = await manager.start_worker(
|
||||
501,
|
||||
login_mode="im_direct",
|
||||
wait_until_ready=True,
|
||||
credential_prevalidated=True,
|
||||
)
|
||||
|
||||
self.assertTrue(started)
|
||||
worker_factory.assert_called_once_with(
|
||||
501,
|
||||
login_mode="im_direct",
|
||||
credential_prevalidated=True,
|
||||
)
|
||||
worker.start.assert_awaited_once_with()
|
||||
worker.wait_until_ready.assert_awaited_once_with()
|
||||
self.assertIs(manager.workers[501], worker)
|
||||
|
||||
async def test_cancelled_ready_wait_stops_and_removes_detached_worker(self):
|
||||
wait_started = asyncio.Event()
|
||||
waiting = asyncio.Event()
|
||||
|
||||
async def wait_forever():
|
||||
wait_started.set()
|
||||
await waiting.wait()
|
||||
|
||||
worker = SimpleNamespace(
|
||||
is_running=True,
|
||||
start=AsyncMock(),
|
||||
wait_until_ready=AsyncMock(side_effect=wait_forever),
|
||||
stop=AsyncMock(),
|
||||
)
|
||||
manager = main.WorkerManager()
|
||||
|
||||
with patch.object(main, "DouyinWorker", return_value=worker):
|
||||
task = asyncio.create_task(
|
||||
manager.start_worker(
|
||||
502,
|
||||
login_mode="im_direct",
|
||||
wait_until_ready=True,
|
||||
credential_prevalidated=True,
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(wait_started.wait(), timeout=0.2)
|
||||
task.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
worker.stop.assert_awaited_once_with()
|
||||
self.assertNotIn(502, manager.workers)
|
||||
|
||||
async def test_batch_start_waits_for_ready_and_skips_duplicate_validation(self):
|
||||
account = SimpleNamespace(
|
||||
id=503,
|
||||
status="offline",
|
||||
qr_code_base64=None,
|
||||
error_message=None,
|
||||
im_session_data="saved-session",
|
||||
)
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
assessment = {
|
||||
"login_mode": "im_direct",
|
||||
"should_reset": False,
|
||||
"can_skip_browser": True,
|
||||
"message": "ready",
|
||||
"cookie_valid": True,
|
||||
"im_ready": True,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(main.manager, "is_running", return_value=False),
|
||||
patch.object(main.manager, "start_worker", AsyncMock(return_value=True)) as start,
|
||||
patch.object(main, "_get_account_cookie_data", return_value="{}"),
|
||||
patch.object(main, "assess_account_credential", AsyncMock(return_value=assessment)),
|
||||
):
|
||||
result = await main._start_account_rpa_impl(
|
||||
account,
|
||||
db,
|
||||
wait_for_ready=True,
|
||||
)
|
||||
|
||||
start.assert_awaited_once_with(
|
||||
503,
|
||||
login_mode="im_direct",
|
||||
wait_until_ready=True,
|
||||
credential_prevalidated=True,
|
||||
)
|
||||
self.assertEqual(result["status"], "running")
|
||||
|
||||
async def test_batch_start_does_not_launch_interactive_browser_login(self):
|
||||
account = SimpleNamespace(
|
||||
id=504,
|
||||
status="offline",
|
||||
qr_code_base64=None,
|
||||
error_message=None,
|
||||
im_session_data=None,
|
||||
)
|
||||
db = SimpleNamespace(commit=AsyncMock())
|
||||
assessment = {
|
||||
"login_mode": "browser",
|
||||
"should_reset": False,
|
||||
"can_skip_browser": False,
|
||||
"message": "login required",
|
||||
"cookie_valid": False,
|
||||
"im_ready": False,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(main.manager, "is_running", return_value=False),
|
||||
patch.object(main.manager, "start_worker", AsyncMock()) as start,
|
||||
patch.object(main, "_get_account_cookie_data", return_value=None),
|
||||
patch.object(main, "assess_account_credential", AsyncMock(return_value=assessment)),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "批量启动"):
|
||||
await main._start_account_rpa_impl(
|
||||
account,
|
||||
db,
|
||||
wait_for_ready=True,
|
||||
)
|
||||
|
||||
start.assert_not_awaited()
|
||||
|
||||
async def test_start_all_uses_lightweight_select_and_submits_once(self):
|
||||
db = _fake_db([(1, False), (2, True), (3, False), (4, False)])
|
||||
user = SimpleNamespace(id=9, role="admin")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -109,6 +111,42 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
||||
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
|
||||
@@ -141,7 +179,260 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
||||
await service._poll_conversations()
|
||||
|
||||
http.get_conversations.assert_awaited_once_with(enrich_profiles=False)
|
||||
service._index_conversations.assert_awaited_once_with([])
|
||||
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.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__":
|
||||
|
||||
@@ -24,15 +24,9 @@ class CredentialResponsivenessTests(unittest.IsolatedAsyncioTestCase):
|
||||
is_sign_ready=lambda: True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"rpa_engine.credential.ensure_frontier_ws",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"rpa_engine.credential.DouyinAuth.from_im_session",
|
||||
return_value=auth,
|
||||
),
|
||||
with patch(
|
||||
"rpa_engine.credential.DouyinAuth.from_im_session",
|
||||
return_value=auth,
|
||||
):
|
||||
result = await validate_im_session(
|
||||
session,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from models.db_config import DatabaseConfig, create_database_engine, engine_kwargs_for_url
|
||||
from models.db_migrate import migrate_message_logs_table
|
||||
from models.models import MessageLog
|
||||
from rpa_engine.douyin_im import protocol
|
||||
from rpa_engine.douyin_im.static import Live_pb2, Response_pb2
|
||||
from utils import system_logger
|
||||
from utils.log_limits import (
|
||||
TRUNCATION_MARKER,
|
||||
bound_message_log_content,
|
||||
bound_raw_message_log_content,
|
||||
)
|
||||
|
||||
|
||||
class LogLimitTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
system_logger.clear()
|
||||
|
||||
def test_system_log_caps_persisted_and_console_detail(self):
|
||||
with patch.dict(os.environ, {"KEFU_SYSTEM_LOG_MAX_CHARS": "512"}):
|
||||
with self.assertLogs("douyin_im.system", level="INFO") as captured:
|
||||
entry = system_logger.record("event", "x" * 5000)
|
||||
|
||||
self.assertLessEqual(len(entry["detail"]), 512)
|
||||
self.assertIn(TRUNCATION_MARKER.strip(), entry["detail"])
|
||||
self.assertLess(len(captured.output[0]), 700)
|
||||
|
||||
def test_oversized_media_log_remains_valid_compact_json(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"type": "sticker",
|
||||
"url": "https://example.invalid/sticker.webp",
|
||||
"text": "x" * 20000,
|
||||
"unused_blob": "y" * 20000,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
with patch.dict(os.environ, {"KEFU_MESSAGE_LOG_MAX_CHARS": "4096"}):
|
||||
bounded = bound_message_log_content(payload)
|
||||
|
||||
decoded = json.loads(bounded)
|
||||
self.assertEqual(decoded["type"], "sticker")
|
||||
self.assertEqual(decoded["url"], "https://example.invalid/sticker.webp")
|
||||
self.assertTrue(decoded["_log_truncated"])
|
||||
self.assertNotIn("unused_blob", decoded)
|
||||
self.assertLessEqual(len(bounded), 4096)
|
||||
|
||||
def test_message_model_validator_caps_all_insert_paths(self):
|
||||
with patch.dict(os.environ, {"KEFU_MESSAGE_LOG_MAX_CHARS": "2048"}):
|
||||
row = MessageLog(message_content="m" * 10000, reply_content="r" * 10000)
|
||||
|
||||
self.assertLessEqual(len(row.message_content), 2048)
|
||||
self.assertLessEqual(len(row.reply_content), 2048)
|
||||
|
||||
def test_raw_message_log_is_bounded(self):
|
||||
with patch.dict(os.environ, {"KEFU_RAW_MESSAGE_LOG_MAX_CHARS": "4096"}):
|
||||
bounded = bound_raw_message_log_content("z" * 20000)
|
||||
self.assertLessEqual(len(bounded), 4096)
|
||||
self.assertIn(TRUNCATION_MARKER.strip(), bounded)
|
||||
|
||||
|
||||
class SqliteIoTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_short_memory_url_uses_one_static_connection(self):
|
||||
kwargs = engine_kwargs_for_url("sqlite+aiosqlite://")
|
||||
self.assertIs(kwargs["poolclass"], StaticPool)
|
||||
self.assertNotIn("pool_size", kwargs)
|
||||
|
||||
engine = create_database_engine(
|
||||
DatabaseConfig(
|
||||
db_type="sqlite",
|
||||
database_url="sqlite+aiosqlite://",
|
||||
)
|
||||
)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("CREATE TABLE memory_probe (id INTEGER)"))
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("INSERT INTO memory_probe VALUES (1)"))
|
||||
count = (
|
||||
await conn.execute(text("SELECT count(*) FROM memory_probe"))
|
||||
).scalar_one()
|
||||
self.assertEqual(count, 1)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
async def test_file_sqlite_uses_bounded_pool_and_wal_pragmas(self):
|
||||
kwargs = engine_kwargs_for_url("sqlite+aiosqlite:///example.db")
|
||||
self.assertEqual(kwargs["pool_size"], 5)
|
||||
self.assertEqual(kwargs["max_overflow"], 0)
|
||||
self.assertEqual(kwargs["connect_args"]["timeout"], 30.0)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
db_path = Path(temp_dir) / "io.db"
|
||||
engine = create_database_engine(
|
||||
DatabaseConfig(db_type="sqlite", db_path=str(db_path))
|
||||
)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
journal_mode = (await conn.execute(text("PRAGMA journal_mode"))).scalar_one()
|
||||
synchronous = (await conn.execute(text("PRAGMA synchronous"))).scalar_one()
|
||||
busy_timeout = (await conn.execute(text("PRAGMA busy_timeout"))).scalar_one()
|
||||
self.assertEqual(str(journal_mode).lower(), "wal")
|
||||
self.assertEqual(synchronous, 1)
|
||||
self.assertEqual(busy_timeout, 30000)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
async def test_existing_log_tables_receive_composite_indexes(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
db_path = Path(temp_dir) / "migration.db"
|
||||
engine = create_database_engine(
|
||||
DatabaseConfig(db_type="sqlite", db_path=str(db_path))
|
||||
)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE TABLE message_logs ("
|
||||
"id INTEGER PRIMARY KEY, account_id INTEGER, "
|
||||
"created_at DATETIME, sender_avatar TEXT, status VARCHAR(50))"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE TABLE received_message_logs ("
|
||||
"id INTEGER PRIMARY KEY, account_id INTEGER, "
|
||||
"created_at DATETIME)"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE TABLE system_logs ("
|
||||
"id INTEGER PRIMARY KEY, account_id INTEGER, "
|
||||
"created_at DATETIME)"
|
||||
)
|
||||
)
|
||||
await conn.run_sync(migrate_message_logs_table)
|
||||
|
||||
async with engine.connect() as conn:
|
||||
message_indexes = {
|
||||
row[1]
|
||||
for row in (await conn.execute(text("PRAGMA index_list(message_logs)"))).all()
|
||||
}
|
||||
received_indexes = {
|
||||
row[1]
|
||||
for row in (
|
||||
await conn.execute(text("PRAGMA index_list(received_message_logs)"))
|
||||
).all()
|
||||
}
|
||||
system_indexes = {
|
||||
row[1]
|
||||
for row in (await conn.execute(text("PRAGMA index_list(system_logs)"))).all()
|
||||
}
|
||||
latest_plan = " ".join(
|
||||
str(row[-1])
|
||||
for row in (
|
||||
await conn.execute(
|
||||
text(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM message_logs "
|
||||
"ORDER BY created_at DESC LIMIT 50"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
status_plan = " ".join(
|
||||
str(row[-1])
|
||||
for row in (
|
||||
await conn.execute(
|
||||
text(
|
||||
"EXPLAIN QUERY PLAN SELECT count(*) FROM message_logs "
|
||||
"WHERE status = 'replied'"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
account_plan = " ".join(
|
||||
str(row[-1])
|
||||
for row in (
|
||||
await conn.execute(
|
||||
text(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM message_logs "
|
||||
"WHERE account_id = 1 ORDER BY created_at DESC LIMIT 50"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
system_plan = " ".join(
|
||||
str(row[-1])
|
||||
for row in (
|
||||
await conn.execute(
|
||||
text(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM system_logs "
|
||||
"WHERE account_id = 1 ORDER BY created_at DESC LIMIT 50"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
self.assertIn("ix_message_logs_account_created_at", message_indexes)
|
||||
self.assertIn("ix_message_logs_created_at", message_indexes)
|
||||
self.assertIn("ix_message_logs_status_account_id", message_indexes)
|
||||
self.assertIn(
|
||||
"ix_received_message_logs_account_created_at",
|
||||
received_indexes,
|
||||
)
|
||||
self.assertIn("ix_system_logs_account_created_at", system_indexes)
|
||||
self.assertIn("ix_message_logs_created_at", latest_plan)
|
||||
self.assertIn("ix_message_logs_status_account_id", status_plan)
|
||||
self.assertIn("ix_message_logs_account_created_at", account_plan)
|
||||
self.assertIn("ix_system_logs_account_created_at", system_plan)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class WebSocketDebugTests(unittest.TestCase):
|
||||
def _frame(self, *, message_type: int, content: str) -> bytes:
|
||||
response = Response_pb2.Response()
|
||||
message = response.body.new_message_notify.message
|
||||
message.conversation_id = "0:1:200:100"
|
||||
message.server_message_id = 123
|
||||
message.message_type = message_type
|
||||
message.sender = 200
|
||||
message.content = content
|
||||
frame = Live_pb2.PushFrame()
|
||||
frame.payloadType = "pb"
|
||||
frame.payload = response.SerializeToString()
|
||||
return frame.SerializeToString()
|
||||
|
||||
def test_control_frame_is_filtered_before_debug_writer(self):
|
||||
with patch.object(protocol, "_dump_ws_message") as dump:
|
||||
result = protocol.parse_ws_payload(
|
||||
self._frame(message_type=50001, content='{"command_type":6}')
|
||||
)
|
||||
self.assertEqual(result, [])
|
||||
dump.assert_not_called()
|
||||
|
||||
def test_debug_writer_uses_non_blocking_rotating_queue(self):
|
||||
# Inspect construction without writing chat data to the repository.
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
old_path = protocol._WS_DEBUG_PATH
|
||||
protocol._WS_DEBUG_PATH = str(Path(temp_dir) / "ws.log")
|
||||
protocol._WS_DEBUG_LOGGER = None
|
||||
try:
|
||||
with patch.dict(os.environ, {"KEFU_WS_DEBUG": "1"}):
|
||||
protocol._dump_ws_message(1, "conv", "hello")
|
||||
logger = protocol._WS_DEBUG_LOGGER
|
||||
self.assertIsNotNone(logger)
|
||||
self.assertIsInstance(logger.handlers[0], logging.handlers.QueueHandler)
|
||||
self.assertIsInstance(
|
||||
logger._kefu_rotating_handler,
|
||||
logging.handlers.RotatingFileHandler,
|
||||
)
|
||||
finally:
|
||||
logger = protocol._WS_DEBUG_LOGGER
|
||||
if logger is not None:
|
||||
logger._kefu_queue_listener.stop()
|
||||
logger._kefu_rotating_handler.close()
|
||||
logger.handlers.clear()
|
||||
protocol._WS_DEBUG_LOGGER = None
|
||||
protocol._WS_DEBUG_PATH = old_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -58,6 +58,7 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
worker.is_running = True
|
||||
worker._im_service = SimpleNamespace(_running=True)
|
||||
worker._require_sec_user_id = AsyncMock(return_value=False)
|
||||
worker._refresh_follow_welcome_config = AsyncMock()
|
||||
worker.get_db = AsyncMock()
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
@@ -68,8 +69,24 @@ class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
# The identity guard must run before the account's follow-welcome flag
|
||||
# is queried. Otherwise accounts with that feature disabled could stay
|
||||
# hosted indefinitely without a sec_user_id.
|
||||
worker._refresh_follow_welcome_config.assert_not_awaited()
|
||||
worker.get_db.assert_not_awaited()
|
||||
|
||||
async def test_cached_disabled_follow_setting_still_guards_missing_identity(self):
|
||||
worker = DouyinWorker(account_id=311, login_mode="im_direct")
|
||||
worker.is_running = True
|
||||
worker._im_service = SimpleNamespace(_running=True)
|
||||
worker._follow_config_loaded = True
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "")
|
||||
)
|
||||
worker._require_sec_user_id = AsyncMock(return_value="")
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
|
||||
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
||||
worker._require_sec_user_id.assert_awaited_once_with("托管运行中")
|
||||
|
||||
async def test_blank_sec_user_id_is_missing_and_stops_hosting(self):
|
||||
worker = DouyinWorker(account_id=303, login_mode="im_direct")
|
||||
worker._load_sec_user_id = AsyncMock(return_value=" ")
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
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.session import DouyinImSession
|
||||
from rpa_engine.playwright_worker import DouyinWorker
|
||||
|
||||
|
||||
class WorkerScaleControlTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_storage_load_selects_only_cookie_column(self):
|
||||
class _ScalarResult:
|
||||
def scalar_one_or_none(self):
|
||||
return '{"cookies": [{"name": "sessionid", "value": "ok"}]}'
|
||||
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=_ScalarResult()),
|
||||
close=AsyncMock(),
|
||||
)
|
||||
worker = DouyinWorker(account_id=499)
|
||||
worker.get_db = AsyncMock(return_value=db)
|
||||
|
||||
storage = await worker._load_storage_state()
|
||||
|
||||
self.assertEqual(storage["cookies"][0]["value"], "ok")
|
||||
statement = db.execute.await_args.args[0]
|
||||
selected_names = [
|
||||
item.get("name") for item in statement.column_descriptions
|
||||
]
|
||||
self.assertEqual(selected_names, ["cookie_data"])
|
||||
self.assertNotIn("im_session_data", str(statement).lower())
|
||||
|
||||
async def test_direct_service_marks_worker_ready_after_initialization(self):
|
||||
worker = DouyinWorker(account_id=500, login_mode="im_direct")
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "sec-user")
|
||||
)
|
||||
worker.get_reply_delay = AsyncMock(return_value=None)
|
||||
fake_service = SimpleNamespace(run=AsyncMock(), stop=AsyncMock())
|
||||
|
||||
async def complete_initialization():
|
||||
service_factory.call_args.kwargs["on_ready"]()
|
||||
|
||||
fake_service.run.side_effect = complete_initialization
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "session"},
|
||||
my_uid=0,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"rpa_engine.playwright_worker.DouyinImService",
|
||||
return_value=fake_service,
|
||||
) as service_factory:
|
||||
await worker._run_im_direct_service(session)
|
||||
|
||||
await worker.wait_until_ready()
|
||||
worker._refresh_follow_welcome_config.assert_awaited_once_with(force=True)
|
||||
fake_service.run.assert_awaited_once_with()
|
||||
fake_service.stop.assert_awaited_once_with()
|
||||
|
||||
async def test_direct_start_failure_releases_readiness_waiter(self):
|
||||
worker = DouyinWorker(account_id=501, login_mode="im_direct")
|
||||
worker._load_storage_state = AsyncMock(return_value=None)
|
||||
worker.update_account_status = AsyncMock()
|
||||
worker.cleanup = AsyncMock()
|
||||
|
||||
await worker.start()
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "未保存 Cookie"):
|
||||
await worker.wait_until_ready()
|
||||
|
||||
if worker._task:
|
||||
await worker._task
|
||||
worker.update_account_status.assert_awaited_once_with(
|
||||
"error",
|
||||
error_msg="未保存 Cookie,无法直连 IM",
|
||||
)
|
||||
|
||||
async def test_prevalidated_start_skips_duplicate_remote_validation(self):
|
||||
worker = DouyinWorker(
|
||||
account_id=502,
|
||||
login_mode="im_direct",
|
||||
credential_prevalidated=True,
|
||||
)
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "session"},
|
||||
my_uid=10001,
|
||||
keys_str=json.dumps({"ec_privateKey": "private"}),
|
||||
web_protect_str=json.dumps(
|
||||
{
|
||||
"ticket": "ticket",
|
||||
"ts_sign": "sign",
|
||||
"client_cert": "certificate",
|
||||
}
|
||||
),
|
||||
)
|
||||
worker._load_user_agent = AsyncMock(return_value="test-agent")
|
||||
worker._build_im_session_from_storage = AsyncMock(return_value=session)
|
||||
worker._require_sec_user_id = AsyncMock(return_value="sec-user")
|
||||
worker._persist_im_session = AsyncMock()
|
||||
worker._run_im_direct_service = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"rpa_engine.playwright_worker.validate_im_session",
|
||||
new_callable=AsyncMock,
|
||||
) as validate:
|
||||
started, reason = await worker._try_cookie_only_im_start(
|
||||
{"cookies": []}
|
||||
)
|
||||
|
||||
self.assertTrue(started)
|
||||
self.assertEqual(reason, "")
|
||||
validate.assert_not_awaited()
|
||||
worker._require_sec_user_id.assert_awaited_once()
|
||||
worker._run_im_direct_service.assert_awaited_once_with(session)
|
||||
|
||||
async def test_disabled_follow_welcome_uses_cached_lightweight_config(self):
|
||||
class _Result:
|
||||
def first(self):
|
||||
return False, "", "sec-user"
|
||||
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=_Result()),
|
||||
close=AsyncMock(),
|
||||
)
|
||||
worker = DouyinWorker(account_id=503)
|
||||
worker.get_db = AsyncMock(return_value=db)
|
||||
|
||||
first = await worker._refresh_follow_welcome_config()
|
||||
second = await worker._refresh_follow_welcome_config()
|
||||
|
||||
self.assertEqual(first, (False, "", "sec-user"))
|
||||
self.assertEqual(second, first)
|
||||
worker.get_db.assert_awaited_once_with()
|
||||
db.execute.assert_awaited_once()
|
||||
db.close.assert_awaited_once_with()
|
||||
|
||||
# The account PUT endpoint calls this synchronous hook so enabling the
|
||||
# feature does not wait for the disabled-account ten-minute TTL.
|
||||
worker.invalidate_follow_welcome_config()
|
||||
await worker._refresh_follow_welcome_config()
|
||||
self.assertEqual(worker.get_db.await_count, 2)
|
||||
self.assertEqual(db.execute.await_count, 2)
|
||||
|
||||
async def test_disabled_follow_tick_does_not_read_follower_log(self):
|
||||
worker = DouyinWorker(account_id=504)
|
||||
worker._im_service = SimpleNamespace(session=object())
|
||||
worker._follow_config_loaded = True
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "sec-user")
|
||||
)
|
||||
worker.get_db = AsyncMock()
|
||||
worker._require_sec_user_id = AsyncMock()
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
|
||||
worker._refresh_follow_welcome_config.assert_awaited_once_with()
|
||||
worker.get_db.assert_not_awaited()
|
||||
worker._require_sec_user_id.assert_not_awaited()
|
||||
|
||||
async def test_missing_cached_sec_user_id_stops_hosting(self):
|
||||
worker = DouyinWorker(account_id=505)
|
||||
worker._im_service = SimpleNamespace(session=object())
|
||||
worker._follow_config_loaded = True
|
||||
worker._refresh_follow_welcome_config = AsyncMock(
|
||||
return_value=(False, "", "")
|
||||
)
|
||||
worker._require_sec_user_id = AsyncMock(return_value="")
|
||||
|
||||
await worker.follow_welcome_tick()
|
||||
|
||||
worker._require_sec_user_id.assert_awaited_once_with(
|
||||
"托管运行中"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,551 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from websockets.legacy.server import serve
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from rpa_engine.douyin_im import ws_client as ws_module
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine.douyin_im.ws_client import DouyinImWsClient, _reconnect_delay
|
||||
|
||||
|
||||
TEST_WS_URL = "wss://frontier-im.douyin.com/ws/v2?token=test-token-value"
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self, frames=()):
|
||||
self.frames = list(frames)
|
||||
self.next_calls = 0
|
||||
self.close_code = 1000
|
||||
self.close_reason = "test complete"
|
||||
self.close_calls: list[tuple[int, str]] = []
|
||||
self.fail_calls = 0
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
self.next_calls += 1
|
||||
if not self.frames:
|
||||
raise StopAsyncIteration
|
||||
return self.frames.pop(0)
|
||||
|
||||
async def close(self, code=1000, reason=""):
|
||||
self.close_calls.append((code, reason))
|
||||
|
||||
def fail_connection(self):
|
||||
self.fail_calls += 1
|
||||
|
||||
|
||||
class WebSocketScalingTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _make_client(self, handler=None, account_id=23):
|
||||
session = DouyinImSession(
|
||||
cookies={"sessionid": "session-value", "sid_tt": "sid-value"},
|
||||
ws_urls=[TEST_WS_URL],
|
||||
user_agent="test-agent/1.0",
|
||||
)
|
||||
return DouyinImWsClient(
|
||||
session,
|
||||
handler or AsyncMock(),
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
async def test_async_connection_preserves_handshake_and_ping_options(self):
|
||||
received: list[bytes] = []
|
||||
|
||||
async def handler(item):
|
||||
received.append(item["payload"])
|
||||
|
||||
client = self._make_client(handler)
|
||||
client._running = True
|
||||
fake_ws = _FakeWebSocket([b"binary-frame", "text-frame"])
|
||||
connect_mock = Mock(return_value=fake_ws)
|
||||
|
||||
with (
|
||||
patch.object(ws_module, "websocket_connect", connect_mock),
|
||||
patch.object(
|
||||
ws_module,
|
||||
"parse_ws_payload",
|
||||
side_effect=lambda payload: [{"payload": payload}],
|
||||
),
|
||||
patch.object(ws_module.system_logger, "record"),
|
||||
):
|
||||
await client._run_connection(TEST_WS_URL)
|
||||
queue = client._message_queue
|
||||
self.assertIsNotNone(queue)
|
||||
await asyncio.wait_for(queue.join(), timeout=0.5)
|
||||
await client.stop()
|
||||
|
||||
self.assertEqual(received, [b"binary-frame", b"text-frame"])
|
||||
kwargs = connect_mock.call_args.kwargs
|
||||
self.assertEqual(kwargs["origin"], "https://www.douyin.com")
|
||||
self.assertEqual(kwargs["subprotocols"], ["binary", "base64", "pbbp2"])
|
||||
self.assertEqual(kwargs["user_agent_header"], "test-agent/1.0")
|
||||
self.assertEqual(kwargs["ping_interval"], 20)
|
||||
self.assertEqual(kwargs["ping_timeout"], ws_module._PING_TIMEOUT_SECONDS)
|
||||
self.assertEqual(kwargs["max_queue"], ws_module._TRANSPORT_MAX_QUEUE)
|
||||
self.assertEqual(kwargs["max_size"], ws_module._INCOMING_MAX_SIZE)
|
||||
headers = dict(kwargs["extra_headers"])
|
||||
self.assertEqual(headers["Cookie"], "sessionid=session-value; sid_tt=sid-value")
|
||||
self.assertNotIn("Sec-WebSocket-Protocol", headers)
|
||||
self.assertFalse(client.connected)
|
||||
self.assertIsNone(client._connection)
|
||||
|
||||
async def test_starting_500_clients_does_not_create_os_threads(self):
|
||||
parked = asyncio.Event()
|
||||
|
||||
async def parked_run_loop(_client, _url):
|
||||
await parked.wait()
|
||||
|
||||
clients = [self._make_client(account_id=index + 1) for index in range(500)]
|
||||
before_threads = threading.active_count()
|
||||
with patch.object(DouyinImWsClient, "_run_loop", parked_run_loop):
|
||||
await asyncio.gather(*(client.start() for client in clients))
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(threading.active_count(), before_threads)
|
||||
self.assertEqual(sum(client._task is not None for client in clients), 500)
|
||||
self.assertEqual(
|
||||
sum(client._dispatcher_task is not None for client in clients),
|
||||
500,
|
||||
)
|
||||
await asyncio.gather(*(client.stop() for client in clients))
|
||||
|
||||
self.assertTrue(all(client._task is None for client in clients))
|
||||
self.assertTrue(all(client._dispatcher_task is None for client in clients))
|
||||
|
||||
async def test_global_handler_concurrency_is_shared_across_clients(self):
|
||||
limit = 3
|
||||
release_handlers = asyncio.Event()
|
||||
limit_reached = asyncio.Event()
|
||||
active = 0
|
||||
maximum_active = 0
|
||||
started = 0
|
||||
completed = 0
|
||||
|
||||
async def handler(_item):
|
||||
nonlocal active, maximum_active, started, completed
|
||||
active += 1
|
||||
started += 1
|
||||
maximum_active = max(maximum_active, active)
|
||||
if started == limit:
|
||||
limit_reached.set()
|
||||
try:
|
||||
await release_handlers.wait()
|
||||
finally:
|
||||
active -= 1
|
||||
completed += 1
|
||||
|
||||
clients = [
|
||||
self._make_client(handler, account_id=index + 1000)
|
||||
for index in range(12)
|
||||
]
|
||||
queues = []
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{ws_module._HANDLER_CONCURRENCY_ENV: str(limit)},
|
||||
):
|
||||
try:
|
||||
for index, client in enumerate(clients):
|
||||
client._running = True
|
||||
client._ensure_dispatcher()
|
||||
queue = client._message_queue
|
||||
self.assertIsNotNone(queue)
|
||||
queues.append(queue)
|
||||
await queue.put({"index": index})
|
||||
|
||||
await asyncio.wait_for(limit_reached.wait(), timeout=0.5)
|
||||
# Give every other account a chance to contend for the same
|
||||
# process/event-loop-wide semaphore.
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(started, limit)
|
||||
self.assertEqual(maximum_active, limit)
|
||||
|
||||
release_handlers.set()
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*(queue.join() for queue in queues)),
|
||||
timeout=1.0,
|
||||
)
|
||||
finally:
|
||||
release_handlers.set()
|
||||
await asyncio.gather(*(client.stop() for client in clients))
|
||||
|
||||
self.assertEqual(completed, len(clients))
|
||||
self.assertEqual(maximum_active, limit)
|
||||
|
||||
async def test_global_handler_limit_preserves_single_client_fifo(self):
|
||||
received: list[int] = []
|
||||
|
||||
async def handler(item):
|
||||
await asyncio.sleep(0)
|
||||
received.append(item["sequence"])
|
||||
|
||||
client = self._make_client(handler, account_id=2001)
|
||||
client._running = True
|
||||
client._ensure_dispatcher()
|
||||
queue = client._message_queue
|
||||
self.assertIsNotNone(queue)
|
||||
for sequence in range(20):
|
||||
await queue.put({"sequence": sequence})
|
||||
await asyncio.wait_for(queue.join(), timeout=0.5)
|
||||
await client.stop()
|
||||
|
||||
self.assertEqual(received, list(range(20)))
|
||||
|
||||
async def test_stop_cancels_dispatcher_waiting_for_global_handler_slot(self):
|
||||
holder_started = asyncio.Event()
|
||||
release_holder = asyncio.Event()
|
||||
waiter_handler = AsyncMock()
|
||||
|
||||
async def holder_handler(_item):
|
||||
holder_started.set()
|
||||
await release_holder.wait()
|
||||
|
||||
holder = self._make_client(holder_handler, account_id=3001)
|
||||
waiter = self._make_client(waiter_handler, account_id=3002)
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{ws_module._HANDLER_CONCURRENCY_ENV: "1"},
|
||||
):
|
||||
holder._running = True
|
||||
holder._ensure_dispatcher()
|
||||
holder_queue = holder._message_queue
|
||||
self.assertIsNotNone(holder_queue)
|
||||
await holder_queue.put({"id": "holder"})
|
||||
await asyncio.wait_for(holder_started.wait(), timeout=0.5)
|
||||
|
||||
waiter._running = True
|
||||
waiter._ensure_dispatcher()
|
||||
waiter_queue = waiter._message_queue
|
||||
self.assertIsNotNone(waiter_queue)
|
||||
await waiter_queue.put({"id": "waiter"})
|
||||
|
||||
state = ws_module._get_loop_state()
|
||||
|
||||
async def wait_until_slot_has_waiter():
|
||||
while not state.handler_slots._waiters:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(wait_until_slot_has_waiter(), timeout=0.5)
|
||||
joined = asyncio.create_task(waiter_queue.join())
|
||||
await asyncio.wait_for(waiter.stop(), timeout=0.5)
|
||||
await asyncio.wait_for(joined, timeout=0.5)
|
||||
|
||||
waiter_handler.assert_not_awaited()
|
||||
self.assertTrue(waiter_queue.empty())
|
||||
|
||||
release_holder.set()
|
||||
await asyncio.wait_for(holder_queue.join(), timeout=0.5)
|
||||
await holder.stop()
|
||||
|
||||
async def test_bounded_dispatch_queue_keeps_receiver_responsive(self):
|
||||
first_handler_started = asyncio.Event()
|
||||
release_first_handler = asyncio.Event()
|
||||
received: list[bytes] = []
|
||||
|
||||
async def handler(item):
|
||||
received.append(item["payload"])
|
||||
if len(received) == 1:
|
||||
first_handler_started.set()
|
||||
await release_first_handler.wait()
|
||||
|
||||
client = self._make_client(handler)
|
||||
client._running = True
|
||||
fake_ws = _FakeWebSocket([b"first", b"second", b"third", b"fourth"])
|
||||
|
||||
async def wait_until_third_frame_is_read():
|
||||
while fake_ws.next_calls < 3:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
with (
|
||||
patch.object(ws_module, "_APPLICATION_QUEUE_SIZE", 1),
|
||||
patch.object(ws_module, "websocket_connect", return_value=fake_ws),
|
||||
patch.object(
|
||||
ws_module,
|
||||
"parse_ws_payload",
|
||||
side_effect=lambda payload: [{"payload": payload}],
|
||||
),
|
||||
patch.object(ws_module.system_logger, "record"),
|
||||
):
|
||||
task = asyncio.create_task(client._run_connection(TEST_WS_URL))
|
||||
await asyncio.wait_for(first_handler_started.wait(), timeout=0.5)
|
||||
await asyncio.wait_for(wait_until_third_frame_is_read(), timeout=0.5)
|
||||
|
||||
# The receiver keeps consuming while the business handler is
|
||||
# blocked, but stops after the bounded application queue fills.
|
||||
# It has pulled the third frame and is blocked enqueueing it; the
|
||||
# fourth frame hasn't been requested and memory remains bounded.
|
||||
self.assertEqual(fake_ws.next_calls, 3)
|
||||
self.assertEqual(received, [b"first"])
|
||||
self.assertEqual(client._message_queue.qsize(), 1)
|
||||
self.assertIsNotNone(client._dispatcher_task)
|
||||
|
||||
release_first_handler.set()
|
||||
await asyncio.wait_for(task, timeout=0.5)
|
||||
queue = client._message_queue
|
||||
self.assertIsNotNone(queue)
|
||||
await asyncio.wait_for(queue.join(), timeout=0.5)
|
||||
await client.stop()
|
||||
|
||||
self.assertEqual(received, [b"first", b"second", b"third", b"fourth"])
|
||||
self.assertEqual(fake_ws.next_calls, 5) # four frames + end-of-stream
|
||||
|
||||
async def test_real_async_handshake_receives_binary_frame(self):
|
||||
received: list[bytes] = []
|
||||
request: dict[str, str | None] = {}
|
||||
|
||||
async def handler(item):
|
||||
received.append(item["payload"])
|
||||
|
||||
async def server_handler(websocket, _path):
|
||||
request["origin"] = websocket.request_headers.get("Origin")
|
||||
request["cookie"] = websocket.request_headers.get("Cookie")
|
||||
request["user_agent"] = websocket.request_headers.get("User-Agent")
|
||||
request["subprotocol"] = websocket.subprotocol
|
||||
await websocket.send(b"protobuf-frame")
|
||||
await websocket.close(code=1000, reason="test complete")
|
||||
|
||||
client = self._make_client(handler)
|
||||
client._running = True
|
||||
with (
|
||||
patch.object(
|
||||
ws_module,
|
||||
"parse_ws_payload",
|
||||
side_effect=lambda payload: [{"payload": payload}],
|
||||
),
|
||||
patch.object(ws_module.system_logger, "record"),
|
||||
):
|
||||
async with serve(
|
||||
server_handler,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
origins=["https://www.douyin.com"],
|
||||
subprotocols=["pbbp2"],
|
||||
) as server:
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
await asyncio.wait_for(
|
||||
client._run_connection(f"ws://127.0.0.1:{port}"),
|
||||
timeout=1.0,
|
||||
)
|
||||
queue = client._message_queue
|
||||
self.assertIsNotNone(queue)
|
||||
await asyncio.wait_for(queue.join(), timeout=0.5)
|
||||
await client.stop()
|
||||
|
||||
self.assertEqual(received, [b"protobuf-frame"])
|
||||
self.assertEqual(request["origin"], "https://www.douyin.com")
|
||||
self.assertEqual(request["cookie"], "sessionid=session-value; sid_tt=sid-value")
|
||||
self.assertEqual(request["user_agent"], "test-agent/1.0")
|
||||
self.assertEqual(request["subprotocol"], "pbbp2")
|
||||
|
||||
async def test_stop_cancels_slow_handler_and_drains_pending_messages(self):
|
||||
handler_started = asyncio.Event()
|
||||
handler_cancelled = asyncio.Event()
|
||||
never_release = asyncio.Event()
|
||||
|
||||
async def handler(_item):
|
||||
handler_started.set()
|
||||
try:
|
||||
await never_release.wait()
|
||||
except asyncio.CancelledError:
|
||||
handler_cancelled.set()
|
||||
raise
|
||||
|
||||
client = self._make_client(handler)
|
||||
client._running = True
|
||||
client._ensure_dispatcher()
|
||||
queue = client._message_queue
|
||||
self.assertIsNotNone(queue)
|
||||
await queue.put({"id": 1})
|
||||
await queue.put({"id": 2})
|
||||
await asyncio.wait_for(handler_started.wait(), timeout=0.5)
|
||||
joined = asyncio.create_task(queue.join())
|
||||
|
||||
await asyncio.wait_for(client.stop(), timeout=0.5)
|
||||
await asyncio.wait_for(joined, timeout=0.5)
|
||||
|
||||
self.assertTrue(handler_cancelled.is_set())
|
||||
self.assertTrue(queue.empty())
|
||||
self.assertIsNone(client._message_queue)
|
||||
self.assertIsNone(client._dispatcher_task)
|
||||
|
||||
async def test_first_connection_uses_captured_url_without_refresh(self):
|
||||
client = self._make_client(account_id=24)
|
||||
client._running = True
|
||||
refreshed_url = TEST_WS_URL + "&refreshed=1"
|
||||
client._prepare_url = AsyncMock(return_value=refreshed_url)
|
||||
connected_urls: list[str] = []
|
||||
|
||||
async def connection(url):
|
||||
connected_urls.append(url)
|
||||
client._last_connection_lifetime = 1.0
|
||||
if len(connected_urls) == 2:
|
||||
client._running = False
|
||||
|
||||
client._run_connection = connection
|
||||
|
||||
with (
|
||||
patch.object(ws_module, "_reconnect_delay", return_value=0.0),
|
||||
patch.object(ws_module.system_logger, "record"),
|
||||
):
|
||||
await asyncio.wait_for(client._run_loop(TEST_WS_URL), timeout=0.5)
|
||||
|
||||
self.assertEqual(connected_urls, [TEST_WS_URL, refreshed_url])
|
||||
client._prepare_url.assert_awaited_once_with(TEST_WS_URL)
|
||||
|
||||
async def test_stop_closes_connection_and_cancels_receive_task(self):
|
||||
client = self._make_client()
|
||||
client._running = True
|
||||
client.connected = True
|
||||
fake_ws = _FakeWebSocket()
|
||||
client._connection = fake_ws
|
||||
task = asyncio.create_task(asyncio.sleep(30))
|
||||
client._task = task
|
||||
|
||||
await client.stop()
|
||||
|
||||
self.assertEqual(fake_ws.close_calls, [(1000, "client stopping")])
|
||||
self.assertTrue(task.cancelled())
|
||||
self.assertFalse(client.connected)
|
||||
self.assertIsNone(client._connection)
|
||||
self.assertIsNone(client._task)
|
||||
|
||||
async def test_stop_aborts_connection_when_close_handshake_stalls(self):
|
||||
client = self._make_client()
|
||||
client._running = True
|
||||
client.connected = True
|
||||
fake_ws = _FakeWebSocket()
|
||||
|
||||
async def stalled_close(code=1000, reason=""):
|
||||
fake_ws.close_calls.append((code, reason))
|
||||
await asyncio.Event().wait()
|
||||
|
||||
fake_ws.close = stalled_close
|
||||
client._connection = fake_ws
|
||||
task = asyncio.create_task(asyncio.sleep(30))
|
||||
client._task = task
|
||||
|
||||
with patch.object(ws_module, "_CLOSE_GRACE_SECONDS", 0.01):
|
||||
await asyncio.wait_for(client.stop(), timeout=0.2)
|
||||
|
||||
self.assertEqual(fake_ws.fail_calls, 1)
|
||||
self.assertTrue(task.cancelled())
|
||||
self.assertIsNone(client._connection)
|
||||
|
||||
async def test_short_normal_closes_continue_exponential_retry(self):
|
||||
client = self._make_client(account_id=41)
|
||||
client._running = True
|
||||
client._prepare_url = AsyncMock(return_value=TEST_WS_URL)
|
||||
attempts = 0
|
||||
|
||||
async def short_connection(_url):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
client._last_connection_lifetime = 1.0
|
||||
if attempts == 4:
|
||||
client._running = False
|
||||
|
||||
client._run_connection = short_connection
|
||||
|
||||
with (
|
||||
patch.object(ws_module, "_reconnect_delay", return_value=0.0) as delay,
|
||||
patch.object(ws_module.system_logger, "record"),
|
||||
):
|
||||
await asyncio.wait_for(client._run_loop(TEST_WS_URL), timeout=0.5)
|
||||
|
||||
self.assertEqual([call.args[1] for call in delay.call_args_list], [1, 2, 3])
|
||||
|
||||
async def test_stable_connection_resets_retry_counter(self):
|
||||
client = self._make_client(account_id=42)
|
||||
client._running = True
|
||||
client._prepare_url = AsyncMock(return_value=TEST_WS_URL)
|
||||
lifetimes = [1.0, 1.0, ws_module._STABLE_CONNECTION_SECONDS + 1.0, 1.0]
|
||||
attempts = 0
|
||||
|
||||
async def connection(_url):
|
||||
nonlocal attempts
|
||||
client._last_connection_lifetime = lifetimes[attempts]
|
||||
attempts += 1
|
||||
if attempts == len(lifetimes):
|
||||
client._running = False
|
||||
|
||||
client._run_connection = connection
|
||||
|
||||
with (
|
||||
patch.object(ws_module, "_reconnect_delay", return_value=0.0) as delay,
|
||||
patch.object(ws_module.system_logger, "record"),
|
||||
):
|
||||
await asyncio.wait_for(client._run_loop(TEST_WS_URL), timeout=0.5)
|
||||
|
||||
self.assertEqual([call.args[1] for call in delay.call_args_list], [1, 2, 1])
|
||||
|
||||
async def test_repeated_connection_failures_throttle_system_logs_per_account(self):
|
||||
client = self._make_client(account_id=4041)
|
||||
client._running = True
|
||||
client._prepare_url = AsyncMock(return_value=TEST_WS_URL)
|
||||
attempts = 0
|
||||
|
||||
async def failing_connection(_url):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 6:
|
||||
client._running = False
|
||||
raise ConnectionError("frontier unavailable")
|
||||
|
||||
client._run_connection = failing_connection
|
||||
|
||||
with (
|
||||
patch.object(ws_module, "_reconnect_delay", return_value=0.0),
|
||||
patch.object(
|
||||
ws_module,
|
||||
"_system_log_throttle_seconds",
|
||||
return_value=300.0,
|
||||
),
|
||||
patch.object(ws_module.system_logger, "record") as system_record,
|
||||
patch.object(ws_module.logger, "warning") as ordinary_warning,
|
||||
):
|
||||
await asyncio.wait_for(client._run_loop(TEST_WS_URL), timeout=0.5)
|
||||
|
||||
# Ordinary diagnostics remain available for every live failure, while
|
||||
# the database-backed system log receives one row for the repeated
|
||||
# failure/reconnect cycle of this account.
|
||||
self.assertEqual(attempts, 6)
|
||||
self.assertEqual(ordinary_warning.call_count, 5)
|
||||
self.assertEqual(system_record.call_count, 1)
|
||||
self.assertEqual(system_record.call_args.kwargs["account_id"], 4041)
|
||||
|
||||
def test_handler_concurrency_environment_value_is_safely_clamped(self):
|
||||
with patch.dict(os.environ, {ws_module._HANDLER_CONCURRENCY_ENV: "0"}):
|
||||
self.assertEqual(ws_module._handler_concurrency_limit(), 1)
|
||||
with patch.dict(os.environ, {ws_module._HANDLER_CONCURRENCY_ENV: "500"}):
|
||||
self.assertEqual(ws_module._handler_concurrency_limit(), 32)
|
||||
with patch.dict(os.environ, {ws_module._HANDLER_CONCURRENCY_ENV: "invalid"}):
|
||||
self.assertEqual(ws_module._handler_concurrency_limit(), 8)
|
||||
|
||||
def test_reconnect_delay_is_bounded_and_spread_by_account(self):
|
||||
waits = [_reconnect_delay(10, retry) for retry in range(1, 10)]
|
||||
|
||||
self.assertGreater(waits[1], waits[0])
|
||||
self.assertGreater(waits[2], waits[1])
|
||||
self.assertTrue(all(2.0 <= wait < 90.0 for wait in waits))
|
||||
self.assertNotEqual(_reconnect_delay(10, 8), _reconnect_delay(11, 8))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user