1754 lines
68 KiB
Python
1754 lines
68 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import socket
|
|
import threading
|
|
import unittest
|
|
from collections import deque
|
|
from contextlib import contextmanager, nullcontext
|
|
from importlib import import_module
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
from unittest.mock import Mock, patch
|
|
|
|
import websocket
|
|
|
|
from .platform.douyin import (
|
|
BrowserResponse,
|
|
CDPConnection,
|
|
DouyinBrowser,
|
|
DouyinError,
|
|
DouyinSubscription,
|
|
SubscriptionManager,
|
|
ack_expression,
|
|
action_expression,
|
|
detect_challenge,
|
|
im_expression,
|
|
install_expression,
|
|
is_douyin_content_url,
|
|
is_douyin_share_url,
|
|
is_douyin_url,
|
|
normalize_notice,
|
|
notice_ids,
|
|
wait_expression,
|
|
)
|
|
from .proxy import (
|
|
MemoryProxy,
|
|
ProxyExit,
|
|
ProxyRegistry,
|
|
_copy_until_close,
|
|
_dial_http_proxy,
|
|
_dial_socks4,
|
|
_dial_socks5,
|
|
_is_ipv4,
|
|
_parse_request,
|
|
_read_request,
|
|
_read_status,
|
|
_recv_exact,
|
|
)
|
|
|
|
gateway_module = import_module(f"{__package__}.server.http")
|
|
douyin_module = import_module(f"{__package__}.platform.douyin")
|
|
proxy_module = import_module(f"{__package__}.proxy")
|
|
Gateway = gateway_module.Gateway
|
|
RequestError = gateway_module.RequestError
|
|
decode_generation = gateway_module.decode_generation
|
|
json_bytes = gateway_module.json_bytes
|
|
load_config = gateway_module.load_config
|
|
split_listen_address = gateway_module.split_listen_address
|
|
valid_douyin_url = gateway_module.valid_douyin_url
|
|
valid_douyin_source_url = gateway_module.valid_douyin_source_url
|
|
validate_create = gateway_module.validate_create
|
|
parse_proxy_exit = gateway_module.parse_proxy_exit
|
|
validate_proxy_exit = gateway_module.validate_proxy_exit
|
|
validate_proxy_restore = gateway_module.validate_proxy_restore
|
|
valid_douyin_generation = gateway_module.valid_douyin_generation
|
|
valid_account_key_query = gateway_module.valid_account_key_query
|
|
numeric_cursor = gateway_module.numeric_cursor
|
|
proxy_port = gateway_module.proxy_port
|
|
has_control = gateway_module.has_control
|
|
|
|
|
|
class FakeSocket:
|
|
def __init__(self, messages: list[object]) -> None:
|
|
self.messages = list(messages)
|
|
self.sent: list[bytes] = []
|
|
self.timeout = 0.0
|
|
|
|
def send(self, data: bytes) -> None:
|
|
self.sent.append(data)
|
|
|
|
def recv(self) -> str:
|
|
if not self.messages:
|
|
raise TimeoutError("no more messages")
|
|
return json.dumps(self.messages.pop(0))
|
|
|
|
def settimeout(self, value: float) -> None:
|
|
self.timeout = value
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
|
|
class FakeConnection:
|
|
def __init__(self, values: list[object]) -> None:
|
|
self.values = list(values)
|
|
|
|
def evaluate(self, expression: str) -> object:
|
|
del expression
|
|
if not self.values:
|
|
raise DouyinError("no fake response")
|
|
return self.values.pop(0)
|
|
|
|
|
|
|
|
|
|
class GatewayValidationTests(unittest.TestCase):
|
|
def test_create_and_generation_validation(self) -> None:
|
|
value = {
|
|
"alias": "safe-account",
|
|
"name": "Safe account",
|
|
"profile_id": "safe-account",
|
|
"cmd": ["about:blank"],
|
|
"binding_version": 1,
|
|
"network_exit_id": "",
|
|
"network_exit": {},
|
|
"stopped": True,
|
|
}
|
|
validate_create(value)
|
|
self.assertEqual(value["network_exit"], ProxyExit("", "", 0))
|
|
self.assertEqual(value["network_exit_id"], "")
|
|
self.assertEqual(
|
|
decode_generation(
|
|
{
|
|
"binding_version": 1,
|
|
"runtime_id": "runtime-not-found",
|
|
"network_id": "native-" + "a" * 32,
|
|
},
|
|
False,
|
|
False,
|
|
)["runtime_id"],
|
|
"runtime-not-found",
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_create(
|
|
{**value, "cmd": ["--proxy-server=http://x", "about:blank"]}
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
decode_generation(
|
|
{"binding_version": 1, "runtime_id": 4, "network_id": "n"}, True, True
|
|
)
|
|
|
|
def test_config_and_urls(self) -> None:
|
|
self.assertEqual(split_listen_address(":8081"), ("", 8081))
|
|
self.assertEqual(split_listen_address("[::1]:8081"), ("::1", 8081))
|
|
with self.assertRaises(ValueError):
|
|
split_listen_address("missing-port")
|
|
config = load_config(
|
|
{
|
|
"LISTEN_ADDR": ":8081",
|
|
"BROWSER_PATH": "/bin/true",
|
|
"GATEWAY_TOKEN": "0123456789abcdef",
|
|
}
|
|
)
|
|
self.assertEqual(config["listen"], ("", 8081))
|
|
self.assertEqual(config["browser_path"], "/bin/true")
|
|
self.assertEqual(config["ready_timeout"], 60.0)
|
|
self.assertIsNone(config["external_display"])
|
|
external = load_config(
|
|
{
|
|
"BROWSER_PATH": "/bin/true",
|
|
"GATEWAY_TOKEN": "0123456789abcdef",
|
|
"RUNTIME_EXTERNAL_DISPLAY": "99",
|
|
}
|
|
)
|
|
self.assertEqual(external["external_display"], 99)
|
|
with self.assertRaises(ValueError):
|
|
load_config(
|
|
{
|
|
"GATEWAY_TOKEN": "0123456789abcdef",
|
|
"BROWSER_PATH": "/not/a/browser",
|
|
}
|
|
)
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=2328120603967913"
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&sec_user_id=MS4wLjABAAAA9f_a7k0bzVizLYXlpC7R61EIaqJ8Ordug7yp7AB8fGKuuF8Fzqk5_DM-eutXnPIK"
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=1&count=20"
|
|
)
|
|
)
|
|
self.assertFalse(valid_douyin_url("https://www.douyin.com.evil/"))
|
|
self.assertTrue(is_douyin_url("https://www.douyin.com/video/123"))
|
|
self.assertFalse(is_douyin_url("https://www.douyin.com.evil/video/123"))
|
|
self.assertTrue(is_douyin_content_url("https://www.douyin.com/video/123"))
|
|
self.assertTrue(is_douyin_share_url("https://v.douyin.com/abc123/"))
|
|
self.assertTrue(valid_douyin_source_url("https://v.douyin.com/abc123/"))
|
|
self.assertFalse(is_douyin_share_url("https://v.douyin.com/abc123/#bad"))
|
|
self.assertFalse(is_douyin_share_url("https://www.douyin.com/user/123"))
|
|
self.assertTrue(
|
|
valid_douyin_url(
|
|
"https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=6383&device_platform=webapp&aweme_id=123"
|
|
)
|
|
)
|
|
|
|
|
|
def test_http_routes_and_body_validation(self) -> None:
|
|
handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
|
|
gateway = Mock()
|
|
gateway.list_browsers.return_value = []
|
|
gateway.douyin_identity.return_value = {"uid": "123"}
|
|
gateway.douyin_login_qr.return_value = {
|
|
"content_type": "image/png",
|
|
"body_base64": "cG5n",
|
|
"qr_detected": True,
|
|
}
|
|
gateway.douyin_action.return_value = {"status": "succeeded"}
|
|
gateway.douyin_message_history.return_value = {"status": "succeeded"}
|
|
gateway.poll_douyin_events.return_value = []
|
|
server = Mock()
|
|
server.gateway = gateway
|
|
cast(Any, handler).server = server
|
|
cast(Any, handler).server_as_gateway = lambda: server
|
|
self.assertEqual(handler._route("GET", "/v1/browsers", {}, {}), [])
|
|
self.assertEqual(
|
|
handler._route("POST", "/v1/browsers", {}, {}),
|
|
(201, gateway.create.return_value),
|
|
)
|
|
handler._route(
|
|
"DELETE",
|
|
"/v1/browsers/safe",
|
|
{},
|
|
{"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""},
|
|
)
|
|
gateway.remove.assert_called_once_with(
|
|
"safe", {"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""}
|
|
)
|
|
handler._route("POST", "/v1/browsers/safe/start", {}, {})
|
|
handler._route("POST", "/v1/browsers/safe/stop", {}, {})
|
|
handler._route("POST", "/v1/browsers/safe/proxy", {}, {})
|
|
with self.assertRaises(RequestError):
|
|
handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {})
|
|
handler._route("POST", "/v1/browsers/safe/douyin/get", {}, {})
|
|
handler._route("POST", "/v1/browsers/safe/douyin/identity", {}, {})
|
|
self.assertEqual(
|
|
handler._route("POST", "/v1/browsers/safe/douyin/login-qr", {}, {}),
|
|
gateway.douyin_login_qr.return_value,
|
|
)
|
|
gateway.douyin_login_qr.assert_called_once_with("safe", {})
|
|
handler._route("POST", "/v1/browsers/safe/douyin/action", {}, {})
|
|
self.assertEqual(
|
|
handler._route("POST", "/v1/browsers/safe/douyin/messages", {}, {}),
|
|
{"status": "succeeded"},
|
|
)
|
|
gateway.douyin_message_history.assert_called_once_with("safe", {})
|
|
self.assertEqual(
|
|
handler._route("GET", "/v1/browsers/safe/douyin/events", {}, {}), []
|
|
)
|
|
handler._route("POST", "/v1/browsers/safe/douyin/events", {}, {})
|
|
handler._route("DELETE", "/v1/browsers/safe/douyin/events", {}, {})
|
|
with self.assertRaises(RequestError):
|
|
handler._route("GET", "/v1/unknown", {}, {})
|
|
cast(Any, handler).headers = {"Content-Length": "7"}
|
|
cast(Any, handler).rfile = io.BytesIO(b'{"x":1}')
|
|
self.assertEqual(handler._body(), {"x": 1})
|
|
cast(Any, handler).headers = {}
|
|
with self.assertRaises(RequestError):
|
|
handler._body()
|
|
test_token = "x" * 16
|
|
cast(Any, handler).headers = {"Authorization": f"Bearer {test_token}"}
|
|
server.gateway.token = test_token
|
|
self.assertTrue(handler._authorized())
|
|
handler._respond = Mock()
|
|
handler._handle_exception("/v1", RequestError("bad", 400))
|
|
handler._handle_exception("/v1", ValueError("bad"))
|
|
self.assertEqual(handler._respond.call_count, 2)
|
|
|
|
def test_get_event_route_reads_generation_body(self) -> None:
|
|
handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
|
|
cast(Any, handler).path = "/v1/browsers/safe/douyin/events?wait=1"
|
|
cast(Any, handler).headers = {"Content-Length": "67"}
|
|
cast(Any, handler).rfile = io.BytesIO(
|
|
b'{"binding_version":1,"runtime_id":"runtime","network_id":"network"}'
|
|
)
|
|
cast(Any, handler)._authorized = lambda: True
|
|
cast(Any, handler)._route = Mock(return_value=[])
|
|
cast(Any, handler)._respond = Mock()
|
|
handler._dispatch("GET")
|
|
cast(Any, handler)._route.assert_called_once_with(
|
|
"GET",
|
|
"/v1/browsers/safe/douyin/events",
|
|
{"wait": ["1"]},
|
|
{"binding_version": 1, "runtime_id": "runtime", "network_id": "network"},
|
|
)
|
|
|
|
def test_validation_boundaries(self) -> None:
|
|
self.assertEqual(proxy_port("http://127.0.0.1:1234"), 1234)
|
|
self.assertTrue(has_control("bad\nvalue"))
|
|
exit_value = parse_proxy_exit(
|
|
{"protocol": "http", "host": "proxy", "port": 8080}
|
|
)
|
|
validate_proxy_exit(exit_value)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_exit(
|
|
parse_proxy_exit({"protocol": "ftp", "host": "proxy", "port": 21})
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_exit(ProxyExit("http", "proxy", 0))
|
|
generation = {
|
|
"binding_version": 1,
|
|
"runtime_id": "a" * 64,
|
|
"network_id": "native-" + "b" * 32,
|
|
"network_exit_id": "exit",
|
|
}
|
|
self.assertTrue(valid_douyin_generation(generation))
|
|
self.assertFalse(
|
|
valid_douyin_generation({**generation, "binding_version": True})
|
|
)
|
|
self.assertTrue(valid_account_key_query({"account": ["account"]}, "account"))
|
|
self.assertFalse(valid_account_key_query({"account": ["bad key"]}, "account"))
|
|
self.assertTrue(numeric_cursor(["0"]))
|
|
self.assertFalse(numeric_cursor(["-1"]))
|
|
restore = {
|
|
"binding_version": 1,
|
|
"runtime_id": "a" * 64,
|
|
"network_id": "native-" + "b" * 32,
|
|
"network_exit_id": "exit",
|
|
"network_exit": {"protocol": "http", "host": "proxy", "port": 8080},
|
|
}
|
|
validate_proxy_restore(restore, "safe")
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_restore({**restore, "network_exit_id": ""}, "safe")
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_restore({"network_exit_id": ""}, "safe")
|
|
with self.assertRaises(RequestError):
|
|
validate_create({"alias": "safe", "unknown": True})
|
|
with self.assertRaises(ValueError):
|
|
load_config({"GATEWAY_TOKEN": "short"})
|
|
with self.assertRaises(ValueError):
|
|
load_config(
|
|
{
|
|
"BROWSER_PATH": "/bin/true",
|
|
"GATEWAY_TOKEN": "0123456789abcdef",
|
|
"RUNTIME_EXTERNAL_DISPLAY": "0",
|
|
}
|
|
)
|
|
self.assertFalse(valid_douyin_url("http://www.douyin.com/video/1"))
|
|
self.assertFalse(valid_douyin_url("https://www.douyin.com/unknown"))
|
|
with self.assertRaises(RequestError):
|
|
decode_generation(
|
|
{"binding_version": 1, "runtime_id": "a" * 64}, True, True
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class GatewayBusinessMethodTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.runtime_id = "a" * 64
|
|
self.network_id = "native-" + "b" * 32
|
|
self.generation = {
|
|
"binding_version": 1,
|
|
"runtime_id": self.runtime_id,
|
|
"network_id": self.network_id,
|
|
"network_exit_id": "exit-1",
|
|
}
|
|
self.runtimes = Mock()
|
|
self.runtimes.proxies = Mock()
|
|
self.runtimes.browser_path = "/bin/true"
|
|
self.runtimes.alias_lock.return_value = nullcontext()
|
|
self.runtimes.require_generation.return_value = SimpleNamespace(
|
|
runtime_id=self.runtime_id, network_exit_id="exit-1"
|
|
)
|
|
self.runtimes.endpoint.return_value = "http://127.0.0.1:19001"
|
|
self.runtimes.list_public.return_value = []
|
|
self.browser = Mock()
|
|
self.browser.get.return_value = Mock(status=200, body="body", challenge="")
|
|
self.browser.resolve.return_value = "https://www.douyin.com/video/123"
|
|
self.browser.get_media.return_value = Mock(
|
|
status=200, content_type="video/mp4", body_base64="dm"
|
|
)
|
|
self.browser.identity.return_value = {
|
|
"uid": "12345678901234567890",
|
|
"sec_uid": "sec",
|
|
"unique_id": "name",
|
|
}
|
|
self.browser.login_qr.return_value = Mock(
|
|
content_type="image/png", body_base64="cG5n", qr_detected=True
|
|
)
|
|
self.browser.message_history.return_value = {"status": "succeeded"}
|
|
self.browser.action.return_value = {"status": "succeeded"}
|
|
self.browser.action_ownership.return_value = None
|
|
self.xhs_browser = Mock()
|
|
self.xhs_browser.get.return_value = Mock(status=200, body="body", challenge="")
|
|
self.xhs_browser.post.return_value = Mock(status=200, body="body", challenge="")
|
|
self.xhs_browser.resolve.return_value = "https://www.xiaohongshu.com/explore/abc"
|
|
self.xhs_browser.get_media.return_value = Mock(
|
|
status=200, content_type="video/mp4", body_base64="dm"
|
|
)
|
|
self.xhs_browser.identity.return_value = {"uid": "xhs-user"}
|
|
self.gateway = Gateway(
|
|
self.runtimes,
|
|
"gateway-token-123456",
|
|
"node-a",
|
|
browser=self.browser,
|
|
xiaohongshu_browser=self.xhs_browser,
|
|
)
|
|
self.gateway.subscriptions = Mock()
|
|
self.gateway.subscriptions.start.return_value = {"status": "started"}
|
|
self.gateway.subscriptions.poll.return_value = []
|
|
|
|
def test_list_browsers_excludes_released_journal_rows(self) -> None:
|
|
self.runtimes.list_public.return_value = [
|
|
{"alias": "same", "state": "released"},
|
|
{"alias": "same", "state": "stopped"},
|
|
]
|
|
self.assertEqual(self.gateway.list_browsers(), [{"alias": "same", "state": "stopped"}])
|
|
|
|
def test_lifecycle_info_and_douyin_operations(self) -> None:
|
|
self.runtimes.create.return_value = {"state": "running"}
|
|
self.assertEqual(self.gateway.info()["node_id"], "node-a")
|
|
self.assertEqual(self.gateway.list_browsers(), [])
|
|
self.assertEqual(self.gateway.create({"alias": "safe"}), {"state": "running"})
|
|
runtime_generation = {key: self.generation[key] for key in ("binding_version", "runtime_id", "network_id")}
|
|
self.gateway.change_state("safe", "stop", runtime_generation)
|
|
self.gateway.remove("safe", {**runtime_generation, "purge_profile": True})
|
|
self.gateway.restore_proxy(
|
|
"safe",
|
|
{**self.generation, "network_exit": {"protocol": "http", "host": "proxy", "port": 8080}},
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.get_douyin(
|
|
"safe",
|
|
{**self.generation, "url": "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"},
|
|
)["status"],
|
|
200,
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.resolve_douyin(
|
|
"safe", {**self.generation, "url": "https://v.douyin.com/abc123/"}
|
|
)["url"],
|
|
"https://www.douyin.com/video/123",
|
|
)
|
|
self.browser.resolve.reset_mock()
|
|
self.assertEqual(
|
|
self.gateway.resolve_douyin(
|
|
"safe", {**self.generation, "url": "https://www.douyin.com/video/123"}
|
|
)["url"],
|
|
"https://www.douyin.com/video/123",
|
|
)
|
|
self.browser.resolve.assert_called_once_with(
|
|
"safe", "https://www.douyin.com/video/123"
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.get_douyin_media(
|
|
"safe", {**self.generation, "url": "https://cdn.example/video.mp4"}
|
|
)["content_type"],
|
|
"video/mp4",
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.douyin_identity(
|
|
"safe", {**self.generation, "expected_account_key": "12345678901234567890"}
|
|
)["uid"],
|
|
"12345678901234567890",
|
|
)
|
|
self.assertTrue(
|
|
self.gateway.douyin_login_qr("safe", self.generation)["qr_detected"]
|
|
)
|
|
expected = "12345678901234567890"
|
|
target = "22345678901234567890"
|
|
self.assertEqual(
|
|
self.gateway.douyin_message_history(
|
|
"safe", {**self.generation, "expected_uid": expected, "target_uid": target}
|
|
)["status"],
|
|
"succeeded",
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.douyin_action(
|
|
"safe",
|
|
{
|
|
**self.generation,
|
|
"expected_uid": expected,
|
|
"target_uid": target,
|
|
"action": "follow",
|
|
"operation_id": "operation-1",
|
|
"confirm": True,
|
|
},
|
|
)["status"],
|
|
"succeeded",
|
|
)
|
|
|
|
def test_xhs_and_event_operations(self) -> None:
|
|
page = "https://www.xiaohongshu.com/explore/abc"
|
|
generation = {**self.generation, "network_exit_id": "exit-1"}
|
|
self.assertEqual(
|
|
self.gateway.get_xiaohongshu(
|
|
"safe", {**generation, "url": "https://edith.xiaohongshu.com/api/sns/web/v2/user/me"}
|
|
)["status"],
|
|
200,
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.post_xiaohongshu(
|
|
"safe",
|
|
{**generation, "url": "https://edith.xiaohongshu.com/api/sns/web/v1/feed", "body": {"ok": True}},
|
|
)["status"],
|
|
200,
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.resolve_xiaohongshu("safe", {**generation, "url": page})["url"],
|
|
page,
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.get_xiaohongshu_media("safe", {**generation, "url": page})["status"],
|
|
200,
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.xiaohongshu_identity(
|
|
"safe", {**generation, "expected_account_key": "xhs-user"}
|
|
)["uid"],
|
|
"xhs-user",
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.start_douyin_events(
|
|
"safe", {**self.generation, "expected_uid": "12345678901234567890"}
|
|
),
|
|
{"status": "started"},
|
|
)
|
|
self.assertEqual(
|
|
self.gateway.poll_douyin_events("safe", self.generation, {"limit": ["1"], "wait": ["0"]}),
|
|
[],
|
|
)
|
|
self.gateway.stop_douyin_events("safe", self.generation)
|
|
|
|
def test_http_info_route_and_action_error_ownership(self) -> None:
|
|
handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
|
|
server = Mock()
|
|
server.gateway = self.gateway
|
|
cast(Any, handler).server = server
|
|
cast(Any, handler).server_as_gateway = lambda: server
|
|
self.assertEqual(handler._route("GET", "/v1/info", {}, {}), self.gateway.info())
|
|
cast(Any, handler).path = "/v1/info"
|
|
cast(Any, handler).headers = {"Authorization": "Bearer gateway-token-123456"}
|
|
cast(Any, handler)._respond = Mock()
|
|
handler._dispatch("GET")
|
|
self.assertEqual(handler._respond.call_args.args[0], 200)
|
|
self.gateway._handle_douyin_action_error("safe", "follow", DouyinError("timed out"), "operation-1")
|
|
self.assertEqual(self.gateway._uncertain_actions["safe"], float("inf"))
|
|
self.gateway._release_action_ownership("safe", "operation-1")
|
|
self.assertNotIn("safe", self.gateway._uncertain_actions)
|
|
with self.assertRaises(RequestError):
|
|
self.gateway.post_xiaohongshu(
|
|
"safe", {**self.generation, "url": "https://edith.xiaohongshu.com/api/sns/web/v1/feed", "body": []}
|
|
)
|
|
|
|
|
|
class CDPTests(unittest.TestCase):
|
|
def test_connect_response_headers_are_drained(self) -> None:
|
|
connection = ChunkSocket(
|
|
[b"HTTP/1.1 200 Connection Established\r\n", b"X-Proxy: value\r\n\r\nTLS"]
|
|
)
|
|
self.assertEqual(_read_status(cast(socket.socket, connection)), 200)
|
|
|
|
def test_command_queues_events_for_wait_event(self) -> None:
|
|
socket_ = FakeSocket(
|
|
[
|
|
{
|
|
"method": "Page.frameNavigated",
|
|
"params": {"frame": {"id": "frame-1"}},
|
|
},
|
|
{"id": 1, "result": {}},
|
|
]
|
|
)
|
|
connection = CDPConnection(cast(websocket.WebSocket, socket_))
|
|
self.assertEqual(connection.command("Page.enable"), {})
|
|
event = connection.wait_event(
|
|
"Page.frameNavigated", lambda params: params["frame"]["id"] == "frame-1"
|
|
)
|
|
self.assertEqual(event["method"], "Page.frameNavigated")
|
|
self.assertTrue(socket_.sent)
|
|
|
|
def test_eof_status_is_a_failure(self) -> None:
|
|
class Closed:
|
|
def recv(self, size: int) -> bytes:
|
|
del size
|
|
return b""
|
|
|
|
with self.assertRaises(OSError):
|
|
_read_status(cast(socket.socket, Closed()))
|
|
|
|
def test_expression_markers_and_challenge(self) -> None:
|
|
expression = im_expression({"text": "hello EXPECTED_UID_VALUE"}, "123")
|
|
self.assertIn("hello EXPECTED_UID_VALUE", expression)
|
|
self.assertIn(
|
|
"https://www.douyin.com", action_expression({"action": "like_work"})
|
|
)
|
|
self.assertEqual(detect_challenge(429, "captcha"), "")
|
|
self.assertEqual(detect_challenge(412, ""), "captcha")
|
|
|
|
def test_notification_details_normalize_safe_targets(self) -> None:
|
|
notice = {
|
|
"nid_str": "9007199254740993",
|
|
"user_id": "99491952055",
|
|
"create_time": 1700000000,
|
|
"aweme_id": "123456",
|
|
"comment": {
|
|
"from_user": [{"uid": "7654321"}],
|
|
"comment": {"cid_str": "987654", "user": {"uid": "7654321"}},
|
|
},
|
|
}
|
|
self.assertEqual(
|
|
normalize_notice(notice),
|
|
{
|
|
"event_key": "9007199254740993",
|
|
"event_type": "comment",
|
|
"interactor_uid": "7654321",
|
|
"comment_id": "987654",
|
|
"work_id": "123456",
|
|
"platform_event_at": "2023-11-14T22:13:20+00:00",
|
|
},
|
|
)
|
|
favorite = {"nid_str": "7", "favorite": {"from_user": [{"uid": "1"}]}}
|
|
self.assertIsNone(normalize_notice(favorite))
|
|
|
|
def test_notification_detail_retries_partial_response(self) -> None:
|
|
details = [
|
|
{"nid_str": "1", "user_id": "123"},
|
|
{"nid_str": "2", "user_id": "123"},
|
|
]
|
|
payload = json.dumps({"status_code": 0, "notice_list_v2": details})
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.connection = cast(
|
|
CDPConnection,
|
|
FakeConnection(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"status": 200,
|
|
"body": json.dumps(
|
|
{"status_code": 0, "notice_list_v2": details[:1]}
|
|
),
|
|
}
|
|
),
|
|
json.dumps({"status": 200, "body": payload}),
|
|
]
|
|
),
|
|
)
|
|
subscription._get_connection = lambda: subscription.connection
|
|
with patch.object(douyin_module.time, "sleep"):
|
|
self.assertEqual(len(subscription._details(["1", "2"])), 2)
|
|
|
|
def test_notification_detail_rejects_unexpected_id(self) -> None:
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.connection = cast(
|
|
CDPConnection,
|
|
FakeConnection(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"status": 200,
|
|
"body": json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"notice_list_v2": [
|
|
{"nid_str": "9", "user_id": "123"}
|
|
],
|
|
}
|
|
),
|
|
}
|
|
)
|
|
]
|
|
),
|
|
)
|
|
subscription._get_connection = lambda: subscription.connection
|
|
with self.assertRaises(DouyinError):
|
|
subscription._details(["1"])
|
|
|
|
|
|
class BrowserCDP:
|
|
def __init__(self, values: list[object]) -> None:
|
|
self.values = list(values)
|
|
self.commands: list[tuple[str, dict | None]] = []
|
|
self.events: list[str] = []
|
|
self.expressions: list[str] = []
|
|
self.closed = False
|
|
|
|
def command(self, method: str, params: dict | None = None) -> dict:
|
|
self.commands.append((method, params))
|
|
if method == "Page.navigate":
|
|
return {"frameId": "frame-1"}
|
|
return {}
|
|
|
|
def wait_event(self, method: str, predicate: object, timeout: float = 15.0) -> dict:
|
|
del predicate, timeout
|
|
self.events.append(method)
|
|
return {"method": method}
|
|
|
|
def evaluate(self, expression: str) -> object:
|
|
self.expressions.append(expression)
|
|
if not self.values:
|
|
raise DouyinError("fake CDP value exhausted")
|
|
return self.values.pop(0)
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class FakeHTTPResponse:
|
|
def __init__(self, status: int, body: bytes) -> None:
|
|
self.status = status
|
|
self.body = body
|
|
|
|
def read(self, limit: int = -1) -> bytes:
|
|
del limit
|
|
return self.body
|
|
|
|
|
|
class FakeHTTPConnection:
|
|
def __init__(self, response: FakeHTTPResponse) -> None:
|
|
self.response = response
|
|
self.requested: list[tuple[str, str]] = []
|
|
self.closed = False
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
body: bytes | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
) -> None:
|
|
del body, headers
|
|
self.requested.append((method, path))
|
|
|
|
def getresponse(self) -> FakeHTTPResponse:
|
|
return self.response
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class BrowserTests(unittest.TestCase):
|
|
def _with_connection(self, browser: DouyinBrowser, connection: BrowserCDP) -> None:
|
|
@contextmanager
|
|
def bound(alias: str):
|
|
del alias
|
|
yield connection
|
|
|
|
cast(Any, browser).connection = bound
|
|
|
|
def test_resolve_initializes_anonymous_blank_runtime(self) -> None:
|
|
cdp = Mock()
|
|
cdp.evaluate.side_effect = ["null", True, "https://www.douyin.com/video/123"]
|
|
cdp.command.return_value = {"frameId": "frame-1"}
|
|
cdp.wait_event.return_value = {
|
|
"params": {"frame": {"url": "https://www.douyin.com/video/123"}}
|
|
}
|
|
browser = DouyinBrowser()
|
|
self._with_connection(browser, cast(BrowserCDP, cdp))
|
|
|
|
self.assertEqual(
|
|
browser.resolve("safe", "https://www.douyin.com/video/123"),
|
|
"https://www.douyin.com/video/123",
|
|
)
|
|
self.assertEqual(cdp.evaluate.call_count, 1)
|
|
self.assertEqual(cdp.evaluate.call_args_list[0].args, ("location.origin",))
|
|
self.assertEqual(cdp.command.call_args_list[0].args, ("Page.enable",))
|
|
self.assertEqual(cdp.notify.call_args_list[0].args, ("Page.navigate", {"url": "https://www.douyin.com/video/123"}))
|
|
|
|
def test_browser_fetch_uses_manually_logged_session(self) -> None:
|
|
cdp = BrowserCDP(
|
|
[
|
|
"https://www.douyin.com",
|
|
{"status": 200, "body": "{}", "too_large": False},
|
|
]
|
|
)
|
|
browser = DouyinBrowser()
|
|
self._with_connection(browser, cdp)
|
|
response = browser.get(
|
|
"safe", "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383"
|
|
)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertNotIn("Network.setCookies", [method for method, _ in cdp.commands])
|
|
|
|
def test_browser_fetch_retries_transient_auth_response(self) -> None:
|
|
cdp = BrowserCDP(
|
|
[
|
|
"https://www.douyin.com",
|
|
{"status": 403, "body": "challenge", "too_large": False},
|
|
{"status": 200, "body": "{}", "too_large": False},
|
|
]
|
|
)
|
|
browser = DouyinBrowser()
|
|
self._with_connection(browser, cdp)
|
|
|
|
with patch("browser_gateway.platform.douyin.time.sleep") as sleep:
|
|
response = browser.get(
|
|
"safe",
|
|
"https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383",
|
|
)
|
|
|
|
self.assertEqual(response.status, 200)
|
|
sleep.assert_called_once_with(2)
|
|
|
|
def test_login_qr_captures_a_browser_screen_without_credentials(self) -> None:
|
|
screenshot = base64.b64encode(b"png-bytes" * 3000).decode("ascii")
|
|
cdp = Mock()
|
|
cdp.evaluate.side_effect = [
|
|
True,
|
|
{"origin": "https://www.douyin.com", "qr_detected": True},
|
|
]
|
|
cdp.command.side_effect = [
|
|
{"frameId": "frame-1"},
|
|
{"data": screenshot},
|
|
{"data": screenshot},
|
|
]
|
|
browser = DouyinBrowser()
|
|
self._with_connection(browser, cast(BrowserCDP, cdp))
|
|
with patch.object(douyin_module.time, "sleep"):
|
|
response = browser.login_qr("safe")
|
|
self.assertEqual(response.content_type, "image/png")
|
|
self.assertEqual(response.body_base64, screenshot)
|
|
self.assertTrue(response.qr_detected)
|
|
self.assertEqual(
|
|
[call.args[0] for call in cdp.command.call_args_list],
|
|
[
|
|
"Page.navigate",
|
|
"Page.captureScreenshot",
|
|
"Page.captureScreenshot",
|
|
],
|
|
)
|
|
self.assertFalse(any("cookie" in expression.lower() for expression in cdp.evaluate.call_args.args))
|
|
|
|
def test_connect_selects_configured_target_from_large_mixed_list(self) -> None:
|
|
targets = [
|
|
{"type": "service", "url": "http://127.0.0.1:9222/json"} for _ in range(40)
|
|
]
|
|
targets.extend(
|
|
[
|
|
{
|
|
"id": "other",
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/video/1",
|
|
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1",
|
|
},
|
|
{
|
|
"id": "selected",
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/user/self",
|
|
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/2",
|
|
},
|
|
]
|
|
)
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
patch.object(
|
|
douyin_module.websocket,
|
|
"create_connection",
|
|
return_value=FakeSocket([]),
|
|
),
|
|
):
|
|
connection = DouyinBrowser(
|
|
lambda alias: "http://127.0.0.1:9222", target_id="selected"
|
|
)._connect("safe")
|
|
self.assertIsInstance(connection, CDPConnection)
|
|
|
|
def test_media_download_is_browser_mediated_and_bounded(self) -> None:
|
|
payload = base64.b64encode(b"video-bytes").decode("ascii")
|
|
cdp = BrowserCDP(
|
|
[
|
|
{"url": "https://www.douyin.com/video/123", "readyState": "complete"},
|
|
{"status": 200, "content_type": "video/mp4", "body": payload},
|
|
]
|
|
)
|
|
browser = DouyinBrowser()
|
|
self._with_connection(browser, cdp)
|
|
response = browser.get_media("safe", "https://www.douyin.com/video/123")
|
|
self.assertEqual(response.status, 200)
|
|
self.assertEqual(response.content_type, "video/mp4")
|
|
self.assertEqual(base64.b64decode(response.body_base64), b"video-bytes")
|
|
self.assertIn("Page.navigate", [method for method, _ in cdp.commands])
|
|
self.assertIn("querySelectorAll", cdp.expressions[-1])
|
|
self.assertIn("uuu_265.mp4", cdp.expressions[-1])
|
|
|
|
def test_connect_identity_and_actions(self) -> None:
|
|
target = [
|
|
{
|
|
"type": "page",
|
|
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1",
|
|
}
|
|
]
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
patch.object(
|
|
douyin_module.websocket,
|
|
"create_connection",
|
|
return_value=FakeSocket([]),
|
|
),
|
|
):
|
|
connection = DouyinBrowser(lambda alias: "http://127.0.0.1:9222")._connect(
|
|
"safe"
|
|
)
|
|
self.assertIsInstance(connection, CDPConnection)
|
|
self.assertEqual(http.requested[0], ("GET", "/json/list"))
|
|
expression = install_expression("safe", "123")
|
|
self.assertIn("safe", expression)
|
|
self.assertIn("old?.dispose?.()", expression)
|
|
self.assertEqual(
|
|
notice_ids(
|
|
{
|
|
"service": 20313,
|
|
"payload": json.dumps(
|
|
{"notices": [{"notice_id_str": "1", "effect_groups": [960]}]}
|
|
),
|
|
}
|
|
),
|
|
["1"],
|
|
)
|
|
self.assertEqual(
|
|
notice_ids(
|
|
{
|
|
"service": 20003,
|
|
"payload": json.dumps({"notice_type": 45, "notice_id_str": "2"}),
|
|
}
|
|
),
|
|
["2"],
|
|
)
|
|
|
|
browser = DouyinBrowser()
|
|
cast(Any, browser).identity = lambda alias, expected: {"uid": expected}
|
|
cast(Any, browser)._evaluate = lambda *args: {
|
|
"status": "succeeded",
|
|
"action": "followed",
|
|
}
|
|
preview = browser.action(
|
|
"safe", "123", "dm", "456", text="hello", confirm=False
|
|
)
|
|
self.assertEqual(preview["action"], "preview")
|
|
self.assertEqual(
|
|
browser.action("safe", "123", "like_work", work_id="789", confirm=False)[
|
|
"action"
|
|
],
|
|
"preview",
|
|
)
|
|
with self.assertRaises(DouyinError):
|
|
browser.action("safe", "123", "dm", "456", text=" ", confirm=False)
|
|
with self.assertRaises(DouyinError):
|
|
browser.action("safe", "123", "follow", "bad", confirm=False)
|
|
|
|
def test_browser_queue_is_retained_until_ack(self) -> None:
|
|
wait = wait_expression("alpha")
|
|
self.assertIn("delivered", wait)
|
|
self.assertNotIn("splice(0)", wait)
|
|
ack = ack_expression("__creatorhub_notice_sub_alpha", ["browser-1"])
|
|
self.assertIn("browser-1", ack)
|
|
self.assertIn("__creatorhub_notice_sub_alpha", ack)
|
|
self.assertIn(
|
|
"crypto.randomUUID", douyin_module.install_expression("alpha", "123")
|
|
)
|
|
|
|
def test_listener_start_retries_until_runtime_ready(self) -> None:
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.alias = "safe"
|
|
subscription.uid = "123"
|
|
subscription.stopped = threading.Event()
|
|
subscription.stopped.wait = Mock(return_value=False)
|
|
subscription._open_listener = Mock(
|
|
side_effect=[
|
|
DouyinError("SDK_NOT_READY"),
|
|
(Mock(), "2026-09-14T18:00:00+00:00"),
|
|
]
|
|
)
|
|
with patch.object(douyin_module.time, "monotonic", side_effect=[0.0, 1.0]):
|
|
connection, boundary = subscription._open_listener_until_ready()
|
|
self.assertEqual(boundary, "2026-09-14T18:00:00+00:00")
|
|
self.assertIsNotNone(connection)
|
|
self.assertEqual(subscription._open_listener.call_count, 2)
|
|
subscription.stopped.wait.assert_called_once_with(0.5)
|
|
|
|
def test_subscription_receipts_are_replayed_until_ack(self) -> None:
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.queue = deque()
|
|
subscription.condition = threading.Condition()
|
|
subscription.stopped = threading.Event()
|
|
subscription._put({"kind": "notice", "notice": {"event_key": "1"}})
|
|
first = subscription.poll(10, 0)
|
|
second = subscription.poll(10, 0)
|
|
self.assertEqual(first, second)
|
|
self.assertTrue(first[0]["notice"]["gateway_received_at"].endswith("+00:00"))
|
|
subscription.ack([first[0]["delivery_id"]])
|
|
self.assertEqual(subscription.poll(10, 0), [])
|
|
|
|
def test_subscription_connection_failure_requests_recovery(self) -> None:
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.alias = "safe"
|
|
subscription.browser = Mock()
|
|
subscription.browser._connect.side_effect = DouyinError("browser unavailable")
|
|
subscription._put = Mock()
|
|
subscription._request_recovery = Mock()
|
|
subscription._browser_inflight_lock = threading.Lock()
|
|
subscription._browser_inflight = set()
|
|
subscription._process_push_async({"delivery_id": "delivery-1"}, 0, False)
|
|
subscription._request_recovery.assert_called_once_with()
|
|
|
|
def test_subscription_detail_failure_does_not_discard_siblings(self) -> None:
|
|
bad = json.dumps(
|
|
{
|
|
"status": 200,
|
|
"body": json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"notice_list_v2": [{"nid_str": "9", "user_id": "123"}],
|
|
}
|
|
),
|
|
}
|
|
)
|
|
good = json.dumps(
|
|
{
|
|
"status": 200,
|
|
"body": json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"notice_list_v2": [
|
|
{
|
|
"nid_str": "2",
|
|
"user_id": "123",
|
|
"follow": {"from_user": [{"uid": "7"}]},
|
|
}
|
|
],
|
|
}
|
|
),
|
|
}
|
|
)
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.queue = deque()
|
|
subscription.condition = threading.Condition()
|
|
subscription.stopped = threading.Event()
|
|
subscription.connection = cast(CDPConnection, FakeConnection([bad, bad, good]))
|
|
subscription._get_connection = lambda: subscription.connection
|
|
subscription._handle(
|
|
{
|
|
"kind": "push",
|
|
"service": 20313,
|
|
"payload": json.dumps(
|
|
{
|
|
"notices": [
|
|
{"notice_id_str": "1", "effect_groups": [960]},
|
|
{"notice_id_str": "2", "effect_groups": [960]},
|
|
]
|
|
}
|
|
),
|
|
}
|
|
)
|
|
events = subscription.poll(10, 0)
|
|
self.assertEqual(events[0]["kind"], "error")
|
|
self.assertEqual(events[0]["event_key"], "1")
|
|
self.assertEqual(events[1]["notice"]["event_key"], "2")
|
|
|
|
def test_subscription_manager_and_queue(self) -> None:
|
|
event = {"kind": "open"}
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.uid = "123"
|
|
subscription.queue = deque()
|
|
subscription.condition = __import__("threading").Condition()
|
|
subscription.stopped = __import__("threading").Event()
|
|
subscription._handle(event)
|
|
first = subscription.poll(10, 0)
|
|
self.assertEqual(first[0]["kind"], "open")
|
|
self.assertTrue(first[0]["delivery_id"])
|
|
subscription.queue = deque([{} for _ in range(1000)])
|
|
subscription._put({"kind": "new"})
|
|
self.assertEqual(subscription.queue[0]["kind"], "error")
|
|
subscription.stopped.set()
|
|
overflow = subscription.poll(10, 0)
|
|
self.assertEqual(overflow[0]["kind"], "error")
|
|
self.assertEqual(overflow[0]["reason"], "notification queue overflow")
|
|
self.assertTrue(overflow[0]["delivery_id"])
|
|
self.assertEqual(len(overflow), 10)
|
|
self.assertEqual(len(subscription.queue), 1000)
|
|
subscription.ack([overflow[0]["delivery_id"]])
|
|
self.assertEqual(len(subscription.queue), 999)
|
|
self.assertEqual(subscription.queue[0], {})
|
|
|
|
browser = DouyinBrowser()
|
|
fake = Mock()
|
|
fake.poll.return_value = [{"kind": "notice"}]
|
|
with patch.object(douyin_module, "DouyinSubscription", return_value=fake):
|
|
manager = SubscriptionManager(browser)
|
|
self.assertTrue(manager.start("safe", "123")["connected"])
|
|
self.assertEqual(manager.poll("safe", 1, 0), [{"kind": "notice"}])
|
|
manager.stop("safe")
|
|
manager.close()
|
|
with self.assertRaises(DouyinError):
|
|
manager.poll("safe", 1, 0)
|
|
|
|
|
|
class ProxyTests(unittest.TestCase):
|
|
def test_chunked_request_body_is_decoded_and_forwarded_with_length(self) -> None:
|
|
request = ChunkSocket(
|
|
[
|
|
(
|
|
b"POST http://example.test/a HTTP/1.1\r\nHost: example.test\r\n"
|
|
b"Transfer-Encoding: chunked\r\n\r\n2\r\nab\r\n3;part=x\r\ncde\r\n0\r\nX-Trailer: yes\r\n\r\n"
|
|
)
|
|
]
|
|
)
|
|
head, body = _read_request(cast(socket.socket, request))
|
|
self.assertEqual(body, b"abcde")
|
|
proxy = MemoryProxy.__new__(MemoryProxy)
|
|
proxy.exit = ProxyExit("socks5", "proxy", 1080)
|
|
upstream = ChunkSocket([b""])
|
|
proxy.dial = lambda target, timeout=20.0: cast(socket.socket, upstream)
|
|
with patch.object(proxy_module, "_copy_until_close"):
|
|
proxy.forward_http(
|
|
cast(socket.socket, ChunkSocket([])),
|
|
"POST",
|
|
"http://example.test/a",
|
|
_parse_request(head)[2],
|
|
body,
|
|
)
|
|
sent = upstream.sent[0].decode("iso-8859-1")
|
|
self.assertIn("Content-Length: 5", sent)
|
|
self.assertNotIn("Transfer-Encoding:", sent)
|
|
|
|
def test_registry_generation_and_shutdown(self) -> None:
|
|
registry = ProxyRegistry()
|
|
url, undo = registry.configure(
|
|
"safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network-1"
|
|
)
|
|
port = int(url.rsplit(":", 1)[1])
|
|
self.assertFalse(registry.ready("safe", port, "runtime-1", "network-1"))
|
|
self.assertTrue(registry.bind("safe", 1, url, "runtime-1", "network-1"))
|
|
self.assertTrue(registry.ready("safe", port, "runtime-1", "network-1"))
|
|
self.assertFalse(registry.remove("safe", 1, "runtime-2", "network-1"))
|
|
undo()
|
|
registry.close()
|
|
|
|
def test_memory_proxy_can_close(self) -> None:
|
|
proxy = MemoryProxy(
|
|
"safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network"
|
|
)
|
|
self.assertGreater(proxy.listener.getsockname()[1], 0)
|
|
proxy.close()
|
|
|
|
def test_proxy_request_parser_and_copy_helpers(self) -> None:
|
|
request = ChunkSocket(
|
|
[
|
|
b"POST http://example.test/a HTTP/1.1\r\nContent-Length: 3\r\nHost: example.test\r\n\r\nabc"
|
|
]
|
|
)
|
|
head, body = _read_request(cast(socket.socket, request))
|
|
self.assertEqual(body, b"abc")
|
|
method, target, headers = _parse_request(head)
|
|
self.assertEqual((method, target), ("POST", "http://example.test/a"))
|
|
self.assertEqual(headers[0], ("Content-Length", "3"))
|
|
source = ChunkSocket([b"one", b"", b"ignored"])
|
|
destination = ChunkSocket([])
|
|
_copy_until_close(cast(socket.socket, source), cast(socket.socket, destination))
|
|
self.assertEqual(b"".join(destination.sent), b"one")
|
|
self.assertEqual(
|
|
_recv_exact(cast(socket.socket, ChunkSocket([b"ab", b"cd"])), 4), b"abcd"
|
|
)
|
|
self.assertTrue(_is_ipv4("127.0.0.1"))
|
|
self.assertFalse(_is_ipv4("host.example"))
|
|
with self.assertRaises(ValueError):
|
|
_parse_request(b"BROKEN\r\n\r\n")
|
|
|
|
def test_connect_payload_in_same_read_is_preserved(self) -> None:
|
|
class GreedySocket(ChunkSocket):
|
|
def recv(self, size: int) -> bytes:
|
|
del size
|
|
return self.chunks.pop(0) if self.chunks else b""
|
|
|
|
upstream = GreedySocket([b"HTTP/1.1 200 OK\r\nX-Test: yes\r\n\r\nTLS"])
|
|
with patch.object(proxy_module, "_open_host", return_value=upstream):
|
|
result = _dial_http_proxy(
|
|
ProxyExit("http", "proxy", 8080), "target:443", 1.0
|
|
)
|
|
self.assertEqual(_recv_exact(cast(socket.socket, result), 3), b"TLS")
|
|
result.close()
|
|
|
|
def test_http_and_socks_handshakes(self) -> None:
|
|
http_socket = ChunkSocket([b"HTTP/1.1 200 Connection Established\r\n\r\n"])
|
|
with patch.object(proxy_module, "_open_host", return_value=http_socket):
|
|
result = _dial_http_proxy(
|
|
ProxyExit("http", "proxy", 8080, "u", "c"), "target:443", 1.0
|
|
)
|
|
self.assertIs(result, http_socket)
|
|
self.assertIn(b"Proxy-Authorization: Basic dTpj", http_socket.sent[0])
|
|
|
|
socks4_socket = ChunkSocket([b"\x00\x5a\x00\x00\x00\x00\x00\x00"])
|
|
with patch.object(proxy_module, "_open_host", return_value=socks4_socket):
|
|
self.assertIs(
|
|
_dial_socks4(ProxyExit("socks4", "proxy", 1080), "127.0.0.1:80", 1.0),
|
|
socks4_socket,
|
|
)
|
|
socks5_socket = ChunkSocket(
|
|
[b"\x05\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"]
|
|
)
|
|
with patch.object(proxy_module, "_open_host", return_value=socks5_socket):
|
|
self.assertIs(
|
|
_dial_socks5(ProxyExit("socks5", "proxy", 1080), "127.0.0.1:80", 1.0),
|
|
socks5_socket,
|
|
)
|
|
with self.assertRaises(OSError):
|
|
_dial_socks5(ProxyExit("socks5", "proxy", 1080), "host:0", 1.0)
|
|
|
|
def test_proxy_forward_and_tunnel_paths(self) -> None:
|
|
proxy = MemoryProxy.__new__(MemoryProxy)
|
|
proxy.exit = ProxyExit("socks5", "proxy", 1080)
|
|
upstream = ChunkSocket([b"reply", b""])
|
|
client = ChunkSocket([])
|
|
cast(Any, proxy).dial = lambda target, timeout=20.0: cast(
|
|
socket.socket, upstream
|
|
)
|
|
with patch.object(proxy_module, "_copy_until_close") as copy:
|
|
proxy.forward_http(
|
|
cast(socket.socket, client),
|
|
"GET",
|
|
"http://example.test/path?q=1",
|
|
[("Host", "example.test")],
|
|
b"",
|
|
)
|
|
copy.assert_called_once()
|
|
self.assertIn(b"GET /path?q=1 HTTP/1.1", upstream.sent[0])
|
|
proxy.exit = ProxyExit("http", "proxy", 8080)
|
|
upstream = ChunkSocket([b""])
|
|
cast(Any, proxy).dial = lambda target, timeout=20.0: cast(
|
|
socket.socket, upstream
|
|
)
|
|
with (
|
|
patch.object(proxy_module, "_open_host", return_value=upstream),
|
|
patch.object(proxy_module, "_copy_until_close"),
|
|
):
|
|
proxy.forward_http(
|
|
cast(socket.socket, client), "GET", "http://example.test/", [], b""
|
|
)
|
|
self.assertIn(b"GET http://example.test/ HTTP/1.1", upstream.sent[0])
|
|
|
|
|
|
class ChunkSocket:
|
|
def __init__(self, chunks: list[bytes]) -> None:
|
|
self.chunks = list(chunks)
|
|
self.sent: list[bytes] = []
|
|
|
|
def recv(self, size: int) -> bytes:
|
|
if not self.chunks:
|
|
return b""
|
|
chunk = self.chunks.pop(0)
|
|
if len(chunk) <= size:
|
|
return chunk
|
|
self.chunks.insert(0, chunk[size:])
|
|
return chunk[:size]
|
|
|
|
def sendall(self, data: bytes) -> None:
|
|
self.sent.append(data)
|
|
|
|
def send(self, data: bytes) -> None:
|
|
self.sent.append(data)
|
|
|
|
def settimeout(self, value: float | None) -> None:
|
|
del value
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GatewayLifecycleTests(unittest.TestCase):
|
|
def test_server_tracks_daemon_request_threads_and_timeout(self) -> None:
|
|
server = gateway_module.GatewayHTTPServer(("127.0.0.1", 0), Mock())
|
|
try:
|
|
self.assertTrue(server.daemon_threads)
|
|
self.assertEqual(gateway_module.GatewayHandler.protocol_version, "HTTP/1.1")
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
|
|
|
|
def _input(self, stopped: bool = True) -> dict:
|
|
return {
|
|
"alias": "safe",
|
|
"name": "Safe",
|
|
"profile_id": "safe",
|
|
"cmd": ["about:blank"],
|
|
"binding_version": 1,
|
|
"network_exit_id": "",
|
|
"network_exit": {},
|
|
"stopped": stopped,
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdditionalGatewayCoverageTests(unittest.TestCase):
|
|
def test_douyin_connect_rejects_bad_discovery(self) -> None:
|
|
browser = DouyinBrowser(lambda _: "https://browser:9222")
|
|
with self.assertRaises(DouyinError):
|
|
browser._connect("safe")
|
|
cases = [
|
|
(500, b"{}"),
|
|
(200, b"{}"),
|
|
(200, json.dumps([{"type": "service"}]).encode()),
|
|
(
|
|
200,
|
|
json.dumps(
|
|
[{"type": "page", "url": "https://www.douyin.com/1"}]
|
|
).encode(),
|
|
),
|
|
(
|
|
200,
|
|
json.dumps(
|
|
[
|
|
{
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/1",
|
|
"webSocketDebuggerUrl": "http://browser/devtools/page/1",
|
|
}
|
|
]
|
|
).encode(),
|
|
),
|
|
]
|
|
for status, body in cases:
|
|
with self.subTest(status=status, body=body):
|
|
http = FakeHTTPConnection(FakeHTTPResponse(status, body))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
browser._connect("safe")
|
|
targets = [
|
|
{
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/1",
|
|
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
|
|
},
|
|
{
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/2",
|
|
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/2",
|
|
},
|
|
]
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
browser._connect("safe")
|
|
|
|
def test_douyin_fetch_identity_and_confirmed_actions(self) -> None:
|
|
browser = DouyinBrowser()
|
|
|
|
def bind(connection: BrowserCDP) -> None:
|
|
BrowserTests()._with_connection(browser, connection)
|
|
|
|
for result in (
|
|
{"too_large": True},
|
|
{"status": 302, "body": ""},
|
|
{"status": 200, "body": 1},
|
|
):
|
|
bind(BrowserCDP(["https://www.douyin.com", result]))
|
|
with self.assertRaises(DouyinError):
|
|
browser.get("safe", "https://www.douyin.com/a")
|
|
cast(Any, browser).get = lambda alias, target: BrowserResponse(200, "not-json")
|
|
with self.assertRaises(DouyinError):
|
|
browser.identity("safe")
|
|
cast(Any, browser).get = lambda alias, target: BrowserResponse(
|
|
200, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}})
|
|
)
|
|
self.assertEqual(browser.identity("safe")["uid"], "1")
|
|
cast(Any, browser).get = lambda alias, target: BrowserResponse(
|
|
200,
|
|
json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"extra": {"now": 1789401629000},
|
|
"user": {"uid": "1", "sec_uid": "sec"},
|
|
}
|
|
),
|
|
)
|
|
self.assertTrue(browser.identity("safe")["platform_now"].startswith("2026-"))
|
|
cast(Any, browser).get = lambda alias, target: BrowserResponse(
|
|
403, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}})
|
|
)
|
|
with self.assertRaises(DouyinError):
|
|
browser.identity("safe")
|
|
cast(Any, browser).identity = lambda alias, expected_uid=None: {
|
|
"uid": expected_uid or "1"
|
|
}
|
|
cast(Any, browser)._evaluate = lambda alias, expression: {
|
|
"status": 200,
|
|
"action": "sent",
|
|
}
|
|
bind(BrowserCDP(["https://www.douyin.com", {}]))
|
|
self.assertEqual(
|
|
browser.action("safe", "1", "follow", "2", confirm=True)["action"], "sent"
|
|
)
|
|
bind(BrowserCDP(["https://www.douyin.com", {}]))
|
|
self.assertEqual(
|
|
browser.action("safe", "1", "dm", "2", text="hello", confirm=True)[
|
|
"status"
|
|
],
|
|
200,
|
|
)
|
|
cast(Any, browser)._evaluate = lambda alias, expression: "bad"
|
|
bind(BrowserCDP(["https://www.douyin.com", "bad"]))
|
|
with self.assertRaises(DouyinError):
|
|
browser.action("safe", "1", "follow", "2", confirm=True)
|
|
|
|
def test_gateway_and_proxy_validation_edges(self) -> None:
|
|
self.assertEqual(
|
|
parse_proxy_exit({"protocol": "http", "host": "", "port": 80}).host, ""
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_exit(
|
|
parse_proxy_exit({"protocol": "http", "host": "", "port": 80})
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_exit(
|
|
parse_proxy_exit({"protocol": "http", "host": "proxy", "port": 0})
|
|
)
|
|
with self.assertRaises(RequestError):
|
|
validate_proxy_restore(
|
|
{
|
|
"binding_version": 1,
|
|
"runtime_id": "a" * 64,
|
|
"network_id": "n",
|
|
"network_exit_id": "x",
|
|
},
|
|
"bad alias",
|
|
)
|
|
self.assertFalse(valid_account_key_query({"key": ["bad key"]}, "key"))
|
|
|
|
|
|
|
|
|
|
def test_cdp_error_and_proxy_auth_paths(self) -> None:
|
|
socket_ = FakeSocket([{"id": 1, "result": {"result": {"value": {"ok": True}}}}])
|
|
connection = CDPConnection(cast(websocket.WebSocket, socket_))
|
|
self.assertEqual(connection.evaluate("1"), {"ok": True})
|
|
socket_ = FakeSocket([{"id": 1, "result": {"result": {}}}])
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1")
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(
|
|
cast(websocket.WebSocket, FakeSocket([{"id": 1, "error": {}}]))
|
|
).command("Page.enable")
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(
|
|
cast(websocket.WebSocket, FakeSocket([{"id": 1, "result": []}]))
|
|
).command("Page.enable")
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(cast(websocket.WebSocket, FakeSocket([]))).wait_event(
|
|
"Page.loadEventFired", lambda _: True, timeout=0.01
|
|
)
|
|
socket_ = FakeSocket([{"id": 1, "result": {"exceptionDetails": {}}}])
|
|
with self.assertRaises(DouyinError):
|
|
CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1")
|
|
target = [
|
|
{
|
|
"type": "page",
|
|
"url": "https://www.douyin.com/1",
|
|
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
|
|
}
|
|
]
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
patch.object(
|
|
douyin_module.websocket,
|
|
"create_connection",
|
|
side_effect=OSError("down"),
|
|
),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
DouyinBrowser(lambda _: "http://browser:9222")._connect("safe")
|
|
socks5 = ChunkSocket(
|
|
[b"\x05\x02", b"\x01\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"]
|
|
)
|
|
with patch.object(proxy_module, "_open_host", return_value=socks5):
|
|
self.assertIs(
|
|
_dial_socks5(
|
|
ProxyExit("socks5", "proxy", 1080, "u", "p"), "127.0.0.1:80", 1.0
|
|
),
|
|
socks5,
|
|
)
|
|
|
|
def test_direct_message_notice_and_uncertain_post_contract(self) -> None:
|
|
notice = normalize_notice(
|
|
{
|
|
"dm": {
|
|
"message_id": "123456",
|
|
"from_user": {"uid": "456789"},
|
|
"text": "hello",
|
|
},
|
|
"create_time": 1700000000,
|
|
}
|
|
)
|
|
self.assertIsNotNone(notice)
|
|
assert notice is not None
|
|
self.assertEqual(notice["event_type"], "dm")
|
|
self.assertEqual(notice["interactor_uid"], "456789")
|
|
self.assertEqual(notice["message_type"], "text")
|
|
self.assertEqual(notice["message_text"], "hello")
|
|
non_text = normalize_notice(
|
|
{
|
|
"dm": {
|
|
"message_id": "123457",
|
|
"from_user": {"uid": "456789"},
|
|
"message_type": "image",
|
|
"image_url": "https://example.invalid/image",
|
|
}
|
|
}
|
|
)
|
|
assert non_text is not None
|
|
self.assertEqual(non_text["message_type"], "image")
|
|
self.assertEqual(non_text["message_text"], "")
|
|
script = action_expression({"alias": "safe", "action": "follow", "target": "2"})
|
|
self.assertIn("POST_UNCERTAIN", script)
|
|
self.assertIn("BUSINESS_REJECTED", script)
|
|
|
|
def test_cdp_timeout_terminates_page_evaluation(self) -> None:
|
|
class TimeoutSocket(FakeSocket):
|
|
def __init__(self) -> None:
|
|
super().__init__([])
|
|
self.receives = 0
|
|
|
|
def recv(self) -> str:
|
|
self.receives += 1
|
|
if self.receives == 1:
|
|
raise TimeoutError("deadline")
|
|
return json.dumps({"id": 2, "result": {}})
|
|
|
|
socket_ = TimeoutSocket()
|
|
connection = CDPConnection(cast(websocket.WebSocket, socket_))
|
|
with (
|
|
patch.object(douyin_module, "CONTROL_TIMEOUT", 0.001),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
connection.command("Runtime.evaluate")
|
|
sent = [json.loads(item) for item in socket_.sent]
|
|
self.assertEqual(sent[1]["method"], "Runtime.terminateExecution")
|
|
|
|
|
|
def test_configured_target_and_media_wait_edge_cases(self) -> None:
|
|
targets = [
|
|
{
|
|
"type": "page",
|
|
"id": "other",
|
|
"url": "https://www.douyin.com/1",
|
|
"webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
|
|
}
|
|
]
|
|
http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
|
|
with (
|
|
patch.object(
|
|
douyin_module.http.client, "HTTPConnection", return_value=http
|
|
),
|
|
self.assertRaisesRegex(DouyinError, "configured browser page target"),
|
|
):
|
|
DouyinBrowser(
|
|
lambda _: "http://browser:9222", target_id="missing"
|
|
)._connect("safe")
|
|
|
|
cdp = Mock()
|
|
cdp.command.return_value = {"frameId": "frame-1"}
|
|
cdp.evaluate.side_effect = [
|
|
DouyinError("page is still loading"),
|
|
{"url": "https://www.douyin.com/video/123", "readyState": "complete"},
|
|
{"error": "media_source_unavailable"},
|
|
]
|
|
browser = DouyinBrowser()
|
|
BrowserTests()._with_connection(browser, cast(BrowserCDP, cdp))
|
|
with (
|
|
patch.object(douyin_module.time, "monotonic", side_effect=[0.0, 1.0, 2.0]),
|
|
patch.object(douyin_module.time, "sleep"),
|
|
self.assertRaisesRegex(DouyinError, "media_source_unavailable"),
|
|
):
|
|
browser.get_media("safe", "https://www.douyin.com/video/123")
|
|
|
|
def test_identity_and_message_history_reject_invalid_values(self) -> None:
|
|
browser = DouyinBrowser()
|
|
for server_now in (0, float("nan")):
|
|
cast(Any, browser).get = lambda alias, target, now=server_now: (
|
|
BrowserResponse(
|
|
200,
|
|
json.dumps(
|
|
{
|
|
"status_code": 0,
|
|
"extra": {"now": now},
|
|
"user": {"uid": "123", "sec_uid": "sec"},
|
|
}
|
|
),
|
|
)
|
|
)
|
|
with (
|
|
self.subTest(server_now=server_now),
|
|
self.assertRaisesRegex(DouyinError, "platform clock"),
|
|
):
|
|
browser.identity("safe")
|
|
|
|
identity = Mock(return_value={"uid": "123"})
|
|
evaluate = Mock(return_value={"status": "succeeded", "messages": []})
|
|
cast(Any, browser).identity = identity
|
|
cast(Any, browser)._evaluate = evaluate
|
|
result = browser.message_history("safe", "123", "456", 20)
|
|
self.assertEqual(result["status"], "succeeded")
|
|
identity.assert_called_once_with("safe", "123")
|
|
self.assertIn("456", evaluate.call_args.args[1])
|
|
for expected_uid, target_uid, limit in (
|
|
("bad", "456", 20),
|
|
("123", "bad", 20),
|
|
("123", "456", 0),
|
|
("123", "456", 201),
|
|
):
|
|
with (
|
|
self.subTest(
|
|
expected_uid=expected_uid, target_uid=target_uid, limit=limit
|
|
),
|
|
self.assertRaises(DouyinError),
|
|
):
|
|
browser.message_history("safe", expected_uid, target_uid, limit)
|
|
evaluate.return_value = "bad"
|
|
with self.assertRaisesRegex(DouyinError, "message history response"):
|
|
browser.message_history("safe", "123", "456")
|
|
|
|
def test_listener_open_and_recovery_boundaries(self) -> None:
|
|
browser = Mock()
|
|
browser.identity.return_value = {
|
|
"uid": "123",
|
|
"platform_now": "2026-09-14T00:00:00+00:00",
|
|
}
|
|
connection = BrowserCDP([json.dumps({"connected": True})])
|
|
browser._connect.return_value = connection
|
|
subscription = DouyinSubscription.__new__(DouyinSubscription)
|
|
subscription.browser = browser
|
|
subscription.alias = "safe"
|
|
subscription.uid = "123"
|
|
subscription.key = "__creatorhub_notice_sub_safe"
|
|
opened, boundary_at = subscription._open_listener()
|
|
self.assertIs(opened, connection)
|
|
self.assertEqual(boundary_at, "2026-09-14T00:00:00+00:00")
|
|
|
|
constructor_connection = BrowserCDP([json.dumps({"connected": True})])
|
|
browser._connect.return_value = constructor_connection
|
|
with patch.object(threading.Thread, "start"):
|
|
constructed = DouyinSubscription(browser, "safe", "123")
|
|
self.assertEqual(constructed.queue[0]["kind"], "baseline")
|
|
constructed.stopped.set()
|
|
constructed._detail_pool.shutdown(wait=True, cancel_futures=True)
|
|
|
|
broken = BrowserCDP([json.dumps({"connected": True}), "{}"])
|
|
browser._connect.return_value = broken
|
|
browser.identity.return_value = {"uid": "123"}
|
|
with self.assertRaisesRegex(DouyinError, "event boundary"):
|
|
subscription._open_listener()
|
|
self.assertTrue(broken.closed)
|
|
|
|
old = Mock()
|
|
old.evaluate.side_effect = DouyinError("stale listener")
|
|
new = Mock()
|
|
subscription.connection = old
|
|
subscription._connection_lock = threading.RLock()
|
|
subscription._epoch = 0
|
|
subscription._initial_boundary_pending = False
|
|
subscription.stopped = threading.Event()
|
|
subscription.stopped.wait = Mock(return_value=False)
|
|
subscription._open_listener = Mock(
|
|
return_value=(new, "2026-09-14T00:00:01+00:00")
|
|
)
|
|
subscription._put = Mock()
|
|
subscription._recover()
|
|
old.close.assert_called_once_with()
|
|
self.assertIs(subscription.connection, new)
|
|
self.assertEqual(subscription._epoch, 1)
|
|
self.assertEqual(
|
|
[call.args[0]["kind"] for call in subscription._put.call_args_list],
|
|
["reconnected", "baseline"],
|
|
)
|
|
|
|
|
|
|
|
|
|
class DouyinReleaseRemediationTests(unittest.TestCase):
|
|
def test_browser_action_marker_is_persisted_in_profile_storage(self) -> None:
|
|
browser = DouyinBrowser()
|
|
browser._evaluate = Mock(side_effect=[{"runtime_id": "old"}, True, True])
|
|
self.assertEqual(browser.action_ownership("safe"), {"runtime_id": "old"})
|
|
browser.set_action_ownership(
|
|
"safe", {"runtime_id": "new", "operation_id": "op-1"}
|
|
)
|
|
browser.clear_action_ownership("safe", "op-1")
|
|
self.assertEqual(browser._evaluate.call_count, 3)
|
|
self.assertIn(
|
|
"__creatorhub_action_ownership_v1",
|
|
browser._evaluate.call_args_list[1].args[1],
|
|
)
|
|
|
|
def test_gateway_action_claim_rejects_marker_from_any_runtime(self) -> None:
|
|
gateway = Gateway.__new__(Gateway)
|
|
gateway._action_ownership_lock = threading.Lock()
|
|
gateway._uncertain_actions = {}
|
|
gateway.browser = Mock()
|
|
gateway.browser.action_ownership.return_value = {"runtime_id": "old-runtime"}
|
|
with self.assertRaises(RequestError):
|
|
gateway._claim_action("safe", "new-runtime", "op-1")
|
|
gateway.browser.clear_action_ownership.assert_not_called()
|
|
gateway.browser.set_action_ownership.assert_not_called()
|
|
|
|
def test_notice_keeps_raw_gateway_receipt_time(self) -> None:
|
|
notice = normalize_notice(
|
|
{"dm": {"message_id": "1", "from_user": {"uid": "2"}, "text": "hi"}},
|
|
"2026-09-15T00:00:00+00:00",
|
|
)
|
|
assert notice is not None
|
|
self.assertEqual(notice["gateway_received_at"], "2026-09-15T00:00:00+00:00")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|