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:
imSp4rky
2026-05-21 10:45:25 -06:00
parent 9c905ef7a3
commit 746b573711
29 changed files with 2541 additions and 0 deletions

View File

View 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)

View 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

View 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)

View 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)

View 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

View 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)

View 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)

View 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")

View 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)

View 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