更新
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
|
||||
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.egress_channels import (
|
||||
EgressChannel,
|
||||
EgressChannelUnavailable,
|
||||
EgressSnapshot,
|
||||
LocalAddress,
|
||||
discover_egress_channels,
|
||||
reset_egress_cache_for_tests,
|
||||
resolve_send_channels,
|
||||
)
|
||||
from models.db_migrate import migrate_accounts_table
|
||||
|
||||
|
||||
class EgressChannelTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
reset_egress_cache_for_tests()
|
||||
|
||||
async def test_discovery_deduplicates_public_ip_and_keeps_bindable_source(self):
|
||||
candidates = [
|
||||
LocalAddress(None, "default", True),
|
||||
LocalAddress("10.0.0.5", "eth0"),
|
||||
LocalAddress("10.0.0.6", "eth0:1"),
|
||||
]
|
||||
|
||||
async def probe(candidate):
|
||||
public_ip = "203.0.113.10" if candidate.source_ip != "10.0.0.6" else "203.0.113.11"
|
||||
return (
|
||||
EgressChannel(
|
||||
public_ip=public_ip,
|
||||
source_ip=candidate.source_ip,
|
||||
interface=candidate.interface,
|
||||
is_default=candidate.is_default,
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"rpa_engine.egress_channels.local_address_candidates",
|
||||
return_value=candidates,
|
||||
),
|
||||
patch(
|
||||
"rpa_engine.egress_channels._probe_local_address",
|
||||
AsyncMock(side_effect=probe),
|
||||
),
|
||||
):
|
||||
snapshot = await discover_egress_channels(force=True)
|
||||
|
||||
self.assertEqual([item.public_ip for item in snapshot.channels], ["203.0.113.10", "203.0.113.11"])
|
||||
self.assertEqual(snapshot.channels[0].source_ip, "10.0.0.5")
|
||||
self.assertTrue(snapshot.channels[0].is_default)
|
||||
|
||||
async def test_selected_channel_is_first_and_attempt_count_is_bounded(self):
|
||||
snapshot = EgressSnapshot(
|
||||
channels=(
|
||||
EgressChannel("198.51.100.1", "10.0.0.1", "eth0", True),
|
||||
EgressChannel("198.51.100.2", "10.0.0.2", "eth0:1"),
|
||||
EgressChannel("198.51.100.3", "10.0.0.3", "eth0:2"),
|
||||
),
|
||||
errors=(),
|
||||
detected_at=time.time(),
|
||||
)
|
||||
with patch(
|
||||
"rpa_engine.egress_channels.discover_egress_channels",
|
||||
AsyncMock(return_value=snapshot),
|
||||
):
|
||||
routes = await resolve_send_channels("198.51.100.2", 2)
|
||||
|
||||
self.assertEqual([item.public_ip for item in routes], ["198.51.100.2", "198.51.100.1"])
|
||||
|
||||
async def test_missing_selected_channel_fails_closed(self):
|
||||
snapshot = EgressSnapshot(
|
||||
channels=(EgressChannel("198.51.100.1", None, "default", True),),
|
||||
errors=(),
|
||||
detected_at=time.time(),
|
||||
)
|
||||
with patch(
|
||||
"rpa_engine.egress_channels.discover_egress_channels",
|
||||
AsyncMock(return_value=snapshot),
|
||||
):
|
||||
with self.assertRaises(EgressChannelUnavailable):
|
||||
await resolve_send_channels("198.51.100.99", 2)
|
||||
|
||||
|
||||
class EgressMigrationTests(unittest.TestCase):
|
||||
def test_old_accounts_table_receives_egress_columns(self):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE accounts (id INTEGER PRIMARY KEY)"))
|
||||
migrate_accounts_table(connection)
|
||||
columns = {item["name"] for item in inspect(connection).get_columns("accounts")}
|
||||
|
||||
self.assertIn("egress_public_ip", columns)
|
||||
self.assertIn("egress_auto_attempts", columns)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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.pb_decode import analyze_send_response
|
||||
|
||||
|
||||
class AnalyzeSendResponseTests(unittest.TestCase):
|
||||
def test_standalone_kick_json_is_not_decoded_as_protobuf(self):
|
||||
result = analyze_send_response(b'{"decision": "KICK"}')
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["decision"], "KICK")
|
||||
self.assertEqual(result["summary"], "JSON响应 decision=KICK")
|
||||
self.assertNotIn("unsupported wire type", result["summary"])
|
||||
|
||||
def test_malformed_json_still_returns_controlled_decode_summary(self):
|
||||
result = analyze_send_response(b'{"decision":')
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertIn("解码失败", result["summary"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -114,6 +114,39 @@ def _build_service(delay_seconds: int = 60):
|
||||
|
||||
|
||||
class ReplyQueueIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_kick_response_takes_account_offline_immediately(self):
|
||||
callback = AsyncMock()
|
||||
service, _, _ = _build_service()
|
||||
service.on_session_invalid = callback
|
||||
|
||||
with unittest.mock.patch(
|
||||
"rpa_engine.douyin_im.service.system_logger.record", Mock()
|
||||
):
|
||||
await service._note_session_invalid(
|
||||
"抖音安全网关返回 decision=KICK,当前登录态已失效"
|
||||
)
|
||||
|
||||
self.assertFalse(service._running)
|
||||
self.assertTrue(service._session_invalid_fired)
|
||||
callback.assert_awaited_once()
|
||||
self.assertIn("decision=KICK", callback.await_args.args[0])
|
||||
|
||||
async def test_invalid_request_still_requires_two_consecutive_failures(self):
|
||||
callback = AsyncMock()
|
||||
service, _, _ = _build_service()
|
||||
service.on_session_invalid = callback
|
||||
|
||||
with unittest.mock.patch(
|
||||
"rpa_engine.douyin_im.service.system_logger.record", Mock()
|
||||
):
|
||||
await service._note_session_invalid("INVALID_REQUEST")
|
||||
self.assertTrue(service._running)
|
||||
callback.assert_not_awaited()
|
||||
await service._note_session_invalid("INVALID_REQUEST")
|
||||
|
||||
self.assertFalse(service._running)
|
||||
callback.assert_awaited_once()
|
||||
|
||||
async def test_same_message_from_ws_and_poll_is_queued_once(self):
|
||||
service, match_reply, _ = _build_service()
|
||||
message = {
|
||||
|
||||
@@ -20,6 +20,7 @@ if str(BACKEND_DIR) not in sys.path:
|
||||
from rpa_engine.douyin_im import http_client as http_client_module
|
||||
from rpa_engine.douyin_im.http_client import DouyinImHttpClient
|
||||
from rpa_engine.douyin_im.session import DouyinImSession
|
||||
from rpa_engine.egress_channels import EgressChannel
|
||||
from rpa_engine.playwright_worker import DouyinWorker
|
||||
|
||||
|
||||
@@ -85,6 +86,7 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
||||
last_send_meta=queued_meta,
|
||||
last_error="credential expired",
|
||||
last_send_needs_refresh=True,
|
||||
last_send_channel_retryable=False,
|
||||
last_request_debug="response status=401",
|
||||
)
|
||||
queued_context = MagicMock()
|
||||
@@ -131,6 +133,65 @@ class SendTextMessageEntryTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(client.last_send_needs_refresh)
|
||||
self.assertEqual(client.last_request_debug, "response status=401")
|
||||
|
||||
async def test_retryable_rejection_switches_channels_serially(self):
|
||||
client = self._make_client(account_id=89)
|
||||
client.session.egress_auto_attempts = 2
|
||||
routes = [
|
||||
EgressChannel("198.51.100.10", "10.0.0.10", "eth0", True),
|
||||
EgressChannel("198.51.100.11", "10.0.0.11", "eth0:1", False),
|
||||
]
|
||||
|
||||
first = SimpleNamespace(
|
||||
send_text_message=AsyncMock(return_value=False),
|
||||
last_send_meta={},
|
||||
last_error="decision=KICK",
|
||||
last_send_needs_refresh=False,
|
||||
last_send_channel_retryable=True,
|
||||
last_request_debug="first route",
|
||||
)
|
||||
second = SimpleNamespace(
|
||||
send_text_message=AsyncMock(return_value=True),
|
||||
last_send_meta={"conv": {"ticket": "ok"}},
|
||||
last_error="",
|
||||
last_send_needs_refresh=False,
|
||||
last_send_channel_retryable=False,
|
||||
last_request_debug="second route",
|
||||
)
|
||||
|
||||
def context_for(value):
|
||||
context = MagicMock()
|
||||
context.__aenter__ = AsyncMock(return_value=value)
|
||||
context.__aexit__ = AsyncMock(return_value=None)
|
||||
return context
|
||||
|
||||
queued_factory = MagicMock(side_effect=[context_for(first), context_for(second)])
|
||||
|
||||
async def execute_submission(account_id, operation, description=""):
|
||||
self.assertEqual(account_id, 89)
|
||||
return await operation()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"rpa_engine.douyin_im.traffic_control.submit_outbound",
|
||||
AsyncMock(side_effect=execute_submission),
|
||||
),
|
||||
patch(
|
||||
"rpa_engine.douyin_im.http_client.resolve_send_channels",
|
||||
AsyncMock(return_value=routes),
|
||||
),
|
||||
patch.object(http_client_module, "DouyinImHttpClient", queued_factory),
|
||||
patch.object(http_client_module.system_logger, "record"),
|
||||
):
|
||||
sent = await client.send_text_message("0:1:10001:20002", "hello")
|
||||
|
||||
self.assertTrue(sent)
|
||||
self.assertEqual(queued_factory.call_count, 2)
|
||||
self.assertEqual(queued_factory.call_args_list[0].kwargs["source_ip"], "10.0.0.10")
|
||||
self.assertEqual(queued_factory.call_args_list[1].kwargs["source_ip"], "10.0.0.11")
|
||||
first.send_text_message.assert_awaited_once()
|
||||
second.send_text_message.assert_awaited_once()
|
||||
self.assertEqual(client.last_request_debug, "second route")
|
||||
|
||||
|
||||
class WorkerLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_start_saves_task_and_stop_waits_until_it_is_done(self):
|
||||
|
||||
Reference in New Issue
Block a user