This commit is contained in:
Your Name
2026-07-28 15:04:17 +08:00
parent ac406a5f99
commit 8f68af1c2c
27 changed files with 3442 additions and 296 deletions
+156 -2
View File
@@ -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"),
+214
View File
@@ -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,
+281
View File
@@ -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()
+17
View File
@@ -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=" ")
+191
View File
@@ -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()
+551
View File
@@ -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()