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:
0
tests/remote/e2e/__init__.py
Normal file
0
tests/remote/e2e/__init__.py
Normal file
40
tests/remote/e2e/conftest.py
Normal file
40
tests/remote/e2e/conftest.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""E2E-specific fixtures.
|
||||
|
||||
These tests are skipped unless ``--live`` is passed on the pytest CLI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_scenarios(config: pytest.Config) -> dict:
|
||||
cached = getattr(config, "_e2e_scenarios_cache", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
import yaml
|
||||
|
||||
path = Path(__file__).parent / "fixtures" / "fixtures.yaml"
|
||||
data = yaml.safe_load(path.read_text()) if path.exists() else {}
|
||||
selected = (config.getoption("--services") or "").strip()
|
||||
services = (data or {}).get("services", {}) or {}
|
||||
if selected:
|
||||
wanted = {s.strip().upper() for s in selected.split(",") if s.strip()}
|
||||
services = {k: v for k, v in services.items() if k.upper() in wanted}
|
||||
cached = {"services": services}
|
||||
config._e2e_scenarios_cache = cached # type: ignore[attr-defined]
|
||||
return cached
|
||||
|
||||
|
||||
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
||||
if "service_case" in metafunc.fixturenames:
|
||||
scenarios = _load_scenarios(metafunc.config)
|
||||
params = [pytest.param((tag, conf), id=tag) for tag, conf in (scenarios.get("services") or {}).items()]
|
||||
if not params:
|
||||
params = [
|
||||
pytest.param(("__none__", {}), id="no-services", marks=pytest.mark.skip(reason="no e2e fixtures"))
|
||||
]
|
||||
metafunc.parametrize("service_case", params)
|
||||
67
tests/remote/e2e/fixtures/fixtures-example.yaml
Normal file
67
tests/remote/e2e/fixtures/fixtures-example.yaml
Normal file
@@ -0,0 +1,67 @@
|
||||
# tests/remote/e2e/fixtures/fixtures-example.yaml
|
||||
#
|
||||
# Copy this file to `fixtures.yaml` in the same directory and fill in
|
||||
# service-specific scenarios for the services you have access to.
|
||||
# `fixtures.yaml` is .gitignored so your private IDs / queries stay local.
|
||||
#
|
||||
# Each entry under `services:` is keyed by the service TAG used by
|
||||
# unshackle (matches the service directory name, e.g. EXAMPLE). The keys
|
||||
# below are read by the e2e tests:
|
||||
#
|
||||
# title_url (required) — movie URL/slug/ID accepted by the service
|
||||
# series_url (optional) — series URL/slug/ID; when set, the
|
||||
# quality + license + download tests
|
||||
# target target_season/target_episode
|
||||
# instead of the movie
|
||||
# target_season (optional, default 1)
|
||||
# target_episode (optional, default 1)
|
||||
# search_query (optional) — string to send to /api/search
|
||||
# requires_auth (optional, default true) — informational
|
||||
# server_cdm (optional, default false) — informational
|
||||
# drm (optional) — informational ("widevine" / "playready")
|
||||
#
|
||||
# expected_quality (optional) — when present, the quality test
|
||||
# asserts these limits against the
|
||||
# discovered video tracks
|
||||
# min_height (optional) — max video.height >= this
|
||||
# min_codecs (optional) — codec names that must be present
|
||||
# (AVC, HEVC, VC1, VP8, VP9, AV1)
|
||||
# min_ranges (optional) — range names that must be present
|
||||
# (SDR, HDR10, HDR10P, DV, HLG, HYBRID)
|
||||
# min_track_count (optional) — at least this many video tracks
|
||||
#
|
||||
# runs_download_test (optional, default false) — when true, the
|
||||
# download smoke test
|
||||
# runs for this service
|
||||
# runs_license_test (optional, default false) — when true, the
|
||||
# license test runs for
|
||||
# this service
|
||||
# license_drm (optional, default "widevine") — DRM type the
|
||||
# license test
|
||||
# requests ("widevine"
|
||||
# or "playready")
|
||||
# license_quality (optional, default 1080) — pixel height of the
|
||||
# track the license test
|
||||
# targets
|
||||
|
||||
services:
|
||||
EXAMPLE:
|
||||
title_url: "https://example.com/movie/example-movie-id"
|
||||
series_url: "https://example.com/series/example-series-id"
|
||||
target_season: 1
|
||||
target_episode: 1
|
||||
search_query: "example"
|
||||
requires_auth: true
|
||||
server_cdm: false
|
||||
drm: widevine
|
||||
|
||||
expected_quality:
|
||||
min_height: 1080
|
||||
min_codecs: [AVC]
|
||||
min_ranges: [SDR]
|
||||
min_track_count: 4
|
||||
|
||||
runs_download_test: true
|
||||
runs_license_test: false
|
||||
license_drm: widevine
|
||||
license_quality: 1080
|
||||
124
tests/remote/e2e/test_live_download.py
Normal file
124
tests/remote/e2e/test_live_download.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""E2E: download smoke test via segments + CDN manifest fetch.
|
||||
|
||||
For any fixture service with ``runs_download_test: true``, this test:
|
||||
1. Creates a session with the full track-selection knobs.
|
||||
2. Picks a 1080p SDR video track on the configured target title.
|
||||
3. Calls /api/session/{id}/segments to resolve the CDN URL + headers.
|
||||
4. Fetches the manifest URL with the resolved headers.
|
||||
5. Asserts the body is non-empty and looks like DASH/HLS.
|
||||
|
||||
It does NOT decrypt or mux — that requires a full local CDM. It proves
|
||||
the end-to-end pipeline up to CDN reachability for the selected quality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.live, pytest.mark.slow]
|
||||
|
||||
|
||||
def _wait_titles(http_session, server_url: str, sid: str, timeout: float = 120.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
r = http_session.get(f"{server_url}/api/session/{sid}/titles", timeout=30)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
if r.status_code == 400:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if (body.get("details") or {}).get("auth_status") in ("authenticating", "pending_input"):
|
||||
time.sleep(2.0)
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _pick_target_title(titles, season: int = 1, episode: int = 1):
|
||||
for t in titles:
|
||||
if t.get("type") == "episode" and t.get("season") == season and t.get("number") == episode:
|
||||
return t
|
||||
return titles[0] if titles else None
|
||||
|
||||
|
||||
def _pick_track_at_height(video_tracks, target_height: int):
|
||||
same_height = [v for v in video_tracks if v.get("height") == target_height and v.get("range") == "SDR"]
|
||||
return sorted(same_height, key=lambda v: v.get("bitrate") or 0)[0] if same_height else None
|
||||
|
||||
|
||||
def test_download_manifest_fetch(http_session, server_url: str, service_case) -> None:
|
||||
service, conf = service_case
|
||||
if not conf.get("runs_download_test"):
|
||||
pytest.skip(f"{service}: download test not enabled (runs_download_test)")
|
||||
|
||||
target_height = int(conf.get("license_quality") or 1080)
|
||||
title_input = conf.get("series_url") or conf.get("title_url")
|
||||
if not title_input:
|
||||
pytest.skip(f"{service}: no title/series in fixture")
|
||||
|
||||
r = http_session.post(
|
||||
f"{server_url}/api/session/create",
|
||||
json={
|
||||
"service": service,
|
||||
"title_id": title_input,
|
||||
"range_": ["SDR"],
|
||||
"vcodec": ["AVC"],
|
||||
"quality": [target_height],
|
||||
"best_available": True,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
pytest.skip(f"{service}: session create failed {r.status_code}: {r.text[:200]}")
|
||||
sid = r.json()["session_id"]
|
||||
|
||||
try:
|
||||
body = _wait_titles(http_session, server_url, sid)
|
||||
if not body:
|
||||
pytest.skip(f"{service}: titles timeout")
|
||||
target = _pick_target_title(
|
||||
body.get("titles") or [],
|
||||
season=conf.get("target_season", 1),
|
||||
episode=conf.get("target_episode", 1),
|
||||
)
|
||||
if not target:
|
||||
pytest.skip(f"{service}: no target title")
|
||||
|
||||
tr = http_session.post(
|
||||
f"{server_url}/api/session/{sid}/tracks",
|
||||
json={"title_id": target["id"]},
|
||||
timeout=240,
|
||||
)
|
||||
assert tr.status_code == 200, tr.text
|
||||
track = _pick_track_at_height(tr.json().get("video") or [], target_height)
|
||||
if not track:
|
||||
pytest.skip(f"{service}: no track at height={target_height}")
|
||||
|
||||
seg = http_session.post(
|
||||
f"{server_url}/api/session/{sid}/segments",
|
||||
json={"track_ids": [track["id"]]},
|
||||
timeout=120,
|
||||
)
|
||||
assert seg.status_code == 200, seg.text
|
||||
info = seg.json().get("tracks", {}).get(track["id"])
|
||||
assert info, f"no segment info: {seg.text[:200]}"
|
||||
|
||||
manifest_url = info.get("url")
|
||||
assert manifest_url, f"no manifest url: {info}"
|
||||
headers = dict(info.get("headers") or {})
|
||||
headers.pop("Host", None)
|
||||
headers.pop("host", None)
|
||||
|
||||
import requests
|
||||
|
||||
cdn = requests.get(manifest_url, headers=headers, timeout=60)
|
||||
assert cdn.status_code == 200, f"CDN fetch failed {cdn.status_code}: {cdn.text[:200]}"
|
||||
assert len(cdn.content) > 256, "manifest body too small"
|
||||
head = cdn.content[:128].lstrip()
|
||||
assert head.startswith((b"<?xml", b"<MPD", b"#EXTM3U")), f"unexpected manifest content: {head[:64]!r}"
|
||||
finally:
|
||||
http_session.delete(f"{server_url}/api/session/{sid}", timeout=30)
|
||||
37
tests/remote/e2e/test_live_health.py
Normal file
37
tests/remote/e2e/test_live_health.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""E2E: basic reachability against a running `unshackle serve`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.live]
|
||||
|
||||
|
||||
def test_health_endpoint(http_session, server_url: str) -> None:
|
||||
r = http_session.get(f"{server_url}/api/health", timeout=10)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert "version" in body
|
||||
assert "update_check" in body
|
||||
|
||||
|
||||
def test_services_endpoint(http_session, server_url: str) -> None:
|
||||
r = http_session.get(f"{server_url}/api/services", timeout=10)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "services" in body
|
||||
assert isinstance(body["services"], list)
|
||||
|
||||
|
||||
def test_health_does_not_require_secret_key(server_url: str) -> None:
|
||||
"""Health bypasses auth even when the server runs with a key."""
|
||||
import requests
|
||||
|
||||
r = requests.get(f"{server_url}/api/health", timeout=10)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_unknown_route_returns_404(http_session, server_url: str) -> None:
|
||||
r = http_session.get(f"{server_url}/api/this-does-not-exist", timeout=10)
|
||||
assert r.status_code in (404, 405)
|
||||
46
tests/remote/e2e/test_live_input_bridge.py
Normal file
46
tests/remote/e2e/test_live_input_bridge.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""E2E: interactive auth prompt round-trip.
|
||||
|
||||
For services that don't naturally prompt during ``authenticate()``, the
|
||||
test still verifies the GET prompt endpoint returns the documented
|
||||
shape (empty when no prompt pending).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.live, pytest.mark.slow]
|
||||
|
||||
|
||||
def test_prompt_endpoint_shape_without_pending(http_session, server_url: str, service_case) -> None:
|
||||
service, conf = service_case
|
||||
r = http_session.post(
|
||||
f"{server_url}/api/session/create",
|
||||
json={"service": service, "title_id": conf["title_url"]},
|
||||
timeout=120,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
pytest.skip(f"{service}: session creation failed: {r.status_code}")
|
||||
sid = r.json()["session_id"]
|
||||
try:
|
||||
pr = http_session.get(f"{server_url}/api/session/{sid}/prompt", timeout=10)
|
||||
assert pr.status_code in (200, 404), pr.text
|
||||
if pr.status_code == 200:
|
||||
body = pr.json()
|
||||
assert "prompt" in body or "status" in body
|
||||
finally:
|
||||
http_session.delete(f"{server_url}/api/session/{sid}", timeout=30)
|
||||
|
||||
|
||||
def test_prompt_post_unknown_session_returns_404(http_session, server_url: str) -> None:
|
||||
r = http_session.post(
|
||||
f"{server_url}/api/session/bogus-session-id/prompt",
|
||||
json={"response": "irrelevant"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_prompt_get_unknown_session_returns_404(http_session, server_url: str) -> None:
|
||||
r = http_session.get(f"{server_url}/api/session/bogus-session-id/prompt", timeout=10)
|
||||
assert r.status_code == 404
|
||||
120
tests/remote/e2e/test_live_license.py
Normal file
120
tests/remote/e2e/test_live_license.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""E2E: license acquisition via server_cdm batch mode.
|
||||
|
||||
For any fixture service with ``runs_license_test: true``, this test:
|
||||
1. Creates a session for the configured target title.
|
||||
2. Picks a video track at ``license_quality`` height (default 1080).
|
||||
3. Asks the server for keys via ``mode=server_cdm`` with ``drm_type``
|
||||
equal to the configured ``license_drm`` (default ``widevine``).
|
||||
4. Asserts at least one 32-hex KID + 32-hex KEY pair is returned.
|
||||
|
||||
The server uses its own configured CDM (no client CDM required). Services
|
||||
without ``runs_license_test`` are skipped, so this file is service-neutral.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.live, pytest.mark.slow]
|
||||
|
||||
|
||||
def _wait_titles(http_session, server_url: str, sid: str, timeout: float = 120.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
r = http_session.get(f"{server_url}/api/session/{sid}/titles", timeout=30)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
if r.status_code == 400:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if (body.get("details") or {}).get("auth_status") in ("authenticating", "pending_input"):
|
||||
time.sleep(2.0)
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _pick_target_title(titles, season: int = 1, episode: int = 1):
|
||||
for t in titles:
|
||||
if t.get("type") == "episode" and t.get("season") == season and t.get("number") == episode:
|
||||
return t
|
||||
return titles[0] if titles else None
|
||||
|
||||
|
||||
def _pick_track_at_height(video_tracks, target_height: int):
|
||||
"""Prefer SDR + AVC at the requested height; smallest bitrate wins."""
|
||||
same_height = [v for v in video_tracks if v.get("height") == target_height]
|
||||
preferred = [v for v in same_height if v.get("codec") == "AVC" and v.get("range") == "SDR"]
|
||||
pool = preferred or [v for v in same_height if v.get("range") == "SDR"] or same_height
|
||||
return sorted(pool, key=lambda v: v.get("bitrate") or 0)[0] if pool else None
|
||||
|
||||
|
||||
def test_license_server_cdm(http_session, server_url: str, service_case) -> None:
|
||||
service, conf = service_case
|
||||
if not conf.get("runs_license_test"):
|
||||
pytest.skip(f"{service}: license test not enabled (runs_license_test)")
|
||||
|
||||
drm_type = (conf.get("license_drm") or "widevine").lower()
|
||||
target_height = int(conf.get("license_quality") or 1080)
|
||||
title_input = conf.get("series_url") or conf.get("title_url")
|
||||
if not title_input:
|
||||
pytest.skip(f"{service}: no title/series in fixture")
|
||||
|
||||
r = http_session.post(
|
||||
f"{server_url}/api/session/create",
|
||||
json={
|
||||
"service": service,
|
||||
"title_id": title_input,
|
||||
"range_": ["SDR"],
|
||||
"vcodec": ["AVC"],
|
||||
"best_available": True,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
pytest.skip(f"{service}: session create failed {r.status_code}: {r.text[:200]}")
|
||||
sid = r.json()["session_id"]
|
||||
|
||||
try:
|
||||
body = _wait_titles(http_session, server_url, sid)
|
||||
if not body:
|
||||
pytest.skip(f"{service}: titles timeout")
|
||||
target = _pick_target_title(
|
||||
body.get("titles") or [],
|
||||
season=conf.get("target_season", 1),
|
||||
episode=conf.get("target_episode", 1),
|
||||
)
|
||||
if not target:
|
||||
pytest.skip(f"{service}: no target title")
|
||||
|
||||
tr = http_session.post(
|
||||
f"{server_url}/api/session/{sid}/tracks",
|
||||
json={"title_id": target["id"]},
|
||||
timeout=240,
|
||||
)
|
||||
assert tr.status_code == 200, tr.text
|
||||
track = _pick_track_at_height(tr.json().get("video") or [], target_height)
|
||||
if not track:
|
||||
pytest.skip(f"{service}: no track at height={target_height}")
|
||||
|
||||
lic = http_session.post(
|
||||
f"{server_url}/api/session/{sid}/license",
|
||||
json={"track_ids": [track["id"]], "mode": "server_cdm", "drm_type": drm_type},
|
||||
timeout=120,
|
||||
)
|
||||
assert lic.status_code == 200, lic.text
|
||||
payload = lic.json()
|
||||
keys = payload.get("keys") or {}
|
||||
assert keys, f"no keys returned; payload={payload}"
|
||||
|
||||
track_keys = keys.get(track["id"]) or keys
|
||||
assert isinstance(track_keys, dict) and track_keys, f"unexpected keys shape: {keys}"
|
||||
for kid, key in track_keys.items():
|
||||
assert len(kid) == 32, f"bad kid length: {kid}"
|
||||
assert len(key) == 32, f"bad key length: {key}"
|
||||
finally:
|
||||
http_session.delete(f"{server_url}/api/session/{sid}", timeout=30)
|
||||
141
tests/remote/e2e/test_live_quality.py
Normal file
141
tests/remote/e2e/test_live_quality.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""E2E: track quality probing per service.
|
||||
|
||||
Sends a session_create with the full track-selection knob set (range_,
|
||||
vcodec, best_available) so the server returns every available track.
|
||||
Picks S01E01 for series, first title for movies, then asserts that the
|
||||
discovered video tracks meet the expected_quality limits declared in
|
||||
fixtures.yaml. If no expected block is present, the test prints what was
|
||||
discovered and skips so you can copy values back into the fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.live, pytest.mark.slow]
|
||||
|
||||
|
||||
def _create_session(http_session, server_url: str, service: str, title_id: str) -> str:
|
||||
payload = {
|
||||
"service": service,
|
||||
"title_id": title_id,
|
||||
# Quality knobs — send enum names the server's session_create parser accepts.
|
||||
"range_": ["SDR", "HDR10", "DV"],
|
||||
"vcodec": ["AVC", "HEVC"],
|
||||
"best_available": True,
|
||||
}
|
||||
r = http_session.post(f"{server_url}/api/session/create", json=payload, timeout=120)
|
||||
if r.status_code >= 400:
|
||||
pytest.skip(f"{service}: session create failed {r.status_code}: {r.text[:200]}")
|
||||
return r.json()["session_id"]
|
||||
|
||||
|
||||
def _wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 120.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
r = http_session.get(f"{server_url}/api/session/{sid}/titles", timeout=30)
|
||||
if r.status_code == 200:
|
||||
return r.status_code, r.json()
|
||||
if r.status_code == 400:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if (body.get("details") or {}).get("auth_status") in ("authenticating", "pending_input"):
|
||||
time.sleep(2.0)
|
||||
continue
|
||||
return r.status_code, r.json() if r.text else {}
|
||||
return 408, {"message": "auth timeout"}
|
||||
|
||||
|
||||
def _pick_target_title(titles: list[dict[str, Any]], season: int = 1, episode: int = 1) -> Optional[dict[str, Any]]:
|
||||
"""Pick the configured target episode; fall back to the first title."""
|
||||
for t in titles:
|
||||
if t.get("type") == "episode" and t.get("season") == season and t.get("number") == episode:
|
||||
return t
|
||||
return titles[0] if titles else None
|
||||
|
||||
|
||||
def _summarize(video_tracks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
max_height = max((v.get("height") or 0 for v in video_tracks), default=0)
|
||||
max_width = max((v.get("width") or 0 for v in video_tracks), default=0)
|
||||
codecs = sorted({v.get("codec") for v in video_tracks if v.get("codec")})
|
||||
ranges = sorted({v.get("range") for v in video_tracks if v.get("range")})
|
||||
bitrates = sorted({v.get("bitrate") for v in video_tracks if v.get("bitrate")})
|
||||
return {
|
||||
"track_count": len(video_tracks),
|
||||
"max_width": max_width,
|
||||
"max_height": max_height,
|
||||
"codecs": codecs,
|
||||
"ranges": ranges,
|
||||
"bitrate_min_kbps": min(bitrates) if bitrates else None,
|
||||
"bitrate_max_kbps": max(bitrates) if bitrates else None,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_title_id(conf: dict) -> Optional[str]:
|
||||
"""Prefer series_url so S01E01 logic kicks in; fall back to movie title_url."""
|
||||
return conf.get("series_url") or conf.get("title_url")
|
||||
|
||||
|
||||
def test_track_quality_meets_expected(http_session, server_url: str, service_case, capsys) -> None:
|
||||
service, conf = service_case
|
||||
title_input = _resolve_title_id(conf)
|
||||
if not title_input:
|
||||
pytest.skip(f"{service}: no title_url/series_url in fixture")
|
||||
|
||||
sid = _create_session(http_session, server_url, service, title_input)
|
||||
try:
|
||||
code, body = _wait_for_titles(http_session, server_url, sid)
|
||||
if code != 200:
|
||||
pytest.skip(f"{service}: titles fetch failed {code}: {str(body)[:200]}")
|
||||
|
||||
titles = body.get("titles") or []
|
||||
target = _pick_target_title(titles, season=conf.get("target_season", 1), episode=conf.get("target_episode", 1))
|
||||
if not target:
|
||||
pytest.skip(f"{service}: no titles returned")
|
||||
|
||||
# Movies appear with type=='movie'; episodes with type=='episode'.
|
||||
kind = target.get("type")
|
||||
title_id = target.get("id")
|
||||
if not title_id:
|
||||
pytest.skip(f"{service}: target title has no id")
|
||||
|
||||
r = http_session.post(
|
||||
f"{server_url}/api/session/{sid}/tracks",
|
||||
json={"title_id": title_id},
|
||||
timeout=300,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
pytest.skip(f"{service}: tracks fetch failed {r.status_code}: {r.text[:200]}")
|
||||
|
||||
tracks = r.json()
|
||||
video = tracks.get("video") or []
|
||||
summary = _summarize(video)
|
||||
# Print summary even when test passes — discovery aid.
|
||||
with capsys.disabled():
|
||||
print(f"\n[{service}] target={kind} '{target.get('name')}' -> {summary}")
|
||||
|
||||
expected = conf.get("expected_quality") or {}
|
||||
if not expected:
|
||||
pytest.skip(f"{service}: no expected_quality in fixtures.yaml — discovered={summary}")
|
||||
|
||||
if "min_height" in expected:
|
||||
assert summary["max_height"] >= expected["min_height"], (
|
||||
f"{service}: max height {summary['max_height']} < expected min {expected['min_height']}"
|
||||
)
|
||||
if "min_codecs" in expected:
|
||||
missing = set(expected["min_codecs"]) - set(summary["codecs"])
|
||||
assert not missing, f"{service}: missing codecs {missing}, got {summary['codecs']}"
|
||||
if "min_ranges" in expected:
|
||||
missing = set(expected["min_ranges"]) - set(summary["ranges"])
|
||||
assert not missing, f"{service}: missing ranges {missing}, got {summary['ranges']}"
|
||||
if "min_track_count" in expected:
|
||||
assert summary["track_count"] >= expected["min_track_count"], (
|
||||
f"{service}: only {summary['track_count']} video tracks, expected >= {expected['min_track_count']}"
|
||||
)
|
||||
finally:
|
||||
http_session.delete(f"{server_url}/api/session/{sid}", timeout=30)
|
||||
29
tests/remote/e2e/test_live_remote_client.py
Normal file
29
tests/remote/e2e/test_live_remote_client.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""E2E: drive the full RemoteClient HTTP surface against the running serve."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.live]
|
||||
|
||||
|
||||
def test_remote_client_get_health(remote_client) -> None:
|
||||
body = remote_client.get("/api/health")
|
||||
assert body["status"] == "ok"
|
||||
assert "version" in body
|
||||
|
||||
|
||||
def test_remote_client_get_services(remote_client) -> None:
|
||||
body = remote_client.get("/api/services")
|
||||
assert "services" in body
|
||||
assert isinstance(body["services"], list)
|
||||
|
||||
|
||||
def test_remote_client_get_404_raises_systemexit(remote_client) -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
remote_client.get("/api/session/this-does-not-exist")
|
||||
|
||||
|
||||
def test_remote_client_delete_404_raises_systemexit(remote_client) -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
remote_client.delete("/api/session/this-does-not-exist")
|
||||
28
tests/remote/e2e/test_live_search.py
Normal file
28
tests/remote/e2e/test_live_search.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""E2E: search endpoint per service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.live, pytest.mark.slow]
|
||||
|
||||
|
||||
def test_search_returns_results(http_session, server_url: str, service_case) -> None:
|
||||
service, conf = service_case
|
||||
query = conf.get("search_query")
|
||||
if not query:
|
||||
pytest.skip(f"no search_query configured for {service}")
|
||||
|
||||
r = http_session.post(
|
||||
f"{server_url}/api/search",
|
||||
json={"service": service, "query": query},
|
||||
timeout=120,
|
||||
)
|
||||
if r.status_code in (400, 401, 403):
|
||||
pytest.skip(f"{service} search not available: {r.status_code} {r.text[:200]}")
|
||||
if r.status_code == 502 and "not supported" in r.text.lower():
|
||||
pytest.skip(f"{service} does not implement search")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "results" in body
|
||||
assert isinstance(body["results"], list)
|
||||
116
tests/remote/e2e/test_live_session_lifecycle.py
Normal file
116
tests/remote/e2e/test_live_session_lifecycle.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""E2E: full session lifecycle per service (create → info → titles → tracks → delete)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.live, pytest.mark.slow]
|
||||
|
||||
|
||||
def _create_session(http_session, server_url: str, service: str, conf: dict) -> str:
|
||||
payload = {"service": service, "title_id": conf["title_url"]}
|
||||
r = http_session.post(f"{server_url}/api/session/create", json=payload, timeout=120)
|
||||
if r.status_code >= 400:
|
||||
pytest.skip(f"Auth/setup not available for {service}: {r.status_code} {r.text[:200]}")
|
||||
body = r.json()
|
||||
sid = body.get("session_id")
|
||||
assert sid, f"no session_id in body: {body}"
|
||||
return sid
|
||||
|
||||
|
||||
def _wait_for_titles(http_session, server_url: str, sid: str, timeout: float = 120.0):
|
||||
"""Poll /titles until auth completes. Returns (status_code, response_json).
|
||||
|
||||
Server returns 400 + auth_status=authenticating while auth is in-flight,
|
||||
200 when authenticated, and other 4xx/5xx on real failure.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
r = http_session.get(f"{server_url}/api/session/{sid}/titles", timeout=30)
|
||||
if r.status_code == 200:
|
||||
return r.status_code, r.json()
|
||||
if r.status_code == 400:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
auth_status = (body.get("details") or {}).get("auth_status")
|
||||
if auth_status in ("authenticating", "pending_input"):
|
||||
time.sleep(2.0)
|
||||
continue
|
||||
return r.status_code, r.json() if r.text else {}
|
||||
return 408, {"message": "timeout waiting for auth"}
|
||||
|
||||
|
||||
def _delete_session(http_session, server_url: str, sid: str) -> None:
|
||||
http_session.delete(f"{server_url}/api/session/{sid}", timeout=30)
|
||||
|
||||
|
||||
def test_session_create_then_delete(http_session, server_url: str, service_case) -> None:
|
||||
service, conf = service_case
|
||||
sid = _create_session(http_session, server_url, service, conf)
|
||||
try:
|
||||
r = http_session.get(f"{server_url}/api/session/{sid}", timeout=30)
|
||||
assert r.status_code == 200
|
||||
info = r.json()
|
||||
assert info.get("session", {}).get("service_tag", service) == service or service in str(info)
|
||||
finally:
|
||||
_delete_session(http_session, server_url, sid)
|
||||
|
||||
# After delete, info should 404
|
||||
r2 = http_session.get(f"{server_url}/api/session/{sid}", timeout=30)
|
||||
assert r2.status_code == 404
|
||||
|
||||
|
||||
def test_session_titles_returns_list(http_session, server_url: str, service_case) -> None:
|
||||
service, conf = service_case
|
||||
sid = _create_session(http_session, server_url, service, conf)
|
||||
try:
|
||||
code, body = _wait_for_titles(http_session, server_url, sid)
|
||||
if code != 200:
|
||||
pytest.skip(f"{service}: titles fetch failed {code}: {str(body)[:200]}")
|
||||
assert "titles" in body
|
||||
assert isinstance(body["titles"], list)
|
||||
assert len(body["titles"]) >= 1
|
||||
first = body["titles"][0]
|
||||
assert "id" in first
|
||||
assert "type" in first
|
||||
finally:
|
||||
_delete_session(http_session, server_url, sid)
|
||||
|
||||
|
||||
def test_session_tracks_for_first_title(http_session, server_url: str, service_case) -> None:
|
||||
service, conf = service_case
|
||||
sid = _create_session(http_session, server_url, service, conf)
|
||||
try:
|
||||
code, body = _wait_for_titles(http_session, server_url, sid)
|
||||
if code != 200:
|
||||
pytest.skip(f"{service}: titles fetch failed {code}: {str(body)[:200]}")
|
||||
titles = body.get("titles") or []
|
||||
if not titles:
|
||||
pytest.skip(f"{service}: no titles returned")
|
||||
title_id = titles[0]["id"]
|
||||
r = http_session.post(
|
||||
f"{server_url}/api/session/{sid}/tracks",
|
||||
json={"title_id": title_id},
|
||||
timeout=240,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
# Server returns a flat payload with `video`, `audio`, `subtitles`,
|
||||
# `chapters`, `manifests`, `attachments` keys.
|
||||
assert "video" in body or "audio" in body, body
|
||||
assert body.get("video") or body.get("audio"), body
|
||||
finally:
|
||||
_delete_session(http_session, server_url, sid)
|
||||
|
||||
|
||||
def test_session_delete_idempotent_returns_404_after(http_session, server_url: str, service_case) -> None:
|
||||
service, conf = service_case
|
||||
sid = _create_session(http_session, server_url, service, conf)
|
||||
_delete_session(http_session, server_url, sid)
|
||||
|
||||
r = http_session.delete(f"{server_url}/api/session/{sid}", timeout=30)
|
||||
assert r.status_code in (404, 200) # tolerate both depending on server semantics
|
||||
Reference in New Issue
Block a user