1769 lines
69 KiB
Python
1769 lines
69 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import errno
|
||
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,
|
||
ORIGIN_URL,
|
||
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")
|
||
create_server = gateway_module.create_server
|
||
format_listen_address = gateway_module.format_listen_address
|
||
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_create_server_reports_conflicting_listen_address(self) -> None:
|
||
gateway = Gateway.__new__(Gateway)
|
||
with patch.object(
|
||
gateway_module,
|
||
"GatewayHTTPServer",
|
||
side_effect=OSError(errno.EADDRINUSE, "Address already in use"),
|
||
):
|
||
with self.assertRaises(SystemExit) as ctx:
|
||
create_server({"listen": ("", 8081)}, gateway)
|
||
message = str(ctx.exception)
|
||
self.assertIn("0.0.0.0:8081", message)
|
||
self.assertIn("address already in use", message)
|
||
self.assertIn("LISTEN_ADDR", message)
|
||
with patch.object(
|
||
gateway_module,
|
||
"GatewayHTTPServer",
|
||
side_effect=OSError(errno.EACCES, "Permission denied"),
|
||
):
|
||
with self.assertRaises(OSError) as denied:
|
||
create_server({"listen": ("127.0.0.1", 8081)}, gateway)
|
||
self.assertEqual(denied.exception.errno, errno.EACCES)
|
||
|
||
def test_format_listen_address(self) -> None:
|
||
self.assertEqual(format_listen_address(("", 8081)), "0.0.0.0:8081")
|
||
self.assertEqual(format_listen_address(("127.0.0.1", 28187)), "127.0.0.1:28187")
|
||
self.assertEqual(format_listen_address(("::1", 8081)), "[::1]:8081")
|
||
|
||
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.gateway = Gateway(
|
||
self.runtimes,
|
||
"gateway-token-123456",
|
||
"node-a",
|
||
browser=self.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_event_operations(self) -> None:
|
||
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)
|
||
|
||
|
||
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 notify(self, method: str, params: dict | None = None) -> None:
|
||
self.commands.append((method, params))
|
||
|
||
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_navigates_blank_runtime_to_origin(self) -> None:
|
||
# 匿名浏览器从 about:blank 启动(origin 为 null):get 前必须先导航到抖音首页,
|
||
# 并等待访客 cookie(ttwid)就绪,否则 API fetch 返回空响应。
|
||
cdp = BrowserCDP(
|
||
[
|
||
"null",
|
||
"https://www.douyin.com",
|
||
"ttwid=1%7Cabc; s_v_web_id=x",
|
||
"ttwid=1%7Cabc; s_v_web_id=x",
|
||
"ttwid=1%7Cabc; s_v_web_id=x",
|
||
{"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.assertIn(("Page.navigate", {"url": ORIGIN_URL}), cdp.commands)
|
||
self.assertEqual(cdp.events, [])
|
||
|
||
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()
|