更新
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
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))
|
||||
|
||||
import main
|
||||
from models.database import Base
|
||||
from models.models import Account
|
||||
|
||||
|
||||
class _CountResult:
|
||||
def __init__(self, count: int):
|
||||
self.count = count
|
||||
|
||||
def scalar_one(self):
|
||||
return self.count
|
||||
|
||||
|
||||
class _RowsResult:
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class AccountPaginationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_paginated_list_counts_then_loads_only_current_page(self):
|
||||
page_rows = [
|
||||
SimpleNamespace(id=10, status="offline"),
|
||||
SimpleNamespace(id=11, status="online"),
|
||||
]
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(
|
||||
side_effect=[
|
||||
_CountResult(392),
|
||||
_RowsResult(page_rows),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(main.manager, "is_running", side_effect=[False, True]),
|
||||
patch.object(
|
||||
main,
|
||||
"_build_account_response",
|
||||
side_effect=lambda account: {"id": account.id, "status": account.status},
|
||||
) as build_response,
|
||||
):
|
||||
response = await main.get_accounts(
|
||||
page=20,
|
||||
page_size=20,
|
||||
q=None,
|
||||
status=None,
|
||||
db=db,
|
||||
user=SimpleNamespace(id=1, role="admin"),
|
||||
)
|
||||
|
||||
self.assertEqual(db.execute.await_count, 2)
|
||||
self.assertEqual(response["total"], 392)
|
||||
self.assertEqual(response["page"], 20)
|
||||
self.assertEqual(response["page_size"], 20)
|
||||
self.assertEqual([item["id"] for item in response["items"]], [10, 11])
|
||||
self.assertEqual(build_response.call_count, 2)
|
||||
|
||||
count_sql = str(db.execute.await_args_list[0].args[0]).upper()
|
||||
page_sql = str(db.execute.await_args_list[1].args[0]).upper()
|
||||
self.assertIn("COUNT", count_sql)
|
||||
self.assertNotIn(" LIMIT ", count_sql)
|
||||
self.assertIn(" LIMIT ", page_sql)
|
||||
self.assertIn(" OFFSET ", page_sql)
|
||||
|
||||
async def test_status_filter_uses_effective_runtime_worker_state(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 = {
|
||||
1001: SimpleNamespace(is_running=True),
|
||||
1002: SimpleNamespace(is_running=False),
|
||||
}
|
||||
try:
|
||||
async with session_factory() as db:
|
||||
db.add_all(
|
||||
[
|
||||
Account(id=1001, status="offline"),
|
||||
Account(id=1002, status="online"),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch.object(
|
||||
main,
|
||||
"_build_account_response",
|
||||
side_effect=lambda account: {
|
||||
"id": account.id,
|
||||
"status": account.status,
|
||||
},
|
||||
):
|
||||
online = await main.get_accounts(
|
||||
page=1,
|
||||
page_size=20,
|
||||
q=None,
|
||||
status="online",
|
||||
db=db,
|
||||
user=SimpleNamespace(id=1, role="admin"),
|
||||
)
|
||||
await db.rollback()
|
||||
db.expire_all()
|
||||
offline = await main.get_accounts(
|
||||
page=1,
|
||||
page_size=20,
|
||||
q=None,
|
||||
status="offline",
|
||||
db=db,
|
||||
user=SimpleNamespace(id=1, role="admin"),
|
||||
)
|
||||
|
||||
self.assertEqual(online["total"], 1)
|
||||
self.assertEqual(online["items"], [{"id": 1001, "status": "online"}])
|
||||
self.assertEqual(offline["total"], 1)
|
||||
self.assertEqual(offline["items"], [{"id": 1002, "status": "offline"}])
|
||||
finally:
|
||||
main.manager.workers = original_workers
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,8 +17,18 @@ from rpa_engine import batch_start as batch_start_module
|
||||
|
||||
|
||||
class BatchStartQueueTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _make_queue(self, handler, *, concurrency: int = 2) -> BatchStartQueue:
|
||||
queue = BatchStartQueue(handler, concurrency=concurrency)
|
||||
def _make_queue(
|
||||
self,
|
||||
handler,
|
||||
*,
|
||||
concurrency: int = 2,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> BatchStartQueue:
|
||||
queue = BatchStartQueue(
|
||||
handler,
|
||||
concurrency=concurrency,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
self.addAsyncCleanup(queue.stop)
|
||||
return queue
|
||||
|
||||
@@ -168,6 +178,54 @@ class BatchStartQueueTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(by_account[32]["status"], "submitted")
|
||||
self.assertEqual(by_account[33]["status"], "submitted")
|
||||
|
||||
async def test_two_timeouts_release_both_workers_for_following_accounts(self):
|
||||
never_release = asyncio.Event()
|
||||
calls: list[int] = []
|
||||
|
||||
async def handler(account_id: int) -> dict:
|
||||
calls.append(account_id)
|
||||
if account_id in (71, 72):
|
||||
await never_release.wait()
|
||||
return {"message": f"started-{account_id}"}
|
||||
|
||||
queue = self._make_queue(
|
||||
handler,
|
||||
concurrency=2,
|
||||
timeout_seconds=0.02,
|
||||
)
|
||||
with patch.object(batch_start_module.logger, "warning"):
|
||||
submitted = await queue.submit([71, 72, 73, 74])
|
||||
completed = await self._wait_for_complete(
|
||||
queue,
|
||||
submitted["batch_id"],
|
||||
)
|
||||
|
||||
self.assertEqual(calls, [71, 72, 73, 74])
|
||||
self.assertEqual(completed["failed_count"], 2)
|
||||
self.assertEqual(completed["submitted_count"], 2)
|
||||
by_account = {item["account_id"]: item for item in completed["items"]}
|
||||
self.assertIn("已跳过并继续处理后续账号", by_account[71]["message"])
|
||||
self.assertIn("已跳过并继续处理后续账号", by_account[72]["message"])
|
||||
self.assertEqual(by_account[73]["status"], "submitted")
|
||||
self.assertEqual(by_account[74]["status"], "submitted")
|
||||
|
||||
async def test_handler_timeout_error_keeps_its_original_detail(self):
|
||||
async def handler(_account_id: int) -> dict:
|
||||
raise asyncio.TimeoutError("upstream request timed out")
|
||||
|
||||
queue = self._make_queue(
|
||||
handler,
|
||||
concurrency=1,
|
||||
timeout_seconds=10,
|
||||
)
|
||||
with patch.object(batch_start_module.logger, "exception"):
|
||||
submitted = await queue.submit([75])
|
||||
completed = await self._wait_for_complete(queue, submitted["batch_id"])
|
||||
|
||||
item = completed["items"][0]
|
||||
self.assertEqual(item["status"], "failed")
|
||||
self.assertEqual(item["message"], "upstream request timed out")
|
||||
|
||||
async def test_failed_account_can_be_submitted_again(self):
|
||||
attempts = 0
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
@@ -16,6 +17,8 @@ if str(BACKEND_DIR) not in sys.path:
|
||||
|
||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine.douyin_im.service import DouyinImService, _conversation_poll_timing
|
||||
from rpa_engine.douyin_im import service as service_module
|
||||
|
||||
|
||||
class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -75,6 +78,71 @@ class ConversationPollBandwidthTests(unittest.IsolatedAsyncioTestCase):
|
||||
["POST", "GET"],
|
||||
)
|
||||
|
||||
async def test_transport_outage_stops_after_one_post_and_get_pair(self):
|
||||
client = self._make_client()
|
||||
client._request = AsyncMock(return_value=None)
|
||||
|
||||
with self.assertLogs("douyin_im.http", level="WARNING"):
|
||||
self.assertEqual(await client.get_conversations(), [])
|
||||
|
||||
self.assertEqual(client._request.await_count, 2)
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in client._request.await_args_list],
|
||||
["POST", "GET"],
|
||||
)
|
||||
|
||||
def test_websocket_reconciliation_is_slow_and_http_fallback_stays_fast(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"KEFU_WS_RECONCILE_INTERVAL_SECONDS": "120",
|
||||
"KEFU_HTTP_POLL_INTERVAL_SECONDS": "15",
|
||||
},
|
||||
):
|
||||
ws_interval, ws_stagger = _conversation_poll_timing(123, True)
|
||||
http_interval, http_stagger = _conversation_poll_timing(123, False)
|
||||
|
||||
self.assertEqual(ws_interval, 120)
|
||||
self.assertEqual(http_interval, 15)
|
||||
self.assertGreaterEqual(ws_stagger, 0)
|
||||
self.assertLess(ws_stagger, ws_interval)
|
||||
self.assertGreaterEqual(http_stagger, 0)
|
||||
self.assertLess(http_stagger, http_interval)
|
||||
|
||||
async def test_service_poll_uses_one_conversation_request_without_unread_probe(self):
|
||||
class _Controller:
|
||||
@asynccontextmanager
|
||||
async def background_slot(self, *_args, **_kwargs):
|
||||
yield
|
||||
|
||||
class _HttpClient:
|
||||
def __init__(self):
|
||||
self.get_conversations = AsyncMock(return_value=[])
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
http = _HttpClient()
|
||||
service = DouyinImService(
|
||||
session=DouyinImSession(cookies={"sessionid": "test"}, my_uid=10001),
|
||||
match_reply=AsyncMock(return_value=None),
|
||||
log_fn=AsyncMock(),
|
||||
account_id=9,
|
||||
)
|
||||
service._index_conversations = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(service_module, "get_traffic_controller", return_value=_Controller()),
|
||||
patch.object(service_module, "DouyinImHttpClient", return_value=http),
|
||||
):
|
||||
await service._poll_conversations()
|
||||
|
||||
http.get_conversations.assert_awaited_once_with(enrich_profiles=False)
|
||||
service._index_conversations.assert_awaited_once_with([])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -84,6 +84,27 @@ class ReplyQueueApiTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(response.total_pending, 1)
|
||||
self.assertEqual([item.account_id for item in response.items], [1])
|
||||
|
||||
async def test_summary_with_account_ids_only_scans_requested_page(self):
|
||||
service_one = _FakeService(1, [_queue_item(1)])
|
||||
service_two = _FakeService(2, [_queue_item(2, "job-2")])
|
||||
service_one.get_reply_queue_snapshot = AsyncMock(return_value=service_one.items)
|
||||
service_two.get_reply_queue_snapshot = AsyncMock(return_value=service_two.items)
|
||||
main.manager.workers = {
|
||||
1: SimpleNamespace(is_running=True, _im_service=service_one),
|
||||
2: SimpleNamespace(is_running=True, _im_service=service_two),
|
||||
}
|
||||
|
||||
response = await main.get_reply_queue_summaries(
|
||||
account_ids="2",
|
||||
db=object(),
|
||||
user=SimpleNamespace(id=1, role="admin"),
|
||||
)
|
||||
|
||||
service_one.get_reply_queue_snapshot.assert_not_awaited()
|
||||
service_two.get_reply_queue_snapshot.assert_awaited_once()
|
||||
self.assertEqual(response.total_pending, 1)
|
||||
self.assertEqual([item.account_id for item in response.items], [2])
|
||||
|
||||
async def test_offline_account_detail_returns_empty_snapshot(self):
|
||||
account = SimpleNamespace(id=1, reply_delay_seconds=0)
|
||||
with (
|
||||
|
||||
@@ -458,6 +458,70 @@ class BackgroundTrafficLimitTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(len(tasks_seen), 3)
|
||||
self.assertTrue(all(task is tasks_seen[0] for task in tasks_seen))
|
||||
|
||||
async def test_startup_request_is_not_buried_behind_normal_backlog(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"KEFU_BACKGROUND_NETWORK_CONCURRENCY": "1"},
|
||||
):
|
||||
controller = TrafficController()
|
||||
self.addAsyncCleanup(controller.stop)
|
||||
|
||||
entered: list[str] = []
|
||||
releases = {
|
||||
name: asyncio.Event()
|
||||
for name in ("active", "normal-1", "normal-2", "startup")
|
||||
}
|
||||
|
||||
async def request(name: str, *, startup: bool = False) -> None:
|
||||
async with controller.background_slot(
|
||||
1,
|
||||
name,
|
||||
startup=startup,
|
||||
):
|
||||
entered.append(name)
|
||||
await releases[name].wait()
|
||||
|
||||
active = asyncio.create_task(request("active"))
|
||||
while entered != ["active"]:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
normal_one = asyncio.create_task(request("normal-1"))
|
||||
normal_two = asyncio.create_task(request("normal-2"))
|
||||
# Let one normal request reach the shared semaphore while the other is
|
||||
# held at normal admission, then add the priority startup request.
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
startup = asyncio.create_task(request("startup", startup=True))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
releases["active"].set()
|
||||
while len(entered) < 2:
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(entered[:2], ["active", "normal-1"])
|
||||
|
||||
releases["normal-1"].set()
|
||||
while len(entered) < 3:
|
||||
await asyncio.sleep(0)
|
||||
self.assertEqual(entered[:3], ["active", "normal-1", "startup"])
|
||||
|
||||
releases["startup"].set()
|
||||
while len(entered) < 4:
|
||||
await asyncio.sleep(0)
|
||||
releases["normal-2"].set()
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(active, normal_one, normal_two, startup),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
entered,
|
||||
["active", "normal-1", "startup", "normal-2"],
|
||||
)
|
||||
self.assertEqual(controller.background_active, 0)
|
||||
self.assertEqual(controller.background_waiting, 0)
|
||||
self.assertEqual(controller.background_startup_active, 0)
|
||||
self.assertEqual(controller.background_startup_waiting, 0)
|
||||
|
||||
|
||||
class TrafficControllerLoopIsolationTests(unittest.TestCase):
|
||||
def test_get_traffic_controller_does_not_reuse_asyncio_primitives(self):
|
||||
|
||||
Reference in New Issue
Block a user