Files
robotex/tests/unit/test_solve.py
T
protokeyandClaude Opus 5.5 fe509c25c3 feature!: параметр user_agent убран, UA всегда родной; docs/Usage.md
- user_agent в запросе → 422 с подсказкой брать UA из ответа: UA, отличный от реального
  Chrome, расходится с userAgentData и движком, Cloudflare такой браузер не пропускает
  (замер: родной 3/3, Chrome 147 и Windows — 0) (#87)
- профиль хранит только отпечаток (экран, DPR), UA не подменяется (#89)
- docs/Usage.md: практики для клиентов API — тот же UA и прокси, что у браузера, сессии,
  cookies, ошибки и повторы, таймауты, пример на httpx
- README, docs/API.md, docs/Introduction.md; тесты под новый контракт

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:19:08 +04:00

241 lines
7.8 KiB
Python

"""/v1/solve без браузера: движок подменён, проверяются сборка ответа, коды ошибок, слоты, сессии и логи."""
import asyncio
import logging
import uuid
import pytest
from fastapi.testclient import TestClient
import main
from api import actions, profile
class FakeContext:
async def cookies(self):
return [{"name": "cf_clearance", "value": "secret-cookie"}]
class FakePage:
context = FakeContext()
async def screenshot(self, type):
return b"png"
async def inner_html(self, selector):
return "<body>page</body>"
async def evaluate(self, js):
return "UA/engine"
class FakeSession:
"""Вместо AsyncStealthySession: запоминает kwargs и вызывает page_action на FakePage."""
calls: list[dict] = []
error: Exception | None = None
def __init__(self, **kwargs):
FakeSession.calls.append(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def fetch(self, url, page_action):
if FakeSession.error:
raise FakeSession.error
return await page_action(FakePage())
@pytest.fixture
def client(monkeypatch, tmp_path):
FakeSession.calls, FakeSession.error = [], None
monkeypatch.setattr(main, "AsyncStealthySession", FakeSession)
monkeypatch.setattr(main, "USER_DATA_ROOT", tmp_path)
monkeypatch.setattr(main, "_slots", asyncio.Semaphore(2))
return TestClient(main.app)
def fake_execute(result=None, exc=None):
async def execute(*args, **kwargs):
if exc:
raise exc
return result
return execute
def post(client, **body):
return client.post("/v1/solve", json={"url": "https://example.com", "actions": [], **body})
def test_ok(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([{"type": "wait_for", "ok": True, "duration_ms": 5}]))
resp = post(client, html=True, screenshot=True)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert "code" not in data and "error" not in data
assert data["user_agent"] == "UA/engine"
assert data["actions"] == [{"type": "wait_for", "ok": True, "duration_ms": 5}]
assert data["html"] == "<body>page</body>"
assert data["screenshot"] == "cG5n" # base64(b"png")
assert data["duration_ms"] >= 0
assert uuid.UUID(resp.cookies["session_id"])
def test_html_and_screenshot_only_on_request(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([]))
data = post(client).json()
assert "html" not in data and "screenshot" not in data
assert data["actions"] == []
def test_action_failed(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([
{"type": "click", "ok": True, "duration_ms": 1},
{"type": "inner_html", "ok": False, "result": "selector not found: #x", "duration_ms": 1},
]))
data = post(client, screenshot=True).json()
assert data["status"] == "err"
assert data["code"] == "action_failed"
assert data["error"] == "inner_html: selector not found: #x"
assert data["screenshot"]
def test_challenge_failed(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute(exc=actions.ChallengeFailed("cloudflare not passed: timeout")))
data = post(client, screenshot=True).json()
assert data["status"] == "err"
assert data["code"] == "challenge_failed"
assert data["error"] == "cloudflare not passed: timeout"
assert data["actions"] == []
assert data["cookies"] and data["screenshot"]
def test_request_failed(client):
FakeSession.error = RuntimeError("net::ERR_PROXY_CONNECTION_FAILED")
data = post(client).json()
assert data == {
"status": "err", "code": "request_failed", "error": "net::ERR_PROXY_CONNECTION_FAILED",
"actions": [], "duration_ms": data["duration_ms"],
}
def test_no_slots(client, monkeypatch):
monkeypatch.setattr(main, "_slots", asyncio.Semaphore(0))
resp = post(client)
assert resp.status_code == 503
assert resp.headers["retry-after"] == main.RETRY_AFTER
assert resp.json() == {"status": "err", "code": "no_slots", "error": "No slots available"}
assert FakeSession.calls == []
def test_slot_released_after_request(client, monkeypatch):
sem = asyncio.Semaphore(1)
monkeypatch.setattr(main, "_slots", sem)
monkeypatch.setattr(actions, "execute", fake_execute([]))
FakeSession.error = RuntimeError("boom")
for _ in range(3):
assert post(client).status_code == 200
assert not sem.locked()
def test_invalid_request_is_422_without_browser(client):
resp = post(client, actions=[{"type": "nope"}])
assert resp.status_code == 422
assert FakeSession.calls == []
def test_session_cookie_reused(client, monkeypatch, tmp_path):
monkeypatch.setattr(actions, "execute", fake_execute([]))
sid = str(uuid.uuid4())
client.cookies.set("session_id", sid)
resp = post(client)
assert resp.cookies["session_id"] == sid
assert FakeSession.calls[0]["user_data_dir"] == str(tmp_path / sid)
@pytest.mark.parametrize("bad", ["../../etc", "not-a-uuid", "/tmp/x"])
def test_bad_session_cookie_gets_new_session(client, monkeypatch, tmp_path, bad):
monkeypatch.setattr(actions, "execute", fake_execute([]))
client.cookies.set("session_id", bad)
resp = post(client)
sid = resp.cookies["session_id"]
assert sid != bad and uuid.UUID(sid)
assert [p.name for p in tmp_path.iterdir()] == [sid]
def test_user_agent_param_rejected(client):
resp = post(client, user_agent="First/1")
assert resp.status_code == 422
assert "take user_agent from the response" in resp.text
assert FakeSession.calls == []
def test_fingerprint_passed_to_browser_and_ua_untouched(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([]))
post(client)
kwargs = FakeSession.calls[0]
assert "useragent" not in kwargs
assert set(kwargs["additional_args"]) == {"screen", "device_scale_factor"}
def test_cookies_replace_profile_cookies_only_when_passed(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([]))
post(client)
post(client, cookies=[{"name": "a", "value": "1", "url": "https://example.com"}])
post(client, cookies=[])
assert [c["clear_cookies"] for c in FakeSession.calls] == [False, True, True]
def test_proxy_passed_and_geo_resolved(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([]))
seen = {}
async def fake_geo(proxy):
seen["proxy"] = proxy
return {"locale": "de-DE", "timezone_id": "Europe/Berlin"}
monkeypatch.setattr(main, "resolve_geo", fake_geo)
post(client, proxy="http://u:p@host:3128")
kwargs = FakeSession.calls[0]
assert kwargs["proxy"] == {"server": "http://host:3128", "username": "u", "password": "p"}
assert seen["proxy"] == kwargs["proxy"]
assert (kwargs["locale"], kwargs["timezone_id"]) == ("de-DE", "Europe/Berlin")
def test_without_proxy_no_geo(client, monkeypatch):
monkeypatch.setattr(actions, "execute", fake_execute([]))
async def fail_geo(proxy):
raise AssertionError("geo must not be resolved without proxy")
monkeypatch.setattr(main, "resolve_geo", fail_geo)
post(client)
assert FakeSession.calls[0]["proxy"] is None
def test_secrets_not_logged(client, monkeypatch, caplog):
monkeypatch.setattr(actions, "execute", fake_execute([]))
async def no_geo(proxy):
return None
monkeypatch.setattr(main, "resolve_geo", no_geo)
caplog.set_level(logging.DEBUG, logger="main")
post(
client, proxy="http://someuser:TopSecret1@host:3128", captcha="KEYSECRET2", html=True,
cookies=[{"name": "sid", "value": "CookieSecret3", "url": "https://example.com"}],
actions=[{"type": "fill", "selector": "#p", "value": "PassSecret4"}],
)
log = caplog.text
assert "Incoming payload" in log and "Outgoing payload" in log
for secret in ("someuser", "TopSecret1", "KEYSECRET2", "CookieSecret3", "PassSecret4", "secret-cookie", "<body>page"):
assert secret not in log
def test_health(client):
assert client.get("/health").json() == {"status": "ok", "browser_slots": main.BROWSER_SLOTS, "free_slots": 2}