Files
donutbrowser/sdk/python/tests/test_requests.py
T
2026-09-09 10:09:14 +04:00

722 lines
20 KiB
Python

"""Every client method sends exactly the request the app documents.
The table below is the whole public surface. Each row names a method, the
arguments to call it with, and the request that must appear on the wire: the
verb, the concrete path, the query string and the JSON body. ``operation`` is
the path template the app publishes, which ties this file to
``donutbrowser.coverage.OPERATIONS`` and, through it, to ``sdk/api-paths.json``.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Tuple
import pytest
from fake_donut import FakeDonut
from donutbrowser import DonutClient
from donutbrowser.coverage import OPERATIONS
Case = Tuple[
str, # client method
Tuple[Any, ...], # positional arguments
Dict[str, Any], # keyword arguments
str, # expected verb
str, # expected concrete path
Optional[Dict[str, Any]], # expected JSON body, or None for no body
Dict[str, str], # expected query string
str, # operation template, as published by the app
]
LOCATOR = {"role": "button", "name": "Sign in"}
CASES: List[Case] = [
# -- profiles ----------------------------------------------------------
("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"),
("get_profile", ("p1",), {}, "GET", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"create_profile",
(),
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
"POST",
"/v1/profiles",
{"name": "Shopper", "browser": "wayfern", "tags": ["eu"], "ephemeral": True},
{},
"/v1/profiles",
),
(
"create_profile",
(),
{"name": "Bare", "browser": "wayfern"},
"POST",
"/v1/profiles",
{"name": "Bare", "browser": "wayfern"},
{},
"/v1/profiles",
),
(
"update_profile",
("p1",),
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
"PUT",
"/v1/profiles/p1",
{"name": "Renamed", "proxy_id": "", "clear_on_close": False},
{},
"/v1/profiles/{id}",
),
("delete_profile", ("p1",), {}, "DELETE", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"),
(
"run_profile",
("p1",),
{"url": "https://example.com", "headless": True},
"POST",
"/v1/profiles/p1/run",
{"url": "https://example.com", "headless": True},
{},
"/v1/profiles/{id}/run",
),
(
"run_profile_remote",
("p1",),
{"url": "https://example.com"},
"POST",
"/v1/profiles/p1/run-remote",
{"url": "https://example.com"},
{},
"/v1/profiles/{id}/run-remote",
),
(
"set_profile_cloud_sync",
("p1",),
{"mode": "Regular"},
"POST",
"/v1/profiles/p1/cloud-sync",
{"mode": "Regular"},
{},
"/v1/profiles/{id}/cloud-sync",
),
(
"open_url",
("p1", "https://example.com/page"),
{},
"POST",
"/v1/profiles/p1/open-url",
{"url": "https://example.com/page"},
{},
"/v1/profiles/{id}/open-url",
),
(
"kill_profile",
("p1",),
{},
"POST",
"/v1/profiles/p1/kill",
None,
{},
"/v1/profiles/{id}/kill",
),
(
"batch_run_profiles",
(["p1", "p2"],),
{"headless": False},
"POST",
"/v1/profiles/batch/run",
{"profile_ids": ["p1", "p2"], "headless": False},
{},
"/v1/profiles/batch/run",
),
(
"batch_stop_profiles",
(["p1", "p2"],),
{},
"POST",
"/v1/profiles/batch/stop",
{"profile_ids": ["p1", "p2"]},
{},
"/v1/profiles/batch/stop",
),
(
"distribute_proxies",
([{"profile_id": "p1", "proxy_id": "x1"}, {"profile_id": "p2", "proxy_id": "x2"}],),
{},
"POST",
"/v1/profiles/distribute-proxies",
{
"pairs": [
{"profile_id": "p1", "proxy_id": "x1"},
{"profile_id": "p2", "proxy_id": "x2"},
]
},
{},
"/v1/profiles/distribute-proxies",
),
(
"detect_import_profiles",
(),
{"folder": "/Users/x/Chrome"},
"GET",
"/v1/profiles/import/detect",
None,
{"folder": "/Users/x/Chrome"},
"/v1/profiles/import/detect",
),
(
"detect_import_profiles",
(),
{},
"GET",
"/v1/profiles/import/detect",
None,
{},
"/v1/profiles/import/detect",
),
(
"import_profiles",
([{"source_path": "/tmp/src", "new_profile_name": "Imported"}],),
{"duplicate_strategy": "skip"},
"POST",
"/v1/profiles/import",
{
"items": [{"source_path": "/tmp/src", "new_profile_name": "Imported"}],
"duplicate_strategy": "skip",
},
{},
"/v1/profiles/import",
),
(
"import_profile_cookies",
("p1",),
{"content": "[]"},
"POST",
"/v1/profiles/p1/cookies/import",
{"content": "[]"},
{},
"/v1/profiles/{id}/cookies/import",
),
# -- agent -------------------------------------------------------------
(
"agent_perceive",
("p1",),
{"viewport_only": True, "max_bytes": 2048},
"POST",
"/v1/profiles/p1/agent/perceive",
{"max_bytes": 2048, "viewport_only": True},
{},
"/v1/profiles/{id}/agent/perceive",
),
(
"agent_perceive",
("p1",),
{},
"POST",
"/v1/profiles/p1/agent/perceive",
{},
{},
"/v1/profiles/{id}/agent/perceive",
),
(
"agent_resolve_locator",
("p1",),
{"locator": LOCATOR, "candidate_limit": 5},
"POST",
"/v1/profiles/p1/agent/resolve-locator",
{"locator": LOCATOR, "candidate_limit": 5},
{},
"/v1/profiles/{id}/agent/resolve-locator",
),
(
"agent_click",
("p1",),
{"locator": LOCATOR, "button": "right", "click_count": 2},
"POST",
"/v1/profiles/p1/agent/click",
{"locator": LOCATOR, "button": "right", "click_count": 2},
{},
"/v1/profiles/{id}/agent/click",
),
(
"agent_type",
("p1",),
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
"POST",
"/v1/profiles/p1/agent/type",
{"locator": LOCATOR, "text": "hello", "clear_first": False, "wpm": 55.0},
{},
"/v1/profiles/{id}/agent/type",
),
(
"agent_extract",
("p1",),
{
"container": {"role": "listitem"},
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
"max_pages": 3,
},
"POST",
"/v1/profiles/p1/agent/extract",
{
"container": {"role": "listitem"},
"field_map": [{"key": "title", "locator": {"role": "heading"}, "source": "text"}],
"max_pages": 3,
},
{},
"/v1/profiles/{id}/agent/extract",
),
(
"agent_pick",
("p1",),
{"timeout_ms": 15000},
"POST",
"/v1/profiles/p1/agent/pick",
{"timeout_ms": 15000},
{},
"/v1/profiles/{id}/agent/pick",
),
# -- remote sessions ---------------------------------------------------
(
"list_remote_sessions",
(),
{},
"GET",
"/v1/remote-sessions",
None,
{},
"/v1/remote-sessions",
),
(
"get_remote_session",
("s1",),
{},
"GET",
"/v1/remote-sessions/s1",
None,
{},
"/v1/remote-sessions/{id}",
),
(
"stop_remote_session",
("s1",),
{},
"DELETE",
"/v1/remote-sessions/s1",
None,
{},
"/v1/remote-sessions/{id}",
),
("get_remote_hours", (), {}, "GET", "/v1/remote-hours", None, {}, "/v1/remote-hours"),
# -- cookie bot --------------------------------------------------------
(
"list_cookie_bot_schedules",
(),
{"scope": "team"},
"GET",
"/v1/cookie-bot/schedules",
None,
{"scope": "team"},
"/v1/cookie-bot/schedules",
),
(
"get_cookie_bot_schedule",
("p1",),
{},
"GET",
"/v1/cookie-bot/schedules/p1",
None,
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"set_cookie_bot_schedule",
("p1",),
{
"enabled": True,
"run_at_minute": 120,
"days_mask": 31,
"timezone": "Europe/Berlin",
"preset": "steady",
"max_minutes": 45,
"sites": ["https://example.com"],
"acknowledge_conflict": True,
},
"PUT",
"/v1/cookie-bot/schedules/p1",
{
"enabled": True,
"run_at_minute": 120,
"days_mask": 31,
"timezone": "Europe/Berlin",
"preset": "steady",
"max_minutes": 45,
"sites": ["https://example.com"],
"acknowledge_conflict": True,
},
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"delete_cookie_bot_schedule",
("p1",),
{},
"DELETE",
"/v1/cookie-bot/schedules/p1",
None,
{},
"/v1/cookie-bot/schedules/{profile_id}",
),
(
"get_cookie_bot_conflicts",
("p1",),
{"run_at_minute": 90, "timezone": "UTC", "days_mask": 7},
"GET",
"/v1/cookie-bot/conflicts",
None,
{"profile_id": "p1", "run_at_minute": "90", "timezone": "UTC", "days_mask": "7"},
"/v1/cookie-bot/conflicts",
),
(
"list_cookie_bot_runs",
(),
{"profile_id": "p1", "limit": 10, "before": "cursor-1"},
"GET",
"/v1/cookie-bot/runs",
None,
{"profile_id": "p1", "limit": "10", "before": "cursor-1"},
"/v1/cookie-bot/runs",
),
(
"start_cookie_bot_run",
(),
{"profile_id": "p1", "max_minutes": 30},
"POST",
"/v1/cookie-bot/runs",
{"profile_id": "p1", "max_minutes": 30},
{},
"/v1/cookie-bot/runs",
),
(
"cancel_cookie_bot_run",
("r1",),
{},
"DELETE",
"/v1/cookie-bot/runs/r1",
None,
{},
"/v1/cookie-bot/runs/{run_id}",
),
(
"list_cookie_bot_presets",
(),
{},
"GET",
"/v1/cookie-bot/presets",
None,
{},
"/v1/cookie-bot/presets",
),
(
"get_cookie_bot_usage",
(),
{"period": "2026-08"},
"GET",
"/v1/cookie-bot/usage",
None,
{"period": "2026-08"},
"/v1/cookie-bot/usage",
),
# -- groups and tags ---------------------------------------------------
("list_groups", (), {}, "GET", "/v1/groups", None, {}, "/v1/groups"),
("get_group", ("g1",), {}, "GET", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("create_group", (), {"name": "Retail"}, "POST", "/v1/groups", {"name": "Retail"}, {}, "/v1/groups"),
(
"update_group",
("g1",),
{"name": "Retail EU"},
"PUT",
"/v1/groups/g1",
{"name": "Retail EU"},
{},
"/v1/groups/{id}",
),
("delete_group", ("g1",), {}, "DELETE", "/v1/groups/g1", None, {}, "/v1/groups/{id}"),
("list_tags", (), {}, "GET", "/v1/tags", None, {}, "/v1/tags"),
# -- proxies -----------------------------------------------------------
("list_proxies", (), {}, "GET", "/v1/proxies", None, {}, "/v1/proxies"),
("get_proxy", ("x1",), {}, "GET", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"create_proxy",
(),
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
"POST",
"/v1/proxies",
{"name": "EU", "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}},
{},
"/v1/proxies",
),
(
"update_proxy",
("x1",),
{"name": "EU 2"},
"PUT",
"/v1/proxies/x1",
{"name": "EU 2"},
{},
"/v1/proxies/{id}",
),
("delete_proxy", ("x1",), {}, "DELETE", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"),
(
"import_proxies",
(),
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
"POST",
"/v1/proxies/import",
{"format": "txt", "content": "h:1:u:p", "name_prefix": "EU"},
{},
"/v1/proxies/import",
),
# -- vpns --------------------------------------------------------------
("list_vpns", (), {}, "GET", "/v1/vpns", None, {}, "/v1/vpns"),
("get_vpn", ("v1",), {}, "GET", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
("export_vpn", ("v1",), {}, "GET", "/v1/vpns/v1/export", None, {}, "/v1/vpns/{id}/export"),
(
"import_vpn",
(),
{"content": "[Interface]", "filename": "eu.conf"},
"POST",
"/v1/vpns/import",
{"content": "[Interface]", "filename": "eu.conf"},
{},
"/v1/vpns/import",
),
(
"create_vpn",
(),
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
"POST",
"/v1/vpns",
{"name": "EU", "vpn_type": "WireGuard", "config_data": "[Interface]"},
{},
"/v1/vpns",
),
(
"update_vpn",
("v1",),
{"name": "EU 2"},
"PUT",
"/v1/vpns/v1",
{"name": "EU 2"},
{},
"/v1/vpns/{id}",
),
("delete_vpn", ("v1",), {}, "DELETE", "/v1/vpns/v1", None, {}, "/v1/vpns/{id}"),
# -- extensions --------------------------------------------------------
("list_extensions", (), {}, "GET", "/v1/extensions", None, {}, "/v1/extensions"),
("get_extension", ("e1",), {}, "GET", "/v1/extensions/e1", None, {}, "/v1/extensions/{id}"),
(
"create_extension",
(),
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
"POST",
"/v1/extensions",
{"name": "Blocker", "file_name": "b.crx", "file_data_base64": "AAAA"},
{},
"/v1/extensions",
),
(
"update_extension",
("e1",),
{"name": "Blocker 2", "link": True},
"PUT",
"/v1/extensions/e1",
{"name": "Blocker 2", "link": True},
{},
"/v1/extensions/{id}",
),
(
"delete_extension",
("e1",),
{},
"DELETE",
"/v1/extensions/e1",
None,
{},
"/v1/extensions/{id}",
),
(
"list_extension_groups",
(),
{},
"GET",
"/v1/extension-groups",
None,
{},
"/v1/extension-groups",
),
(
"get_extension_group",
("eg1",),
{},
"GET",
"/v1/extension-groups/eg1",
None,
{},
"/v1/extension-groups/{id}",
),
(
"create_extension_group",
(),
{"name": "Adblock set"},
"POST",
"/v1/extension-groups",
{"name": "Adblock set"},
{},
"/v1/extension-groups",
),
(
"update_extension_group",
("eg1",),
{"extension_ids": ["e1", "e2"]},
"PUT",
"/v1/extension-groups/eg1",
{"extension_ids": ["e1", "e2"]},
{},
"/v1/extension-groups/{id}",
),
(
"delete_extension_group",
("eg1",),
{},
"DELETE",
"/v1/extension-groups/eg1",
None,
{},
"/v1/extension-groups/{id}",
),
(
"add_extension_to_group",
("eg1", "e1"),
{},
"POST",
"/v1/extension-groups/eg1/extensions/e1",
None,
{},
"/v1/extension-groups/{id}/extensions/{extension_id}",
),
(
"remove_extension_from_group",
("eg1", "e1"),
{},
"DELETE",
"/v1/extension-groups/eg1/extensions/e1",
None,
{},
"/v1/extension-groups/{id}/extensions/{extension_id}",
),
# -- browsers ----------------------------------------------------------
(
"download_browser",
(),
{"browser": "wayfern", "version": "152.0.1"},
"POST",
"/v1/browsers/download",
{"browser": "wayfern", "version": "152.0.1"},
{},
"/v1/browsers/download",
),
(
"list_browser_versions",
("wayfern",),
{},
"GET",
"/v1/browsers/wayfern/versions",
None,
{},
"/v1/browsers/{browser}/versions",
),
(
"is_browser_downloaded",
("wayfern", "152.0.1"),
{},
"GET",
"/v1/browsers/wayfern/versions/152.0.1/downloaded",
None,
{},
"/v1/browsers/{browser}/versions/{version}/downloaded",
),
]
@pytest.mark.parametrize(
"case", CASES, ids=[f"{case[0]}[{index}]" for index, case in enumerate(CASES)]
)
def test_method_sends_the_documented_request(
client: DonutClient, fake: FakeDonut, case: Case
) -> None:
name, args, kwargs, verb, path, body, query, operation = case
getattr(client, name)(*args, **kwargs)
sent = fake.last
assert sent.method == verb
assert sent.path == path
assert sent.query == query
assert sent.json == body
assert OPERATIONS[(verb, operation)] == name
def test_every_client_method_is_exercised_here() -> None:
"""No method may be added to the table of operations without a case above."""
covered = {case[0] for case in CASES}
missing = sorted(set(OPERATIONS.values()) - covered)
assert not missing, f"these wrapped operations have no request test: {missing}"
def test_the_token_travels_as_a_bearer_header(client: DonutClient, fake: FakeDonut) -> None:
client.list_profiles()
sent = fake.last
assert sent.header("Authorization") == "Bearer test-token-abc123"
assert sent.header("Accept") == "application/json"
assert sent.header("Content-Type") is None, "a GET must not claim to carry JSON"
def test_a_body_is_sent_as_json(client: DonutClient, fake: FakeDonut) -> None:
client.create_group(name="Retail")
sent = fake.last
assert sent.header("Content-Type") == "application/json"
assert json.loads(sent.body.decode()) == {"name": "Retail"}
def test_path_ids_are_escaped(client: DonutClient, fake: FakeDonut) -> None:
"""An id can never break out of its own path segment."""
client.get_profile("a/b c?d")
assert fake.last.path == "/v1/profiles/a%2Fb%20c%3Fd"
def test_none_arguments_are_left_out_of_the_body(client: DonutClient, fake: FakeDonut) -> None:
client.update_profile("p1", name="Only this")
assert fake.last.json == {"name": "Only this"}
def test_an_empty_string_still_reaches_the_app(client: DonutClient, fake: FakeDonut) -> None:
"""`proxy_id=""` is how the app is told to detach a proxy, so it must survive."""
client.update_profile("p1", proxy_id="")
assert fake.last.json == {"proxy_id": ""}
def test_a_no_content_answer_becomes_none(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_empty(204)
assert client.delete_profile("p1") is None
def test_a_json_answer_is_returned_as_sent(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json({"profiles": [{"id": "p1", "name": "Shopper"}], "total": 1})
assert client.list_profiles() == {
"profiles": [{"id": "p1", "name": "Shopper"}],
"total": 1,
}
def test_a_bare_boolean_answer_is_returned(client: DonutClient, fake: FakeDonut) -> None:
fake.enqueue_json(True)
assert client.is_browser_downloaded("wayfern", "152.0.1") is True