mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-10 11:12:13 +00:00
test(remote): add unit + e2e suite for remote-services subsystem
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.
This commit is contained in:
208
tests/remote/conftest.py
Normal file
208
tests/remote/conftest.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user