mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-10 19:22:08 +00:00
Covers RemoteClient/RemoteService, REST routes, handlers, SessionStore, InputBridge, DownloadQueueManager, errors, compression, and serve CLI. E2e tier opts in via --live and can auto-spawn its own serve.
209 lines
6.4 KiB
Python
209 lines
6.4 KiB
Python
"""Shared fixtures + CLI flags for tests/remote/."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def pytest_configure(config: pytest.Config) -> None:
|
|
config.addinivalue_line("markers", "unit: fast, mocked tests (default)")
|
|
config.addinivalue_line("markers", "live: end-to-end tests against a running serve (opt-in via --live)")
|
|
config.addinivalue_line("markers", "slow: tests that hit real services and may take >10s")
|
|
|
|
|
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
parser.addoption(
|
|
"--live",
|
|
action="store_true",
|
|
default=False,
|
|
help="Run e2e tests against a running 'unshackle serve' instance.",
|
|
)
|
|
parser.addoption(
|
|
"--server-url",
|
|
action="store",
|
|
default=os.environ.get("UNSHACKLE_SERVE_URL", ""),
|
|
help="Server URL for e2e tests. If empty, the suite will spawn its own serve "
|
|
"(via --spawn-serve, default on for --live). Override with $UNSHACKLE_SERVE_URL.",
|
|
)
|
|
parser.addoption(
|
|
"--secret-key",
|
|
action="store",
|
|
default=os.environ.get("UNSHACKLE_SECRET_KEY", ""),
|
|
help="X-Secret-Key for e2e tests. Empty for --no-key servers.",
|
|
)
|
|
parser.addoption(
|
|
"--services",
|
|
action="store",
|
|
default="",
|
|
help="Comma-separated service tags to run e2e against (default: all in fixtures.yaml).",
|
|
)
|
|
parser.addoption(
|
|
"--spawn-serve",
|
|
action="store",
|
|
default="auto",
|
|
choices=["auto", "always", "never"],
|
|
help="Whether to spawn 'unshackle serve' for e2e tests. "
|
|
"'auto' (default): spawn only when --server-url is empty. "
|
|
"'always': always spawn (kills any existing process is your problem). "
|
|
"'never': require an external serve.",
|
|
)
|
|
|
|
|
|
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
|
"""Skip live tests unless --live is passed."""
|
|
if config.getoption("--live"):
|
|
return
|
|
skip_live = pytest.mark.skip(reason="needs --live")
|
|
for item in items:
|
|
if "live" in item.keywords:
|
|
item.add_marker(skip_live)
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def _wait_for_health(url: str, timeout: float = 60.0) -> bool:
|
|
import requests
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
r = requests.get(f"{url}/api/health", timeout=2)
|
|
if r.status_code == 200:
|
|
return True
|
|
except requests.RequestException:
|
|
pass
|
|
time.sleep(0.5)
|
|
return False
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def secret_key(request: pytest.FixtureRequest) -> str:
|
|
return str(request.config.getoption("--secret-key"))
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def server_url(request: pytest.FixtureRequest):
|
|
"""Provide a base URL for the live serve.
|
|
|
|
- If --server-url is set: use it as-is (external server).
|
|
- Else (default) and --live is on: spawn 'unshackle serve' on a free
|
|
port, wait for /api/health, yield the URL, terminate at session end.
|
|
- Spawn mode controlled by --spawn-serve {auto, always, never}.
|
|
"""
|
|
cfg = request.config
|
|
explicit = str(cfg.getoption("--server-url")).rstrip("/")
|
|
mode = cfg.getoption("--spawn-serve")
|
|
is_live = cfg.getoption("--live")
|
|
|
|
if not is_live:
|
|
yield explicit or "http://localhost:8786"
|
|
return
|
|
|
|
if mode == "never":
|
|
if not explicit:
|
|
pytest.fail("--spawn-serve=never requires --server-url")
|
|
if not _wait_for_health(explicit, timeout=10):
|
|
pytest.fail(f"External serve not reachable at {explicit}")
|
|
yield explicit
|
|
return
|
|
|
|
if mode == "auto" and explicit:
|
|
if not _wait_for_health(explicit, timeout=10):
|
|
pytest.fail(f"External serve not reachable at {explicit}")
|
|
yield explicit
|
|
return
|
|
|
|
# Spawn our own serve.
|
|
port = _free_port()
|
|
cmd = [
|
|
"uv",
|
|
"run",
|
|
"unshackle",
|
|
"serve",
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
str(port),
|
|
"--no-key",
|
|
"--remote-only",
|
|
]
|
|
proc = subprocess.Popen( # nosec B603
|
|
cmd,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
cwd=Path(__file__).resolve().parents[2],
|
|
)
|
|
url = f"http://127.0.0.1:{port}"
|
|
try:
|
|
if not _wait_for_health(url, timeout=60):
|
|
proc.terminate()
|
|
pytest.fail(f"Spawned serve at {url} did not become healthy within 60s")
|
|
yield url
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def selected_services(request: pytest.FixtureRequest) -> list[str]:
|
|
raw = str(request.config.getoption("--services")).strip()
|
|
return [s.strip().upper() for s in raw.split(",") if s.strip()] if raw else []
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def fixtures_dir() -> Path:
|
|
return Path(__file__).parent / "e2e" / "fixtures"
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def e2e_scenarios(fixtures_dir: Path, selected_services: list[str]) -> dict:
|
|
"""Load e2e service scenarios from fixtures.yaml.
|
|
|
|
See fixtures-example.yaml for the schema. fixtures.yaml is .gitignored
|
|
so each user keeps their own private scenario set locally.
|
|
"""
|
|
import yaml
|
|
|
|
path = fixtures_dir / "fixtures.yaml"
|
|
if not path.exists():
|
|
return {"services": {}}
|
|
data = yaml.safe_load(path.read_text()) or {}
|
|
services = data.get("services", {})
|
|
if selected_services:
|
|
services = {k: v for k, v in services.items() if k.upper() in selected_services}
|
|
data["services"] = services
|
|
return data
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def http_session(secret_key: str):
|
|
"""Plain requests.Session with X-Secret-Key wired in."""
|
|
import requests
|
|
|
|
s = requests.Session()
|
|
s.headers["User-Agent"] = "unshackle-tests/1.0"
|
|
if secret_key:
|
|
s.headers["X-Secret-Key"] = secret_key
|
|
return s
|
|
|
|
|
|
@pytest.fixture
|
|
def remote_client(server_url: str, secret_key: str):
|
|
"""RemoteClient pointed at the e2e serve instance."""
|
|
from unshackle.core.remote_service import RemoteClient
|
|
|
|
return RemoteClient(server_url=server_url, api_key=secret_key)
|