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

933 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import os
import sys
import unittest
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, 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 import account_profile as account_profile_module
from rpa_engine.credential import build_im_session_from_storage
from rpa_engine.playwright_worker import DouyinWorker
class SecUserIdGuardTests(unittest.IsolatedAsyncioTestCase):
async def test_current_profile_uid_overrides_browser_runtime_uid(self):
now = datetime.utcnow()
row = SimpleNamespace(
im_session_data=None,
cookie_updated_at=now,
uid="2609567359568155",
profile_updated_at=now,
)
result = MagicMock()
result.one_or_none.return_value = row
db = SimpleNamespace(
execute=AsyncMock(return_value=result),
close=AsyncMock(),
)
worker = DouyinWorker(account_id=300, login_mode="im_direct")
worker.get_db = AsyncMock(return_value=db)
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
storage = {
"cookies": [{"name": "sessionid", "value": "test-session"}],
"my_uid": 7678285795559818786,
"origins": [
{
"localStorage": [
{
"name": "web_runtime_security_uid",
"value": "7678285795559818786",
}
]
}
],
}
session = await worker._build_im_session_from_storage(storage)
self.assertEqual(session.my_uid, 2609567359568155)
# device_id 必须与权威 UID 同步(protobuf 发送时 device_id 优先取
# session.device_id,残留的浏览器采集值会导致 device_id != my_uid -> KICK
self.assertEqual(session.device_id, "2609567359568155")
self.assertTrue(session.uid_verified)
db.close.assert_awaited_once()
async def test_stale_profile_uid_does_not_override_new_cookie(self):
now = datetime.utcnow()
row = SimpleNamespace(
im_session_data=None,
cookie_updated_at=now,
uid="2609567359568155",
profile_updated_at=now - timedelta(seconds=1),
)
result = MagicMock()
result.one_or_none.return_value = row
db = SimpleNamespace(
execute=AsyncMock(return_value=result),
close=AsyncMock(),
)
worker = DouyinWorker(account_id=300, login_mode="im_direct")
worker.get_db = AsyncMock(return_value=db)
worker._load_raw_user_agent = AsyncMock(return_value="test-agent")
session = await worker._build_im_session_from_storage(
{
"cookies": [{"name": "sessionid", "value": "test-session"}],
"my_uid": 7678285795559818786,
}
)
self.assertEqual(session.my_uid, 7678285795559818786)
self.assertFalse(session.uid_verified)
def test_persisted_verified_uid_wins_in_generic_session_builder(self):
saved = DouyinImSession(
cookies={"sessionid": "test-session"},
my_uid=2609567359568155,
device_id="7678285795559818786",
uid_verified=True,
)
session = build_im_session_from_storage(
{
"cookies": [{"name": "sessionid", "value": "test-session"}],
"my_uid": 7678285795559818786,
"origins": [
{
"localStorage": [
{
"name": "web_runtime_security_uid",
"value": "7678285795559818786",
}
]
}
],
},
json.dumps(saved.to_dict()),
)
self.assertEqual(session.my_uid, 2609567359568155)
# device_id 同步为已核验 UID,避免凭证残留设备号导致 device_id != my_uid
self.assertEqual(session.device_id, "2609567359568155")
self.assertTrue(session.uid_verified)
async def test_im_direct_missing_sec_user_id_continues_im_hosting(self):
worker = DouyinWorker(account_id=301, login_mode="im_direct")
worker._best_effort_sec_user_id = AsyncMock(return_value="")
worker._load_user_agent = AsyncMock(return_value="test-agent")
im_session = DouyinImSession(
cookies={"sessionid": "test-session"},
my_uid=30101,
)
worker._build_im_session_from_storage = AsyncMock(return_value=im_session)
worker._persist_im_session = AsyncMock()
worker._run_im_direct_service = AsyncMock()
with patch(
"rpa_engine.playwright_worker.validate_im_session",
new=AsyncMock(return_value=(True, "ready")),
) as validate:
started, reason = await worker._try_cookie_only_im_start(
{"cookies": [{"name": "sessionid", "value": "test-session"}]}
)
self.assertTrue(started)
self.assertEqual(reason, "")
worker._best_effort_sec_user_id.assert_awaited_once_with(
refresh_if_missing=True,
refresh_if_stale=True,
)
worker._build_im_session_from_storage.assert_awaited_once()
validate.assert_awaited_once_with(im_session)
worker._persist_im_session.assert_awaited_once()
worker._run_im_direct_service.assert_awaited_once_with(im_session)
async def test_disabled_follow_welcome_does_not_require_identity(self):
worker = DouyinWorker(account_id=302, login_mode="im_direct")
worker.is_running = True
worker._im_service = SimpleNamespace(_running=True)
worker._best_effort_sec_user_id = AsyncMock()
worker._refresh_follow_welcome_config = AsyncMock(
return_value=(False, "", "")
)
worker.get_db = AsyncMock()
await worker.follow_welcome_tick()
worker._refresh_follow_welcome_config.assert_awaited_once_with()
worker._best_effort_sec_user_id.assert_not_awaited()
worker.get_db.assert_not_awaited()
async def test_cached_disabled_follow_setting_skips_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._best_effort_sec_user_id = AsyncMock(return_value="")
await worker.follow_welcome_tick()
worker._refresh_follow_welcome_config.assert_awaited_once_with()
worker._best_effort_sec_user_id.assert_not_awaited()
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=" ")
worker._refresh_sec_user_id = AsyncMock(return_value="\t")
worker._stop_for_missing_sec_user_id = AsyncMock()
accepted = await worker._require_sec_user_id(
"直连启动前",
refresh_if_missing=True,
)
self.assertFalse(accepted)
worker._load_sec_user_id.assert_awaited()
worker._refresh_sec_user_id.assert_awaited_once_with()
worker._stop_for_missing_sec_user_id.assert_awaited_once_with("直连启动前")
async def test_existing_sec_user_id_does_not_refresh_or_stop(self):
worker = DouyinWorker(account_id=304, login_mode="im_direct")
worker._load_sec_user_id = AsyncMock(return_value="MS4wLjABAAAA-valid")
worker._refresh_sec_user_id = AsyncMock()
worker._stop_for_missing_sec_user_id = AsyncMock()
accepted = await worker._require_sec_user_id(
"运行中",
refresh_if_missing=True,
)
self.assertTrue(accepted)
worker._load_sec_user_id.assert_awaited_once_with()
worker._refresh_sec_user_id.assert_not_awaited()
worker._stop_for_missing_sec_user_id.assert_not_awaited()
async def test_identity_read_error_does_not_claim_field_is_missing(self):
worker = DouyinWorker(account_id=307, login_mode="im_direct")
worker._load_sec_user_id = AsyncMock(
side_effect=RuntimeError("database temporarily unavailable")
)
worker._refresh_sec_user_id = AsyncMock()
worker._stop_for_missing_sec_user_id = AsyncMock()
with self.assertRaisesRegex(RuntimeError, "database temporarily unavailable"):
await worker._require_sec_user_id(
"托管运行中",
refresh_if_missing=True,
)
worker._refresh_sec_user_id.assert_not_awaited()
worker._stop_for_missing_sec_user_id.assert_not_awaited()
async def test_unknown_profile_refresh_error_does_not_stop_as_missing(self):
worker = DouyinWorker(account_id=308, login_mode="im_direct")
worker._load_sec_user_id = AsyncMock(return_value="")
worker._refresh_sec_user_id = AsyncMock(
side_effect=RuntimeError("暂时无法核验 sec_user_id,请稍后重试")
)
worker._stop_for_missing_sec_user_id = AsyncMock()
with self.assertRaisesRegex(RuntimeError, "暂时无法核验"):
await worker._require_sec_user_id(
"启动托管时",
refresh_if_missing=True,
)
worker._refresh_sec_user_id.assert_awaited_once_with()
worker._stop_for_missing_sec_user_id.assert_not_awaited()
async def test_stale_cookie_forces_refresh_instead_of_using_cached_identity(self):
worker = DouyinWorker(account_id=309, login_mode="im_direct")
worker._load_sec_user_id = AsyncMock(return_value="cached-sec-user-id")
worker._sec_user_id_is_stale = AsyncMock(return_value=True)
worker._refresh_sec_user_id = AsyncMock(return_value="fresh-sec-user-id")
worker._stop_for_missing_sec_user_id = AsyncMock()
resolved = await worker._require_sec_user_id(
"启动托管时",
refresh_if_missing=True,
refresh_if_stale=True,
)
self.assertEqual(resolved, "fresh-sec-user-id")
worker._sec_user_id_is_stale.assert_awaited_once_with()
worker._refresh_sec_user_id.assert_awaited_once_with()
worker._stop_for_missing_sec_user_id.assert_not_awaited()
async def test_profile_helper_uses_uid_fallback_for_sec_user_id(self):
fetch_detail = AsyncMock(
return_value={
"uid": "123456789",
"nickname": "fallback-user",
"sec_user_id": "",
"fetched": True,
"message": "",
}
)
run_in_thread = AsyncMock(return_value=("MS4wLjABAAAA-fallback", True))
with (
patch.object(
account_profile_module,
"fetch_douyin_profile_detail",
fetch_detail,
),
patch.object(
account_profile_module.asyncio,
"to_thread",
run_in_thread,
),
):
detail = await account_profile_module.fetch_douyin_profile_detail_with_sec_user_id(
"cookie-json",
"test-agent",
)
self.assertEqual(detail["sec_user_id"], "MS4wLjABAAAA-fallback")
self.assertEqual(detail["sec_user_id_status"], "found")
fetch_detail.assert_awaited_once_with("cookie-json", "test-agent")
run_in_thread.assert_awaited_once_with(
account_profile_module._resolve_sec_user_id_by_uid_sync,
"cookie-json",
"test-agent",
"123456789",
)
async def test_profile_transport_failure_remains_unknown_not_missing(self):
fetch_detail = AsyncMock(
return_value={
"uid": "",
"sec_user_id": "",
"fetched": False,
"message": "profile request timed out",
}
)
run_in_thread = AsyncMock()
with (
patch.object(
account_profile_module,
"fetch_douyin_profile_detail",
fetch_detail,
),
patch.object(
account_profile_module.asyncio,
"to_thread",
run_in_thread,
),
):
detail = await account_profile_module.fetch_douyin_profile_detail_with_sec_user_id(
"cookie-json",
"test-agent",
)
self.assertEqual(detail["sec_user_id_status"], "unknown")
run_in_thread.assert_not_awaited()
async def test_confirmed_empty_uid_fallback_is_missing(self):
fetch_detail = AsyncMock(
return_value={
"uid": "987654321",
"sec_user_id": "",
"fetched": True,
"message": "",
}
)
run_in_thread = AsyncMock(return_value=("", True))
with (
patch.object(
account_profile_module,
"fetch_douyin_profile_detail",
fetch_detail,
),
patch.object(
account_profile_module.asyncio,
"to_thread",
run_in_thread,
),
):
detail = await account_profile_module.fetch_douyin_profile_detail_with_sec_user_id(
"cookie-json",
"test-agent",
)
self.assertEqual(detail["sec_user_id"], "")
self.assertEqual(detail["sec_user_id_status"], "missing")
run_in_thread.assert_awaited_once()
async def test_stop_for_missing_sec_user_id_is_offline_and_idempotent(self):
worker = DouyinWorker(account_id=310, login_mode="im_direct")
worker.is_running = True
worker.stopping = False
service = SimpleNamespace(_running=True)
worker._im_service = service
worker.update_account_status = AsyncMock()
with patch(
"rpa_engine.playwright_worker.system_logger.record"
) as record_system_log:
await worker._stop_for_missing_sec_user_id("托管运行中")
await worker._stop_for_missing_sec_user_id("重复检查")
self.assertTrue(worker.stopping)
self.assertFalse(worker.is_running)
self.assertFalse(service._running)
worker.update_account_status.assert_awaited_once()
status_call = worker.update_account_status.await_args
self.assertEqual(status_call.args[0], "offline")
self.assertIn("sec_user_id", status_call.kwargs["error_msg"])
self.assertIn("托管已自动退出", status_call.kwargs["error_msg"])
record_system_log.assert_called_once()
def test_profile_payload_uid_outranks_query_user_and_cookie_uid(self):
"""资料接口的 UID 必须压过 query/user 的 user_uid 和 cookie 兜底。
实测同一个 Cookiequery/user 返回 user_uid=938334054809296
而账号资料 UID 是 2609567359568155。以前前者先占位,导致账号卡片
一直显示「用户938334054809296」,反查 sec_user_id 也永远失败。
"""
auth = SimpleNamespace(
cookie={},
msToken="test-ms-token",
get_uid=lambda: "938334054809296",
)
payloads = [
# 1) query/user:只有 user_uid,属于弱兜底
{"status_code": 0, "user_uid": "938334054809296"},
# 2) user/profile/self:权威账号资料
{
"status_code": 0,
"user": {
"uid": "2609567359568155",
"nickname": "凤的心",
"sec_uid": "MS4wLjABAAAA-real-sec-user-id",
"unique_id": "39688250979",
"aweme_count": 50,
"follower_count": 154,
},
},
]
responses = [SimpleNamespace(json=lambda value=value: value) for value in payloads]
with (
patch.object(
account_profile_module, "_build_auth",
return_value=(auth, "test-agent"),
),
patch.object(
account_profile_module.requests, "get", side_effect=responses,
),
patch.object(
account_profile_module, "generate_a_bogus", return_value="a-bogus",
),
patch.object(
account_profile_module, "generate_webid", return_value="web-id",
),
patch.object(
account_profile_module, "_requests_proxies", return_value=None,
),
):
detail = account_profile_module.fetch_douyin_profile_detail_sync(
"cookie-json", "test-agent",
)
self.assertEqual(detail["uid"], "2609567359568155")
self.assertEqual(detail["nickname"], "凤的心")
self.assertTrue(detail["fetched"])
async def test_sync_writes_one_identity_to_both_account_and_profile(self):
"""卡片与详细资料必须来自同一次抓取,不能各抓一次抓出两个身份。"""
detail = {
"uid": "2609567359568155",
"nickname": "凤的心",
"avatar_url": "https://example.test/avatar.png",
"unique_id": "39688250979",
"signature": "",
"sec_user_id": "MS4wLjABAAAA-real-sec-user-id",
"sec_user_id_status": "found",
"video_count": 50,
"follower_count": 154,
"following_count": 162,
"total_favorited": 612,
"favoriting_count": 0,
"fetched": True,
"message": "",
}
account = SimpleNamespace(
id=1,
username="用户938334054809296",
douyin_uid="938334054809296",
avatar_url=None,
user_agent="test-agent",
)
profile = SimpleNamespace(
account_id=1, uid=None, nickname=None, avatar_url=None,
unique_id=None, signature=None, sec_user_id=None,
follower_count=None, following_count=None, total_favorited=None,
favoriting_count=None, video_count=None, synced_at=None,
sync_message=None,
)
profile_result = MagicMock()
profile_result.scalar_one_or_none.return_value = profile
username_result = MagicMock()
username_result.scalar_one_or_none.return_value = None
db = SimpleNamespace(
execute=AsyncMock(side_effect=lambda stmt: (
username_result if "accounts.username" in str(stmt).lower()
or "username" in str(stmt).lower() else profile_result
)),
add=MagicMock(),
commit=AsyncMock(),
refresh=AsyncMock(),
)
with (
patch.object(
account_profile_module,
"fetch_douyin_profile_detail_with_sec_user_id",
new=AsyncMock(return_value=detail),
),
patch.object(
account_profile_module,
"fetch_douyin_user_videos",
new=AsyncMock(return_value={"videos": [], "message": "无作品"}),
),
patch.object(
account_profile_module,
"fetch_douyin_profile",
new=AsyncMock(side_effect=AssertionError("must not re-fetch")),
),
patch.object(
account_profile_module,
"load_account_profile_from_db",
new=AsyncMock(return_value={}),
),
):
await account_profile_module.sync_account_profile_to_db(
db, account, "cookie-json",
)
# 同一个身份同时写进 accounts 行和 account_profile_details 行
self.assertEqual(account.douyin_uid, "2609567359568155")
self.assertEqual(account.username, "凤的心")
self.assertEqual(account.avatar_url, "https://example.test/avatar.png")
self.assertEqual(profile.uid, "2609567359568155")
self.assertEqual(profile.nickname, "凤的心")
async def test_cookie_uid_without_valid_profile_payload_stays_unknown(self):
auth = SimpleNamespace(
cookie={},
msToken="test-ms-token",
get_uid=lambda: "cookie-only-uid",
)
payloads = [
{"status_code": 0, "data": {"status": "ok"}},
{"status_code": 0, "message": "success"},
{"status_code": 0, "data": {}},
]
responses = [SimpleNamespace(json=lambda value=value: value) for value in payloads]
with (
patch.object(
account_profile_module,
"_build_auth",
return_value=(auth, "test-agent"),
),
patch.object(
account_profile_module.requests,
"get",
side_effect=responses,
) as request_get,
patch.object(
account_profile_module,
"generate_a_bogus",
return_value="a-bogus",
),
patch.object(
account_profile_module,
"generate_webid",
return_value="web-id",
),
patch.object(
account_profile_module,
"_requests_proxies",
return_value=None,
),
):
raw_detail = account_profile_module.fetch_douyin_profile_detail_sync(
"cookie-json",
"test-agent",
)
self.assertEqual(request_get.call_count, 3)
self.assertEqual(raw_detail["uid"], "cookie-only-uid")
self.assertFalse(raw_detail["fetched"])
self.assertFalse(raw_detail["profile_response_valid"])
with patch.object(
account_profile_module,
"fetch_douyin_profile_detail",
new=AsyncMock(return_value=raw_detail),
):
checked = await account_profile_module.fetch_douyin_profile_detail_with_sec_user_id(
"cookie-json",
"test-agent",
)
self.assertEqual(checked["sec_user_id_status"], "unknown")
async def test_invalid_uid_fallback_payloads_stay_unknown(self):
auth = SimpleNamespace(cookie={}, msToken="test-ms-token")
cases = (
(
"nonzero-status",
{
"status_code": 10007,
"data": {
"user": {
"uid": "fallback-uid",
"sec_uid": "must-not-be-used",
}
},
},
),
(
"no-user-node",
{"status_code": 0, "data": {"status": "ok"}},
),
)
for label, payload in cases:
with self.subTest(label=label):
with (
patch.object(
account_profile_module,
"_build_auth",
return_value=(auth, "test-agent"),
),
patch.object(
account_profile_module,
"_douyin_get_json",
return_value=payload,
),
patch.object(
account_profile_module,
"generate_webid",
return_value="web-id",
),
):
resolved, request_completed = (
account_profile_module._resolve_sec_user_id_by_uid_sync(
"cookie-json",
"test-agent",
"fallback-uid",
)
)
self.assertEqual(resolved, "")
self.assertFalse(request_completed)
fetch_detail = AsyncMock(
return_value={
"uid": "fallback-uid",
"sec_user_id": "",
"fetched": True,
"message": "",
}
)
run_in_thread = AsyncMock(
return_value=(resolved, request_completed)
)
with (
patch.object(
account_profile_module,
"fetch_douyin_profile_detail",
fetch_detail,
),
patch.object(
account_profile_module.asyncio,
"to_thread",
run_in_thread,
),
):
checked = await account_profile_module.fetch_douyin_profile_detail_with_sec_user_id(
"cookie-json",
"test-agent",
)
self.assertEqual(checked["sec_user_id_status"], "unknown")
async def test_unknown_profile_sync_preserves_cached_identity_and_videos(self):
old_synced_at = object()
profile = SimpleNamespace(
sec_user_id="cached-sec-user-id",
synced_at=old_synced_at,
sync_message="previous message",
)
account = SimpleNamespace(
id=411,
user_agent="test-agent",
douyin_uid="cached-uid",
username="cached-user",
avatar_url="cached-avatar",
)
scalar_result = SimpleNamespace(scalar_one_or_none=lambda: profile)
db = SimpleNamespace(
execute=AsyncMock(return_value=scalar_result),
commit=AsyncMock(),
add=MagicMock(),
)
unknown_detail = {
"uid": "cookie-uid",
"sec_user_id": "",
"sec_user_id_status": "unknown",
"fetched": False,
"message": "profile endpoint temporarily unavailable",
}
cached_response = {
"account_id": 411,
"sec_user_id": "cached-sec-user-id",
"synced_at": "cached-time",
"videos": [{"aweme_id": "cached-video"}],
}
fetch_videos = AsyncMock()
apply_profile = AsyncMock()
load_cached = AsyncMock(return_value=dict(cached_response))
with (
patch.object(
account_profile_module,
"fetch_douyin_profile_detail_with_sec_user_id",
new=AsyncMock(return_value=unknown_detail),
),
patch.object(
account_profile_module,
"fetch_douyin_user_videos",
fetch_videos,
),
patch.object(
account_profile_module,
"apply_profile_to_account",
apply_profile,
),
patch.object(
account_profile_module,
"load_account_profile_from_db",
load_cached,
),
):
result = await account_profile_module.sync_account_profile_to_db(
db,
account,
"cookie-json",
)
self.assertEqual(profile.sec_user_id, "cached-sec-user-id")
self.assertIs(profile.synced_at, old_synced_at)
fetch_videos.assert_not_awaited()
apply_profile.assert_not_awaited()
load_cached.assert_awaited_once_with(db, account)
self.assertEqual(db.execute.await_count, 1)
self.assertNotIn("delete", str(db.execute.await_args.args[0]).lower())
self.assertEqual(result["videos"], cached_response["videos"])
self.assertEqual(
result["message"],
"profile endpoint temporarily unavailable",
)
async def test_browser_login_missing_sec_user_id_still_starts_im(self):
worker = DouyinWorker(account_id=305, login_mode="browser")
worker.is_running = True
events: list[str] = []
async def reject_identity(*_args, **_kwargs):
events.append("require-sec-user-id")
return ""
worker._load_user_agent = AsyncMock(return_value="test-agent")
worker._probe_existing_login = AsyncMock(return_value=True)
worker._finalize_login_session = AsyncMock()
worker._best_effort_sec_user_id = AsyncMock(side_effect=reject_identity)
worker._setup_im_network_listener = AsyncMock()
worker._navigate_to_message_center = AsyncMock()
worker._harvest_im_credentials = AsyncMock()
worker._persist_cookies = AsyncMock()
im_session = DouyinImSession(
cookies={"sessionid": "test-session"},
my_uid=30501,
)
worker._build_im_session = AsyncMock(return_value=im_session)
worker._persist_im_session = AsyncMock()
worker._run_im_direct_service = AsyncMock()
worker._close_browser_only = AsyncMock()
page = SimpleNamespace()
context = SimpleNamespace(
add_init_script=AsyncMock(),
new_page=AsyncMock(return_value=page),
)
browser = SimpleNamespace(new_context=AsyncMock(return_value=context))
playwright = SimpleNamespace()
playwright_starter = SimpleNamespace(
start=AsyncMock(return_value=playwright),
)
@asynccontextmanager
async def browser_slot(account_id: int, description: str):
self.assertEqual(account_id, 305)
self.assertEqual(description, "browser credential harvest")
events.append("browser-acquired")
try:
yield
finally:
events.append("browser-released")
controller = SimpleNamespace(browser_slot=browser_slot)
with (
patch(
"rpa_engine.playwright_worker.async_playwright",
return_value=playwright_starter,
),
patch(
"rpa_engine.playwright_worker._launch_chromium",
new=AsyncMock(return_value=browser),
),
patch(
"rpa_engine.playwright_worker.get_traffic_controller",
return_value=controller,
),
patch(
"rpa_engine.playwright_worker.validate_im_session",
new=AsyncMock(return_value=(True, "ready")),
),
):
await worker._run_browser_im_flow(
storage_state=None,
cookie_info={
"cookie_valid": False,
"has_sessionid": False,
"reason": "no saved login",
},
)
worker._finalize_login_session.assert_awaited_once_with()
worker._best_effort_sec_user_id.assert_awaited_once()
require_call = worker._best_effort_sec_user_id.await_args
self.assertTrue(require_call.kwargs["force_refresh"])
self.assertEqual(
events,
["browser-acquired", "browser-released", "require-sec-user-id"],
)
worker._setup_im_network_listener.assert_awaited_once_with()
worker._navigate_to_message_center.assert_awaited_once_with()
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
worker._persist_cookies.assert_awaited_once_with()
worker._build_im_session.assert_awaited_once_with()
# sec_user_id 只服务关注欢迎语;缺它不能阻断私信托管,
# 否则浏览器登录后账号立刻下线、永远不会自动回复。
worker._persist_im_session.assert_awaited_once()
worker._run_im_direct_service.assert_awaited_once_with(im_session)
worker._close_browser_only.assert_awaited_once_with()
async def test_browser_login_with_sec_user_id_continues_to_im(self):
worker = DouyinWorker(account_id=306, login_mode="browser")
worker.is_running = True
worker._load_user_agent = AsyncMock(return_value="test-agent")
worker._probe_existing_login = AsyncMock(return_value=True)
worker._finalize_login_session = AsyncMock()
worker._best_effort_sec_user_id = AsyncMock(return_value="sec-uid-306")
worker._setup_im_network_listener = AsyncMock()
worker._navigate_to_message_center = AsyncMock()
worker._harvest_im_credentials = AsyncMock()
worker._persist_cookies = AsyncMock()
im_session = DouyinImSession(
cookies={"sessionid": "test-session"},
my_uid=30601,
)
worker._build_im_session = AsyncMock(return_value=im_session)
worker._persist_im_session = AsyncMock()
worker._run_im_direct_service = AsyncMock()
worker._close_browser_only = AsyncMock()
page = SimpleNamespace()
context = SimpleNamespace(
add_init_script=AsyncMock(),
new_page=AsyncMock(return_value=page),
)
browser = SimpleNamespace(new_context=AsyncMock(return_value=context))
playwright_starter = SimpleNamespace(
start=AsyncMock(return_value=SimpleNamespace()),
)
@asynccontextmanager
async def browser_slot(_account_id: int, _description: str):
yield
controller = SimpleNamespace(browser_slot=browser_slot)
with (
patch(
"rpa_engine.playwright_worker.async_playwright",
return_value=playwright_starter,
),
patch(
"rpa_engine.playwright_worker._launch_chromium",
new=AsyncMock(return_value=browser),
),
patch(
"rpa_engine.playwright_worker.get_traffic_controller",
return_value=controller,
),
patch(
"rpa_engine.playwright_worker.validate_im_session",
new=AsyncMock(return_value=(True, "ready")),
),
):
await worker._run_browser_im_flow(
storage_state=None,
cookie_info={
"cookie_valid": False,
"has_sessionid": False,
"reason": "no saved login",
},
)
worker._best_effort_sec_user_id.assert_awaited_once()
self.assertTrue(
worker._best_effort_sec_user_id.await_args.kwargs["force_refresh"]
)
worker._setup_im_network_listener.assert_awaited_once_with()
worker._navigate_to_message_center.assert_awaited_once_with()
worker._harvest_im_credentials.assert_awaited_once_with(timeout=25)
worker._build_im_session.assert_awaited_once_with()
worker._persist_im_session.assert_awaited_once_with(
im_session,
status="online",
clear_error=True,
)
worker._run_im_direct_service.assert_awaited_once_with(im_session)
worker._close_browser_only.assert_awaited()
if __name__ == "__main__":
unittest.main()