Files
magnus919_agent-skills/jellyfin/scripts/test_jellyfin_cli.py
T
Magnus Hedemarkandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 08689dfd8f fix(media-home): repair scrutiny round-1 doc-vs-reality findings
jellyfin: send the access token over exactly ONE channel per request (drop
the simultaneous X-Emby-Token header; the MediaBrowser Token= parameter is
the sole transport, legacy fallback remains documented as substitute-never-
stack and is request-capture tested); remove the dead no-op conditional in
JellyfinClient.__init__; make the login error-path test exception-safe with
patch.object; document the test-pinned dry-run plan keys and the actual
0/1/2 exit-code mapping in worked-recipes and SKILL.md.

peertube: replace the stale dry-run shape prose ('url'/'form') with the
test-pinned {dry_run, method, path, params} / form_fields keys; harden
cmd_me against a non-dict role (no AttributeError) with regression tests;
remove the dead client facade, the unused cmd_channels variable, and the
unused List/Tuple imports (ruff F401/F841 clean).

ghost: fold the 5 nested with-statements (ruff SIM117) in test_ghost.py
into single with-statements.

All three skills double-runner + proxy-trap green (25/56/37 tests);
validate-evals, paired smoke, quality validator, core gates, and catalog
check modes green.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-29 20:32:06 -04:00

617 lines
29 KiB
Python

import contextlib
import importlib.machinery
import importlib.util
import io
import json
import os
import pathlib
import subprocess
import tempfile
import unittest
from unittest.mock import Mock, patch
SCRIPT = pathlib.Path(__file__).resolve().parent / "jellyfin"
LOADER = importlib.machinery.SourceFileLoader("jellyfin_cli", str(SCRIPT))
SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER)
jellyfin_cli = importlib.util.module_from_spec(SPEC)
LOADER.exec_module(jellyfin_cli)
def clean_env():
env = os.environ.copy()
for var in ("JELLYFIN_URL", "JELLYFIN_API_KEY", "JELLYFIN_TOKEN",
"JELLYFIN_USER_ID", "JELLYFIN_DEVICE_ID", "JELLYFIN_PASSWORD"):
env.pop(var, None)
return env
class FakeResponse:
def __init__(self, status_code=200, json_body=None, text="", headers=None):
self.status_code = status_code
self._json = json_body
self.text = text or (json.dumps(json_body) if json_body is not None else "")
self.headers = headers or {}
def json(self):
if self._json is None:
raise ValueError("no json")
return self._json
class FakeClient:
def __init__(self, libraries=None, dry_run=False):
self.dry_run = dry_run
self.libraries = libraries
self.recent_calls = []
def get_libraries(self):
return self.libraries
def get_recent(self, user_id, limit=10, include_types=None):
self.recent_calls.append((user_id, limit, include_types))
return [{"Name": "Arrival", "Type": "Movie", "Id": "movie-1"}]
class NavigationFakeClient:
def __init__(self, dry_run=False):
self.dry_run = dry_run
self.next_up_calls = []
self.item_calls = []
self.items_calls = []
self.seasons_calls = []
self.episodes_calls = []
def get_next_up(self, user_id, limit=10, series_id=None):
self.next_up_calls.append((user_id, limit, series_id))
return {
"Items": [{"Name": "The Signal", "Type": "Episode", "Id": "episode-1",
"SeriesName": "Voyagers", "IndexNumber": 4}],
"StartIndex": 0,
"TotalRecordCount": 9,
}
def get_item(self, item_id, user_id):
self.item_calls.append((item_id, user_id))
return {"Name": "The Signal", "Type": "Episode", "Id": item_id,
"SeriesName": "Voyagers", "IndexNumber": 4,
"Overview": "A message arrives."}
def get_items(self, parent_id, types=None, limit=50, sort_by="SortName",
sort_order="Ascending", start_index=0, user_id=None):
self.items_calls.append((parent_id, types, limit, sort_by, sort_order,
start_index, user_id))
return {
"Items": [{"Name": "Arrival", "Type": "Movie", "Id": "movie-1",
"ProductionYear": 2016}],
"StartIndex": start_index,
"TotalRecordCount": 1,
}
def get_seasons(self, series_id, user_id):
self.seasons_calls.append((series_id, user_id))
return {"Items": [{"Name": "Season 1", "Type": "Season", "Id": "season-1",
"ParentIndexNumber": 1}],
"TotalRecordCount": 1}
def get_episodes(self, series_id, season_id, user_id):
self.episodes_calls.append((series_id, season_id, user_id))
return {"Items": [{"Name": "The Signal", "Type": "Episode", "Id": "episode-1",
"ParentIndexNumber": 1, "IndexNumber": 4}],
"TotalRecordCount": 1}
class JellyfinCliTests(unittest.TestCase):
def setUp(self):
self.flags = jellyfin_cli.GLOBAL_FLAGS
self.env_user_id = jellyfin_cli.ENV_USER_ID
jellyfin_cli.GLOBAL_FLAGS = {"json": True, "dry_run": False}
def tearDown(self):
jellyfin_cli.GLOBAL_FLAGS = self.flags
jellyfin_cli.ENV_USER_ID = self.env_user_id
def test_hardened_recent_and_libraries_contracts(self):
output = io.StringIO()
libraries = FakeClient(libraries={"Items": [{"Name": "Films", "Id": "lib-1", "CollectionType": "movies"}]})
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_libraries(libraries, [])
self.assertEqual(json.loads(output.getvalue())["libraries"][0]["name"], "Films")
calls = []
client = jellyfin_cli.JellyfinClient()
client._get = lambda path, params=None: calls.append((path, params)) or []
client.get_recent("user-1", limit=3, include_types=["Movie", "Episode"])
self.assertEqual(calls, [("/Items/Latest", {"userId": "user-1", "includeItemTypes": "Movie,Episode", "limit": 3, "fields": "DateCreated"})])
recent = FakeClient()
with contextlib.redirect_stdout(io.StringIO()):
jellyfin_cli.cmd_recent(recent, ["--user-id", "user-1", "--movies", "--limit", "2"])
self.assertEqual(recent.recent_calls, [("user-1", 2, ["Movie"])])
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
jellyfin_cli.cmd_recent(recent, ["--movies", "--episodes"])
result = subprocess.run(
[str(SCRIPT), "recent", "--movies", "--episodes"],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 2)
self.assertIn("not allowed with argument", result.stderr)
def test_recent_requires_user_before_network_and_dry_run_previews_request(self):
jellyfin_cli.ENV_USER_ID = ""
client = FakeClient()
error = io.StringIO()
with contextlib.redirect_stderr(error), self.assertRaises(SystemExit):
jellyfin_cli.cmd_recent(client, [])
self.assertIn("JELLYFIN_USER_ID", error.getvalue())
self.assertEqual(client.recent_calls, [])
dry_run = FakeClient(dry_run=True)
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_recent(dry_run, ["--movies", "--limit", "2"])
self.assertEqual(json.loads(output.getvalue()), {
"dry_run": True,
"path": "/Items/Latest",
"params": {"userId": None, "includeItemTypes": "Movie", "limit": 2, "fields": "DateCreated"},
})
self.assertEqual(dry_run.recent_calls, [])
def test_navigation_client_contracts(self):
calls = []
client = jellyfin_cli.JellyfinClient()
client._get = lambda path, params=None: calls.append((path, params)) or {}
client.get_next_up("user-1", limit=3, series_id="series-1")
client.get_item("item-1", "user-1")
client.get_items("library-1", types=["Movie", "Series"], limit=4, start_index=2,
user_id="user-1")
self.assertEqual(calls, [
("/Shows/NextUp", {"userId": "user-1", "limit": 3, "seriesId": "series-1"}),
("/Items/item-1", {"userId": "user-1"}),
("/Items", {"parentId": "library-1", "limit": 4, "sortBy": "SortName",
"sortOrder": "Ascending", "startIndex": 2, "recursive": True,
"userId": "user-1",
"includeItemTypes": "Movie,Series"}),
])
def test_authorization_header_builds_media_browser_scheme(self):
header = jellyfin_cli.build_authorization_header("dev-42")
self.assertTrue(header.startswith("MediaBrowser "))
self.assertIn('Client="jellyfin-cli"', header)
self.assertIn('DeviceId="dev-42"', header)
for required in ("Client=", "Device=", "DeviceId=", "Version="):
self.assertIn(required, header)
self.assertNotIn("Token=", header) # pre-token form carries no token segment
with_token = jellyfin_cli.build_authorization_header("dev-42", token="tok-1")
self.assertIn('Token="tok-1"', with_token)
def test_next_up_item_and_browse_parse_results_as_json(self):
client = NavigationFakeClient()
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_next_up(client, ["--user-id", "user-1", "--limit", "3"])
self.assertEqual(json.loads(output.getvalue()), {
"items": [{"id": "episode-1", "name": "The Signal", "type": "Episode",
"series": "Voyagers", "episode_number": 4}],
"total_record_count": 9,
})
self.assertEqual(client.next_up_calls, [("user-1", 3, None)])
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_item(client, ["--id", "episode-1", "--user-id", "user-1"])
self.assertEqual(json.loads(output.getvalue()), {
"id": "episode-1", "name": "The Signal", "type": "Episode",
"series": "Voyagers", "episode_number": 4, "overview": "A message arrives.",
})
self.assertEqual(client.item_calls, [("episode-1", "user-1")])
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_browse(client, ["--library-id", "library-1", "--type", "Movie",
"--limit", "4", "--start-index", "2",
"--user-id", "user-1"])
self.assertEqual(json.loads(output.getvalue()), {
"items": [{"id": "movie-1", "name": "Arrival", "type": "Movie", "year": 2016}],
"start_index": 2,
"total_record_count": 1,
})
self.assertEqual(client.items_calls,
[("library-1", ["Movie"], 4, "SortName", "Ascending", 2, "user-1")])
def test_user_scoped_navigation_requires_user_before_network(self):
jellyfin_cli.ENV_USER_ID = ""
for handler, arguments in (
(jellyfin_cli.cmd_next_up, []),
(jellyfin_cli.cmd_item, ["--id", "item-1"]),
):
client = NavigationFakeClient()
with self.subTest(handler=handler.__name__), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
handler(client, arguments)
self.assertEqual(client.next_up_calls, [])
self.assertEqual(client.item_calls, [])
def test_navigation_dry_runs_do_not_call_network_and_emit_requests(self):
cases = (
(jellyfin_cli.cmd_next_up, ["--limit", "3"], {"path": "/Shows/NextUp", "params": {"userId": None, "limit": 3}}),
(jellyfin_cli.cmd_next_up, ["--limit", "3", "--series-id", "s1"], {"path": "/Shows/NextUp", "params": {"userId": None, "limit": 3, "seriesId": "s1"}}),
(jellyfin_cli.cmd_item, ["--id", "item-1"], {"path": "/Items/item-1", "params": {"userId": None}}),
(jellyfin_cli.cmd_browse, ["--library-id", "library-1", "--type", "Movie,Series", "--limit", "4", "--start-index", "2"], {"path": "/Items", "params": {"parentId": "library-1", "limit": 4, "sortBy": "SortName", "sortOrder": "Ascending", "startIndex": 2, "recursive": True, "includeItemTypes": "Movie,Series"}}),
)
for handler, arguments, request in cases:
client = NavigationFakeClient(dry_run=True)
output = io.StringIO()
with self.subTest(handler=handler.__name__), contextlib.redirect_stdout(output):
handler(client, arguments)
self.assertEqual(json.loads(output.getvalue()), {"dry_run": True, **request})
self.assertEqual(client.next_up_calls, [])
self.assertEqual(client.item_calls, [])
self.assertEqual(client.items_calls, [])
def test_navigation_commands_dispatch_and_leaf_help_has_examples(self):
for command, arguments in (
("next-up", ["--user-id", "user-1", "--limit", "2"]),
("item", ["--id", "item-1", "--user-id", "user-1"]),
("browse", ["--library-id", "library-1", "--limit", "2"]),
):
result = subprocess.run([str(SCRIPT), "--json", "--dry-run", command, *arguments],
capture_output=True, text=True)
with self.subTest(command=command):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(json.loads(result.stdout)["dry_run"])
help_result = subprocess.run([str(SCRIPT), command, "--help"], capture_output=True, text=True)
with self.subTest(help_command=command):
self.assertEqual(help_result.returncode, 0)
self.assertIn("Example:", help_result.stdout)
class LoginAuthSequenceTests(unittest.TestCase):
"""The login path must demonstrate the researched auth sequence:
complete pre-token MediaBrowser header on POST /Users/AuthenticateByName,
AccessToken returned, Token= header for everything after."""
def run_cli(self, *args):
return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True,
env=clean_env(), cwd=tempfile.gettempdir())
def test_help_lists_login_and_every_subcommand(self):
result = self.run_cli("--help")
self.assertEqual(result.returncode, 0)
for noun in ("login", "info", "recent", "search", "next-up", "item",
"seasons", "episodes", "browse", "libraries", "stats"):
self.assertIn(noun, result.stdout)
def test_login_requires_username(self):
result = self.run_cli("login")
self.assertNotEqual(result.returncode, 0)
self.assertIn("--username", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_login_dry_run_previews_pre_token_header_without_network(self):
result = self.run_cli("--dry-run", "--json", "login", "--username", "alice")
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(result.stdout)
self.assertTrue(payload["dry_run"])
self.assertEqual(payload["path"], "/Users/AuthenticateByName")
header = payload["authorization_header"]
self.assertIn("MediaBrowser", header)
for part in ("Client=", "Device=", "DeviceId=", "Version="):
self.assertIn(part, header)
self.assertNotIn("Token=", header) # pre-token: no token exists yet
self.assertTrue(payload["pre_token_header"])
def test_login_strips_trailing_slash_from_server_override(self):
result = self.run_cli("--dry-run", "--json", "login", "--username", "bob",
"--server", "http://box.local:8096/")
payload = json.loads(result.stdout)
self.assertEqual(payload["server"], "http://box.local:8096")
def test_mocked_login_sends_media_browser_header_and_returns_session(self):
cli = jellyfin_cli
cli.GLOBAL_FLAGS = {"json": True, "dry_run": False}
captured = {}
response = FakeResponse(200, json_body={
"User": {"Name": "alice", "Id": "6eec632a-ff0d-4d09-aad0-bf9e90b14bc6"},
"SessionInfo": {"UserId": "6eec632a-ff0d-4d09-aad0-bf9e90b14bc6"},
"AccessToken": "at-1234",
"ServerId": "srv-1",
})
with patch.object(cli.requests, "post") as poster:
poster.return_value = response
output = io.StringIO()
with contextlib.redirect_stdout(output):
cli.cmd_login(cli.JellyfinClient(url="http://s:8096", device_id="dev-9"),
["--username", "alice", "--password", "pw"])
captured["call"] = poster.call_args
cli.GLOBAL_FLAGS = {"json": False, "dry_run": False}
args, kwargs = captured["call"]
self.assertTrue(args[0].endswith("/Users/AuthenticateByName"))
header = kwargs["headers"]["Authorization"]
self.assertIn("MediaBrowser", header)
self.assertIn('DeviceId="dev-9"', header)
self.assertNotIn("Token=", header) # pre-token header on the login call itself
self.assertEqual(kwargs["json"], {"Username": "alice", "Pw": "pw"})
session = json.loads(output.getvalue())
self.assertEqual(session["user_id"], "6eec632a-ff0d-4d09-aad0-bf9e90b14bc6")
self.assertEqual(session["access_token"], "at-1234")
self.assertIn('Token="at-1234"', session["authorization_header"])
def test_mocked_login_error_paths_do_not_crash(self):
cli = jellyfin_cli
cli.GLOBAL_FLAGS = {"json": False, "dry_run": False}
for status, expected_fragment in ((400, "400"), (401, "401"), (403, "403")):
with self.subTest(status=status):
response = FakeResponse(status, text="Error processing request.")
with patch.object(cli.requests, "post", return_value=response), \
contextlib.redirect_stderr(io.StringIO()) as stderr, \
self.assertRaises(SystemExit):
cli.cmd_login(cli.JellyfinClient(url="http://s:8096", device_id="d"),
["--username", "alice", "--password", "bad"])
self.assertIn(expected_fragment, stderr.getvalue())
cli.GLOBAL_FLAGS = {"json": False, "dry_run": False}
def test_reads_require_a_credential_before_network(self):
cli = jellyfin_cli
client = cli.JellyfinClient()
client.key = ""
client.token = ""
error = io.StringIO()
with contextlib.redirect_stderr(error), self.assertRaises(SystemExit):
client._get("/System/Info")
self.assertIn("JELLYFIN_API_KEY", error.getvalue())
def test_client_headers_use_modern_authorization_scheme(self):
cli = jellyfin_cli
client = cli.JellyfinClient(key="k-1", device_id="dev-1")
headers = client._headers()
self.assertIn('Token="k-1"', headers["Authorization"])
self.assertTrue(headers["Authorization"].startswith("MediaBrowser "))
self.assertNotIn("X-Emby-Token", headers) # one token channel per request
token_client = cli.JellyfinClient(token="t-1", device_id="dev-1")
token_headers = token_client._headers()
self.assertIn('Token="t-1"', token_headers["Authorization"])
self.assertNotIn("X-Emby-Token", token_headers)
def test_captured_requests_carry_token_in_exactly_one_channel(self):
"""VAL-JF-009: the access token (or API key) appears in exactly ONE
channel per request — the MediaBrowser Token= parameter — and the
legacy X-Emby-Token header is never sent alongside it."""
cli = jellyfin_cli
captured = []
response = FakeResponse(200, json_body={"Items": [], "TotalRecordCount": 0})
for client in (cli.JellyfinClient(key="k-capture", device_id="dev-cap"),
cli.JellyfinClient(token="t-capture", device_id="dev-cap")):
with patch.object(cli.requests, "get",
side_effect=lambda url, **kw: captured.append(kw) or response):
client._get("/Items")
self.assertEqual(len(captured), 2)
for kwargs in captured:
self.assertIn("headers", kwargs)
headers = kwargs["headers"]
self.assertIn("Token=", headers["Authorization"])
self.assertNotIn("X-Emby-Token", headers)
channel_count = sum(
1 for value in headers.values()
if "Token=" in value or value.lower() == "x-emby-token"
)
self.assertEqual(channel_count, 1)
class TvNavigationCommandTests(unittest.TestCase):
"""seasons/episodes commands close the search → seasons → episodes walk."""
def setUp(self):
self.flags = jellyfin_cli.GLOBAL_FLAGS
self.env_user_id = jellyfin_cli.ENV_USER_ID
jellyfin_cli.GLOBAL_FLAGS = {"json": True, "dry_run": False}
jellyfin_cli.ENV_USER_ID = ""
def tearDown(self):
jellyfin_cli.GLOBAL_FLAGS = self.flags
jellyfin_cli.ENV_USER_ID = self.env_user_id
def test_seasons_and_episodes_emit_items_and_consume_ids(self):
client = NavigationFakeClient()
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_seasons(client, ["--series-id", "series-1",
"--user-id", "user-1"])
seasons = json.loads(output.getvalue())
self.assertEqual(seasons["items"][0]["name"], "Season 1")
self.assertEqual(seasons["items"][0]["season_number"], 1)
self.assertEqual(client.seasons_calls, [("series-1", "user-1")])
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_episodes(client, ["--series-id", "series-1",
"--season-id", "season-1",
"--user-id", "user-1"])
episodes = json.loads(output.getvalue())
self.assertEqual(episodes["items"][0]["episode_number"], 4)
self.assertEqual(client.episodes_calls, [("series-1", "season-1", "user-1")])
def test_seasons_and_episodes_require_user_before_network(self):
for handler, arguments in (
(jellyfin_cli.cmd_seasons, ["--series-id", "series-1"]),
(jellyfin_cli.cmd_episodes, ["--series-id", "series-1"]),
):
client = NavigationFakeClient()
with self.subTest(handler=handler.__name__), \
contextlib.redirect_stderr(io.StringIO()), \
self.assertRaises(SystemExit):
handler(client, arguments)
self.assertEqual(client.seasons_calls, [])
self.assertEqual(client.episodes_calls, [])
def test_seasons_and_episodes_dry_run_previews_requests(self):
cases = (
(jellyfin_cli.cmd_seasons, ["--series-id", "s1"],
{"path": "/Shows/s1/Seasons", "params": {"userId": None}}),
(jellyfin_cli.cmd_episodes, ["--series-id", "s1", "--season-id", "se1",
"--limit", "7"],
{"path": "/Shows/s1/Episodes",
"params": {"userId": None, "limit": 7, "startIndex": 0,
"seasonId": "se1"}}),
)
for handler, arguments, request in cases:
client = NavigationFakeClient(dry_run=True)
output = io.StringIO()
with self.subTest(handler=handler.__name__), \
contextlib.redirect_stdout(output):
handler(client, arguments)
self.assertEqual(json.loads(output.getvalue()),
{"dry_run": True, **request})
self.assertEqual(client.seasons_calls, [])
self.assertEqual(client.episodes_calls, [])
class SearchHintIdFallbackTests(unittest.TestCase):
"""SearchHint carries both Id and (deprecated) ItemId; newer servers omit ItemId."""
def test_prefers_current_id_falls_back_to_deprecated_item_id(self):
jellyfin_cli.GLOBAL_FLAGS = {"json": True, "dry_run": False}
try:
client = Mock()
client.dry_run = False
client.search = Mock(return_value={
"SearchHints": [
{"Id": "new-id", "ItemId": "legacy-id", "Name": "Both", "Type": "Series"},
{"Id": "only-new", "Name": "Modern", "Type": "Movie"},
{"ItemId": "legacy-only", "Name": "Old server", "Type": "Movie"},
],
"TotalRecordCount": 3,
})
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_search(client, ["--query", "dune"])
results = json.loads(output.getvalue())["results"]
self.assertEqual([r["id"] for r in results],
["new-id", "only-new", "legacy-only"])
finally:
jellyfin_cli.GLOBAL_FLAGS = {"json": False, "dry_run": False}
class SearchRequiresQueryTests(unittest.TestCase):
def test_missing_query_is_argument_error(self):
result = subprocess.run([str(SCRIPT), "search"], capture_output=True,
text=True, env=clean_env())
self.assertNotEqual(result.returncode, 0)
self.assertIn("--query", result.stderr)
self.assertNotIn("Traceback", result.stderr)
class PipelineChainTests(unittest.TestCase):
"""Documented multi-step pipelines must execute stage by stage, each stage
consuming the previous stage's emitted output (field names AND types)."""
@classmethod
def setUpClass(cls):
cls.tmpdir = tempfile.TemporaryDirectory(prefix="jellyfin-pipeline-")
@classmethod
def tearDownClass(cls):
cls.tmpdir.cleanup()
def run_cli(self, *args):
return subprocess.run([str(SCRIPT), "--dry-run", "--json", *args],
text=True, capture_output=True, env=clean_env(),
cwd=self.tmpdir.name)
def run_jq(self, *jq_args):
return subprocess.run(["jq", *jq_args], text=True, capture_output=True,
env=clean_env(), cwd=self.tmpdir.name)
def write_stage_file(self, name, document):
path = pathlib.Path(self.tmpdir.name) / name
path.write_text(json.dumps(document))
return str(path)
def test_search_then_item_chain_consumability(self):
# Stage 1: search plan; jq extracts the query field (string) the next
# stage would resolve into an item id.
r1 = self.run_cli("search", "--query", "dune", "--type", "Movie")
self.assertEqual(r1.returncode, 0, r1.stderr)
stage1 = self.write_stage_file("stage1.json", json.loads(r1.stdout))
term = self.run_jq("-r", ".params.searchTerm", stage1).stdout.strip()
self.assertEqual(term, "dune")
types_check = self.run_jq("-r", ".params.includeItemTypes | type", stage1)
self.assertEqual(types_check.stdout.strip(), "string")
# Stage 2: item detail plan consumes a hand-built id from stage 1's
# contract (results[].id is a string); jq proves the id travels into
# the request path.
r2 = self.run_cli("item", "--id", "movie-123", "--user-id", "user-9")
self.assertEqual(r2.returncode, 0, r2.stderr)
stage2 = self.write_stage_file("stage2.json", json.loads(r2.stdout))
item_path = self.run_jq("-r", ".path", stage2).stdout.strip()
self.assertEqual(item_path, "/Items/movie-123")
user_id = self.run_jq("-r", ".params.userId", stage2).stdout.strip()
self.assertEqual(user_id, "user-9")
# Stage 3: next-up plan consumes the same user id and proves it is a
# JSON string parameter on /Shows/NextUp.
r3 = self.run_cli("next-up", "--user-id", "user-9", "--limit", "5")
self.assertEqual(r3.returncode, 0, r3.stderr)
stage3 = self.write_stage_file("stage3.json", json.loads(r3.stdout))
self.assertEqual(self.run_jq("-r", ".params.userId", stage3).stdout.strip(),
"user-9")
self.assertEqual(self.run_jq("-r", ".path", stage3).stdout.strip(),
"/Shows/NextUp")
def test_libraries_then_browse_chain_consumability(self):
# Stage 1: libraries plan; jq type-checks the id field browse consumes.
r1 = self.run_cli("libraries")
self.assertEqual(r1.returncode, 0, r1.stderr)
stage1 = self.write_stage_file("stage1.json", json.loads(r1.stdout))
self.assertEqual(self.run_jq("-r", ".path", stage1).stdout.strip(),
"/Library/MediaFolders")
# Stage 2: browse plan consumes a library id and paginates by
# startIndex (number type asserted via jq).
r2 = self.run_cli("browse", "--library-id", "lib-77", "--start-index", "100")
self.assertEqual(r2.returncode, 0, r2.stderr)
stage2 = self.write_stage_file("stage2.json", json.loads(r2.stdout))
parent = self.run_jq("-r", ".params.parentId", stage2).stdout.strip()
self.assertEqual(parent, "lib-77")
start_index_type = self.run_jq("-r", ".params.startIndex | type", stage2)
self.assertEqual(start_index_type.stdout.strip(), "number")
# Stage 3: seasons plan consumes a series id discovered by browsing.
r3 = self.run_cli("seasons", "--series-id", "series-2", "--user-id", "user-1")
self.assertEqual(r3.returncode, 0, r3.stderr)
stage3 = self.write_stage_file("stage3.json", json.loads(r3.stdout))
self.assertEqual(self.run_jq("-r", ".path", stage3).stdout.strip(),
"/Shows/series-2/Seasons")
def test_login_to_recent_chain_previews_token_handoff(self):
# Stage 1: login plan emits the pre-token header the server requires.
r1 = self.run_cli("login", "--username", "alice")
self.assertEqual(r1.returncode, 0, r1.stderr)
stage1 = self.write_stage_file("stage1.json", json.loads(r1.stdout))
header = self.run_jq("-r", ".authorization_header", stage1).stdout.strip()
self.assertIn("MediaBrowser", header)
self.assertNotIn("Token=", header)
# Stage 2: user-scoped read plan; the user id login would capture is a
# string parameter on /Items/Latest (shape: bare array per research).
r2 = self.run_cli("recent", "--user-id", "6eec632a", "--limit", "20")
self.assertEqual(r2.returncode, 0, r2.stderr)
stage2 = self.write_stage_file("stage2.json", json.loads(r2.stdout))
self.assertEqual(self.run_jq("-r", ".path", stage2).stdout.strip(),
"/Items/Latest")
user_id_type = self.run_jq("-r", ".params.userId | type", stage2)
self.assertEqual(user_id_type.stdout.strip(), "string")
self.assertEqual(self.run_jq("-r", ".params.fields", stage2).stdout.strip(),
"DateCreated")
if __name__ == "__main__":
unittest.main()