Files
magnus919_agent-skills/peertube/scripts/test_peertube.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

991 lines
40 KiB
Python

"""Offline test suite for the bundled peertube CLI.
All HTTP is mocked at the requests seam; the only live call in the file is the
single anonymous instance probe behind the PEERTUBE_LIVE_TESTS=1 guard (skipped
by default, so the suite is fully offline and passes the proxy-trap rerun).
Covers: help output, argument-error paths, dry-run plans, mocked OAuth2 token
persistence/refresh/revocation, handler output contracts, and the documented
multi-step pipeline stages (each stage's output fields/types feed the next).
"""
import contextlib
import importlib.machinery
import importlib.util
import io
import json
import os
import pathlib
import stat
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
SCRIPT = pathlib.Path(__file__).resolve().parent / "peertube"
LOADER = importlib.machinery.SourceFileLoader("peertube_cli", str(SCRIPT))
SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER)
pt = importlib.util.module_from_spec(SPEC)
LOADER.exec_module(pt)
def clean_env():
env = os.environ.copy()
for var in ("PEERTUBE_SERVER", "PEERTUBE_CONFIG_DIR", "PEERTUBE_LIVE_TESTS"):
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 if text else (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 body")
return self._json
VIDEO_ONE = {
"id": 1,
"uuid": "uuid-one",
"shortUUID": "sOne1",
"url": "https://inst.example/w/uuid-one",
"name": "First video",
"duration": 125,
"views": 42,
"likes": 7,
"publishedAt": "2026-08-01T10:00:00.000Z",
"privacy": {"id": 1, "label": "Public"},
"account": {"name": "alice", "displayName": "Alice", "host": "inst.example"},
"channel": {"name": "alice-channel", "displayName": "Alice Channel", "host": "inst.example"},
}
VIDEO_TWO = dict(
VIDEO_ONE,
id=2,
uuid="uuid-two",
shortUUID="sTwo2",
name="Second video",
duration=3661,
views=5,
channel={"name": "bob-channel", "displayName": "Bob Channel", "host": "other.example"},
)
TOKEN_RESPONSE = {
"access_token": "tok-1",
"refresh_token": "ref-1",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token_expires_in": 7200,
}
OAUTH_CLIENT = {"client_id": "cid-1", "client_secret": "client-secret-1"}
MASKED_OAUTH_CLIENT = {"client_id": "cid-1", "client_secret": "*" * 32}
def run_cli(*args, env=None):
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True,
text=True,
env=env if env is not None else clean_env(),
)
class ModuleStateTestCase(unittest.TestCase):
"""Base that restores module globals mutated by in-process tests."""
def setUp(self):
self._flags = dict(pt.GLOBAL_FLAGS)
self._env_server = pt.ENV_SERVER
self._env_config = pt.ENV_CONFIG_DIR
pt.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
def tearDown(self):
pt.GLOBAL_FLAGS = self._flags
pt.ENV_SERVER = self._env_server
pt.ENV_CONFIG_DIR = self._env_config
class HelpOutputTests(unittest.TestCase):
"""Class 1: --help output."""
def test_help_lists_every_subcommand(self):
result = run_cli("--help")
self.assertEqual(result.returncode, 0, result.stderr)
for noun in (
"server",
"videos",
"video",
"search",
"comments",
"channels",
"channel",
"account",
"me",
"my-videos",
"login",
"logout",
):
self.assertIn(noun, result.stdout)
def test_help_names_the_instance_env_var(self):
result = run_cli("--help")
self.assertIn("PEERTUBE_SERVER", result.stdout)
self.assertIn("sepiasearch.org", result.stdout)
def test_search_help_states_its_scope(self):
result = run_cli("search", "--help")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("searchTarget", result.stdout)
self.assertIn("sepiasearch", result.stdout.lower())
def test_leaf_help_carries_examples(self):
for leaf in ("videos", "comments", "login", "logout"):
result = run_cli(leaf, "--help")
with self.subTest(leaf=leaf):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Example:", result.stdout)
class ArgumentErrorTests(unittest.TestCase):
"""Class 2: argument-error paths fail cleanly before any network call."""
def test_search_requires_query(self):
result = run_cli("search")
self.assertNotEqual(result.returncode, 0)
self.assertIn("--query", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_video_requires_id(self):
result = run_cli("video")
self.assertNotEqual(result.returncode, 0)
self.assertIn("--id", result.stderr)
def test_channel_requires_handle(self):
result = run_cli("channel")
self.assertNotEqual(result.returncode, 0)
self.assertIn("--handle", result.stderr)
def test_no_subcommand_prints_help_and_exits(self):
result = run_cli()
self.assertNotEqual(result.returncode, 0)
self.assertIn("usage", result.stdout)
def test_limit_above_server_maximum_rejected(self):
result = run_cli("--dry-run", "--json", "videos", "--limit", "101")
self.assertNotEqual(result.returncode, 0)
self.assertIn("100", result.stderr)
def test_limit_zero_rejected(self):
result = run_cli("--dry-run", "--json", "videos", "--limit", "0")
self.assertNotEqual(result.returncode, 0)
self.assertIn("--limit", result.stderr)
def test_negative_offset_rejected(self):
result = run_cli("--dry-run", "--json", "videos", "--offset", "-1")
self.assertNotEqual(result.returncode, 0)
self.assertIn("--offset", result.stderr)
def test_login_without_password_errors(self):
result = run_cli("login", "--username", "alice", "--server", "https://inst.example")
self.assertNotEqual(result.returncode, 0)
self.assertIn("password", result.stderr.lower())
self.assertNotIn("Traceback", result.stderr)
def test_missing_server_dies_before_network(self):
result = run_cli("videos", "--limit", "1")
self.assertNotEqual(result.returncode, 0)
self.assertIn("PEERTUBE_SERVER", result.stderr)
self.assertNotIn("Traceback", result.stderr)
class DryRunPlanTests(ModuleStateTestCase):
"""Class 3: --dry-run emits valid JSON plans with zero network activity."""
def run_json(self, *args):
pt.GLOBAL_FLAGS = {"json": True, "dry_run": True, "quiet": False, "verbose": False}
return io.StringIO()
def test_single_endpoint_plans_emit_method_path_params(self):
cases = (
(
pt.cmd_videos,
["--limit", "3"],
{
"method": "GET",
"path": "/api/v1/videos",
"params.start": 0,
"params.count": 3,
"params.sort": "-publishedAt",
},
),
(
pt.cmd_search,
["--query", "linux", "--limit", "5"],
{
"method": "GET",
"path": "/api/v1/search/videos",
"params.searchTarget": "local",
"params.search": "linux",
},
),
(
pt.cmd_video,
["--id", "uuid-one"],
{"method": "GET", "path": "/api/v1/videos/uuid-one"},
),
(
pt.cmd_comments,
["--id", "uuid-one"],
{"method": "GET", "path": "/api/v1/videos/uuid-one/comment-threads"},
),
(pt.cmd_channels, [], {"method": "GET", "path": "/api/v1/video-channels"}),
(pt.cmd_me, [], {"method": "GET", "path": "/api/v1/users/me"}),
(pt.cmd_my_videos, [], {"method": "GET", "path": "/api/v1/users/me/videos"}),
(pt.cmd_logout, [], {"method": "POST", "path": "/api/v1/users/revoke-token"}),
)
for handler, args, expectations in cases:
with self.subTest(handler=handler.__name__):
client = pt.PeerTubeClient(
server="https://inst.example",
dry_run=True,
config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
)
out = io.StringIO()
with contextlib.redirect_stdout(out):
handler(client, args)
plan = json.loads(out.getvalue())
self.assertTrue(plan["dry_run"])
self.assertEqual(plan["method"], expectations["method"])
self.assertEqual(plan["path"], expectations["path"])
def test_dry_run_videos_plan_never_sends_page_param(self):
client = pt.PeerTubeClient(
server="https://inst.example",
dry_run=True,
config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
)
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_videos(client, ["--limit", "9", "--offset", "18"])
plan = json.loads(out.getvalue())
self.assertNotIn("page", plan["params"])
self.assertEqual(plan["params"]["start"], 18)
self.assertEqual(plan["params"]["count"], 9)
def test_search_plan_defaults_to_instance_local_scope(self):
client = pt.PeerTubeClient(
server="https://inst.example",
dry_run=True,
config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
)
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_search(client, ["--query", "peertube"])
plan = json.loads(out.getvalue())
self.assertEqual(plan["params"]["searchTarget"], "local")
def test_composite_commands_plan_every_request(self):
client = pt.PeerTubeClient(
server="https://inst.example",
dry_run=True,
config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
)
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_server(client, [])
plan = json.loads(out.getvalue())
paths = [req["path"] for req in plan["requests"]]
self.assertEqual(paths, ["/api/v1/config/about", "/api/v1/server/stats"])
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_channel(client, ["--handle", "alice-channel"])
plan = json.loads(out.getvalue())
paths = [req["path"] for req in plan["requests"]]
self.assertEqual(
paths,
["/api/v1/video-channels/alice-channel", "/api/v1/video-channels/alice-channel/videos"],
)
def test_login_dry_run_lists_form_fields_without_values(self):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_login(
pt.PeerTubeClient(
server="https://inst.example",
dry_run=True,
config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
),
["--username", "alice"],
)
plan = json.loads(out.getvalue())
self.assertTrue(plan["dry_run"])
self.assertEqual(plan["path"], "/api/v1/users/token")
self.assertIn("grant_type", plan["form_fields"])
self.assertIn("client_secret", plan["form_fields"])
self.assertNotIn("form", plan) # no values leak in the plan
def test_dry_run_works_without_any_server_configured(self):
pt.ENV_SERVER = ""
client = pt.PeerTubeClient(
server="", dry_run=True, config_dir=tempfile.mkdtemp(prefix="pt-dry-")
)
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_videos(client, ["--limit", "2"])
self.assertTrue(json.loads(out.getvalue())["dry_run"])
def test_dry_run_never_touches_network(self):
client = pt.PeerTubeClient(
server="https://inst.example",
dry_run=True,
config_dir=tempfile.mkdtemp(prefix="pt-dry-"),
)
with (
patch.object(pt.requests, "get") as getter,
patch.object(pt.requests, "post") as poster,
):
for handler, args in (
(pt.cmd_videos, ["--limit", "2"]),
(pt.cmd_search, ["--query", "x"]),
(pt.cmd_server, []),
(pt.cmd_me, []),
(pt.cmd_login, ["--username", "a"]),
(pt.cmd_logout, []),
):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
handler(client, args)
getter.assert_not_called()
poster.assert_not_called()
def test_flags_work_before_and_after_subcommand(self):
result = run_cli("--json", "--dry-run", "search", "--query", "x", "--limit", "2")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(json.loads(result.stdout)["dry_run"])
result = run_cli("search", "--query", "x", "--limit", "2", "--json", "--dry-run")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(json.loads(result.stdout)["dry_run"])
class ClientContractTests(ModuleStateTestCase):
"""Class 4a: mocked requests — paths, params, and error handling."""
def mocked_get(self, responses, server="https://inst.example"):
client = pt.PeerTubeClient(server=server, config_dir=tempfile.mkdtemp(prefix="pt-cc-"))
return client, patch.object(pt.requests, "get", side_effect=responses)
def test_video_listing_sends_start_count_sort(self):
client, patcher = self.mocked_get(
[FakeResponse(200, {"total": 2, "data": [VIDEO_ONE, VIDEO_TWO]})]
)
with patcher as getter:
pt.cmd_videos(client, ["--limit", "2", "--offset", "10"])
args, kwargs = getter.call_args
self.assertEqual(args[0], "https://inst.example/api/v1/videos")
self.assertEqual(kwargs["params"], {"start": 10, "count": 2, "sort": "-publishedAt"})
self.assertNotIn("page", kwargs["params"])
def test_search_defaults_to_local_target_and_omits_empty_sort(self):
client, patcher = self.mocked_get([FakeResponse(200, {"total": 0, "data": []})])
with patcher as getter:
pt.cmd_search(client, ["--query", "linux"])
params = getter.call_args[1]["params"]
self.assertEqual(params["searchTarget"], "local")
self.assertEqual(params["search"], "linux")
self.assertNotIn("sort", params)
def test_comment_threads_route_is_hyphenated(self):
client, patcher = self.mocked_get(
[FakeResponse(200, {"total": 0, "totalNotDeletedComments": 0, "data": []})]
)
with patcher as getter:
pt.cmd_comments(client, ["--id", "uuid-one"])
self.assertEqual(
getter.call_args[0][0], "https://inst.example/api/v1/videos/uuid-one/comment-threads"
)
def test_server_composes_about_and_stats(self):
about = FakeResponse(
200, {"instance": {"name": "Inst", "shortDescription": "Desc", "description": "Long"}}
)
stats = FakeResponse(
200,
{
"totalUsers": 9,
"totalLocalVideos": 933,
"totalVideos": 23890,
"totalLocalVideoViews": 1001751,
"totalLocalVideoDownloads": 32569,
"totalLocalVideoChannels": 28,
},
)
client, patcher = self.mocked_get([about, stats])
with patcher:
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_server(client, [])
payload = json.loads(out.getvalue())
self.assertEqual(payload["instance"]["name"], "Inst")
self.assertEqual(payload["stats"]["totalLocalVideos"], 933)
self.assertIsInstance(payload["stats"]["totalUsers"], int)
def test_401_names_login_remedy(self):
client, patcher = self.mocked_get([FakeResponse(401, {"detail": "token expired"})])
client._token = "stale-token" # authed command proceeds, then server rejects
with patcher:
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_me(client, [])
self.assertIn("401", err.getvalue())
self.assertIn("login", err.getvalue().lower())
def test_429_surfaces_retry_after(self):
client, patcher = self.mocked_get(
[FakeResponse(429, {"detail": "rate limit"}, headers={"Retry-After": "7"})]
)
with patcher:
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_videos(client, ["--limit", "2"])
self.assertIn("429", err.getvalue())
self.assertIn("Retry-After", err.getvalue())
def test_rfc7807_detail_extracted_on_generic_error(self):
client, patcher = self.mocked_get(
[
FakeResponse(
400,
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "unknown route shape",
},
)
]
)
with patcher:
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_videos(client, ["--limit", "2"])
self.assertIn("unknown route shape", err.getvalue())
def test_non_json_instance_response_is_diagnosed(self):
client, patcher = self.mocked_get([FakeResponse(200, text="<html>not peertube</html>")])
with patcher:
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_videos(client, ["--limit", "2"])
self.assertIn("Non-JSON", err.getvalue())
class OAuthFlowTests(ModuleStateTestCase):
"""Class 4b: mocked OAuth2 — client fetch, password grant, persistence,
refresh, revocation. Token files live in TemporaryDirectories only."""
def setUp(self):
super().setUp()
self.tmp = tempfile.TemporaryDirectory(prefix="pt-oauth-")
self.config_dir = self.tmp.name
pt.ENV_SERVER = "https://inst.example"
def tearDown(self):
self.tmp.cleanup()
super().tearDown()
def client(self, **kwargs):
return pt.PeerTubeClient(
server="https://inst.example", config_dir=self.config_dir, **kwargs
)
def test_fetch_oauth_client_hits_singular_local_route(self):
client = self.client()
with patch.object(
pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)
) as getter:
client_id, client_secret = client.fetch_oauth_client()
self.assertEqual(getter.call_args[0][0], "https://inst.example/api/v1/oauth-clients/local")
self.assertEqual((client_id, client_secret), ("cid-1", "client-secret-1"))
def test_password_grant_sends_form_encoded_fields(self):
client = self.client()
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
patch.object(
pt.requests, "post", return_value=FakeResponse(200, TOKEN_RESPONSE)
) as poster,
):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
args, kwargs = poster.call_args
self.assertEqual(args[0], "https://inst.example/api/v1/users/token")
form = kwargs["data"]
self.assertEqual(form["grant_type"], "password")
self.assertEqual(form["username"], "alice")
self.assertEqual(form["client_id"], "cid-1")
self.assertNotIn("response_type", form) # not part of the documented schema
payload = json.loads(out.getvalue())
self.assertEqual(payload["status"], "logged_in")
def test_bad_password_400_exits_with_guidance(self):
client = self.client()
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
patch.object(
pt.requests, "post", return_value=FakeResponse(400, {"detail": "invalid_grant"})
),
):
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_login(client, ["--username", "alice", "--password", "wrong"])
self.assertIn("400", err.getvalue())
self.assertIn("invalid_grant", err.getvalue())
def test_two_factor_401_suggests_otp_flag(self):
client = self.client()
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
patch.object(pt.requests, "post", return_value=FakeResponse(401, {})),
):
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
self.assertIn("--otp", err.getvalue())
def test_otp_header_attached_when_provided(self):
client = self.client()
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
patch.object(
pt.requests, "post", return_value=FakeResponse(200, TOKEN_RESPONSE)
) as poster,
):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_login(client, ["--username", "alice", "--password", "pw", "--otp", "123456"])
self.assertEqual(poster.call_args[1]["headers"]["x-peertube-otp"], "123456")
def test_masked_client_secret_stops_login_with_guidance(self):
client = self.client()
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, MASKED_OAUTH_CLIENT)),
patch.object(pt.requests, "post") as poster,
):
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
self.assertIn("masks client_secret", err.getvalue())
poster.assert_not_called()
def test_is_masked_secret_detection(self):
self.assertTrue(pt.is_masked_secret("*" * 32))
self.assertFalse(pt.is_masked_secret("client-secret-1"))
self.assertFalse(pt.is_masked_secret(""))
self.assertFalse(pt.is_masked_secret("*-mixed-*"))
def test_token_file_persisted_owner_only_with_expiry(self):
client = self.client()
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
patch.object(pt.requests, "post", return_value=FakeResponse(200, TOKEN_RESPONSE)),
):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
token_path = client.token_path()
self.assertTrue(os.path.isfile(token_path))
mode = stat.S_IMODE(os.stat(token_path).st_mode)
self.assertEqual(mode & 0o077, 0, "token file must be owner-only")
with open(token_path) as handle:
record = json.load(handle)
self.assertEqual(record["server"], "https://inst.example")
self.assertEqual(record["access_token"], "tok-1")
self.assertEqual(record["refresh_token"], "ref-1")
self.assertIsNotNone(record["expires_at"])
self.assertGreater(record["expires_at"], time.time())
self.assertLess(record["expires_at"], time.time() + 7200)
def test_token_from_another_instance_is_ignored(self):
client = self.client()
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
patch.object(pt.requests, "post", return_value=FakeResponse(200, TOKEN_RESPONSE)),
):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
pt.cmd_login(client, ["--username", "alice", "--password", "pw"])
other = pt.PeerTubeClient(server="https://other.example", config_dir=self.config_dir)
self.assertIsNone(other._token)
def test_expired_token_triggers_refresh_then_success(self):
client = self.client()
client.save_session(dict(TOKEN_RESPONSE, expires_in=-10)) # already expired
refreshed = dict(TOKEN_RESPONSE, access_token="tok-2", expires_in=3600)
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
patch.object(pt.requests, "post", return_value=FakeResponse(200, refreshed)) as poster,
):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
pt.cmd_me(client, [])
form = poster.call_args[1]["data"]
self.assertEqual(form["grant_type"], "refresh_token")
self.assertEqual(form["refresh_token"], "ref-1")
self.assertEqual(client._token, "tok-2")
with open(client.token_path()) as handle:
self.assertEqual(json.load(handle)["access_token"], "tok-2")
def test_failed_refresh_falls_back_to_login_guidance(self):
client = self.client()
client.save_session(dict(TOKEN_RESPONSE, expires_in=-10))
with (
patch.object(pt.requests, "get", return_value=FakeResponse(200, OAUTH_CLIENT)),
patch.object(pt.requests, "post", return_value=FakeResponse(400, {})),
):
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_me(client, [])
self.assertIn("Not authenticated", err.getvalue())
def test_logout_revokes_and_deletes_token_file(self):
client = self.client()
client.save_session(TOKEN_RESPONSE)
self.assertTrue(os.path.isfile(client.token_path()))
with patch.object(pt.requests, "post", return_value=FakeResponse(200, {})) as poster:
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_logout(client, [])
args, kwargs = poster.call_args
self.assertEqual(args[0], "https://inst.example/api/v1/users/revoke-token")
self.assertEqual(kwargs["headers"]["Authorization"], "Bearer tok-1")
self.assertFalse(os.path.exists(client.token_path()))
def test_logout_keeps_file_when_revocation_fails(self):
client = self.client()
client.save_session(TOKEN_RESPONSE)
with patch.object(pt.requests, "post", return_value=FakeResponse(500, {})):
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_logout(client, [])
self.assertTrue(os.path.isfile(client.token_path()))
def test_logout_without_token_errors_cleanly(self):
client = self.client()
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
pt.cmd_logout(client, [])
self.assertIn("No stored token", err.getvalue())
class HandlerOutputTests(ModuleStateTestCase):
"""Class 4c: handler output contracts consumed by jq pipelines."""
def test_videos_output_carries_raw_video_objects(self):
client = pt.PeerTubeClient(
server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
)
with patch.object(
pt.requests,
"get",
return_value=FakeResponse(200, {"total": 2, "data": [VIDEO_ONE, VIDEO_TWO]}),
):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_videos(client, ["--limit", "2"])
payload = json.loads(out.getvalue())
self.assertEqual(payload["total"], 2)
self.assertEqual(payload["start"], 0)
self.assertEqual(payload["count"], 2)
self.assertIsInstance(payload["videos"], list)
first = payload["videos"][0]
self.assertEqual(first["uuid"], "uuid-one")
self.assertIsInstance(first["duration"], int) # seconds
self.assertEqual(first["channel"]["host"], "inst.example")
def test_search_output_marks_scope_and_carries_uuids(self):
client = pt.PeerTubeClient(
server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
)
with patch.object(
pt.requests, "get", return_value=FakeResponse(200, {"total": 1, "data": [VIDEO_TWO]})
):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_search(client, ["--query", "x"])
payload = json.loads(out.getvalue())
self.assertEqual(payload["videos"][0]["uuid"], "uuid-two")
def test_me_tolerates_docs_array_sample_and_object(self):
client = pt.PeerTubeClient(
server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
)
profile = {
"username": "alice",
"role": {"id": 1, "label": "User"},
"videoQuota": 1073741824,
"videoChannels": [],
}
for body in (profile, [profile]):
client.save_session(TOKEN_RESPONSE)
with patch.object(pt.requests, "get", return_value=FakeResponse(200, body)):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_me(client, [])
payload = json.loads(out.getvalue())
self.assertEqual(payload["username"], "alice")
self.assertEqual(payload["role"]["label"], "User")
client.clear_token()
def test_me_tolerates_scalar_role_without_attribute_error(self):
"""VAL-PT-010: a non-dict `role` (scalar id from a version-drifted
server) must degrade to a readable line, never AttributeError."""
client = pt.PeerTubeClient(
server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
)
profile = {"username": "bob", "role": 2, "videoQuota": None}
client.save_session(TOKEN_RESPONSE)
with patch.object(pt.requests, "get", return_value=FakeResponse(200, profile)):
out = io.StringIO()
err = io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
pt.cmd_me(client, [])
self.assertNotIn("Traceback", err.getvalue())
self.assertEqual(json.loads(out.getvalue())["role"], 2)
client.clear_token()
def test_me_tolerates_missing_role(self):
client = pt.PeerTubeClient(
server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
)
client.save_session(TOKEN_RESPONSE)
with patch.object(pt.requests, "get", return_value=FakeResponse(200, {"username": "carol"})):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_me(client, [])
self.assertEqual(json.loads(out.getvalue())["username"], "carol")
client.clear_token()
def test_comments_output_exposes_thread_counts(self):
client = pt.PeerTubeClient(
server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
)
body = {
"total": 1,
"totalNotDeletedComments": 3,
"data": [
{"totalReplies": 3, "comment": {"text": "nice video", "account": {"name": "bob"}}}
],
}
with patch.object(pt.requests, "get", return_value=FakeResponse(200, body)):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_comments(client, ["--id", "uuid-one"])
payload = json.loads(out.getvalue())
self.assertEqual(payload["total"], 1)
self.assertEqual(payload["total_not_deleted"], 3)
self.assertEqual(payload["threads"][0]["comment"]["text"], "nice video")
def test_channels_output_carries_handles_and_counts(self):
client = pt.PeerTubeClient(
server="https://inst.example", config_dir=tempfile.mkdtemp(prefix="pt-ho-")
)
body = {
"total": 1,
"data": [
{
"name": "alice-channel",
"displayName": "Alice Channel",
"host": "inst.example",
"videosCount": 12,
"followersCount": 34,
}
],
}
with patch.object(pt.requests, "get", return_value=FakeResponse(200, body)):
out = io.StringIO()
with contextlib.redirect_stdout(out):
pt.cmd_channels(client, [])
payload = json.loads(out.getvalue())
channel = payload["channels"][0]
self.assertEqual(channel["name"], "alice-channel")
self.assertIsInstance(channel["videosCount"], int)
class PipelineChainTests(ModuleStateTestCase):
"""Documented multi-step recipes must execute stage by stage, each stage's
output field names AND JSON types consumable by the next."""
@classmethod
def setUpClass(cls):
cls.tmpdir = tempfile.TemporaryDirectory(prefix="pt-pipeline-")
@classmethod
def tearDownClass(cls):
cls.tmpdir.cleanup()
def run_cli(self, *args):
env = clean_env()
env["PEERTUBE_SERVER"] = "https://inst.example"
env["PEERTUBE_CONFIG_DIR"] = self.tmpdir.name
return subprocess.run(
[sys.executable, str(SCRIPT), "--json", "--dry-run", *args],
capture_output=True,
text=True,
env=env,
)
def run_jq(self, *jq_args, stdin_text=""):
return subprocess.run(
["jq", *jq_args], input=stdin_text, capture_output=True, text=True, env=clean_env()
)
def stage_file(self, name, document):
path = pathlib.Path(self.tmpdir.name) / name
path.write_text(json.dumps(document))
return str(path)
def test_browse_then_detail_chain_consumability(self):
# Stage 1: videos plan; jq proves the path and the start-offset type
# (number) that stage two consumes when picking an id from the listing.
r1 = self.run_cli("videos", "--limit", "2")
self.assertEqual(r1.returncode, 0, r1.stderr)
self.stage_file("s1.json", json.loads(r1.stdout))
self.assertEqual(
self.run_jq("-r", ".path", stdin_text=r1.stdout).stdout.strip(), "/api/v1/videos"
)
self.assertEqual(
self.run_jq("-r", ".params.start | type", stdin_text=r1.stdout).stdout.strip(), "number"
)
# Stage 2: detail plan consumes an id into the URL path.
r2 = self.run_cli("video", "--id", "uuid-one")
self.assertEqual(r2.returncode, 0, r2.stderr)
self.stage_file("s2.json", json.loads(r2.stdout))
self.assertEqual(
self.run_jq("-r", ".path", stdin_text=r2.stdout).stdout.strip(),
"/api/v1/videos/uuid-one",
)
def test_search_then_video_chain_consumability(self):
r1 = self.run_cli("search", "--query", "linux", "--limit", "3")
self.assertEqual(r1.returncode, 0, r1.stderr)
self.assertEqual(
self.run_jq("-r", ".params.searchTarget", stdin_text=r1.stdout).stdout.strip(), "local"
)
self.assertEqual(
self.run_jq("-r", ".params.count | type", stdin_text=r1.stdout).stdout.strip(), "number"
)
# The documented jq selector .videos[0].uuid maps to detail --id.
r2 = self.run_cli("video", "--id", "uuid-from-search")
self.assertEqual(r2.returncode, 0, r2.stderr)
self.assertEqual(
self.run_jq("-r", ".path", stdin_text=r2.stdout).stdout.strip(),
"/api/v1/videos/uuid-from-search",
)
def test_channel_offset_paging_chain_consumability(self):
r1 = self.run_cli("channels", "--limit", "100", "--offset", "0")
self.assertEqual(r1.returncode, 0, r1.stderr)
self.assertEqual(
self.run_jq("-r", ".path", stdin_text=r1.stdout).stdout.strip(),
"/api/v1/video-channels",
)
r2 = self.run_cli(
"channel", "--handle", "alice-channel", "--limit", "100", "--offset", "100"
)
self.assertEqual(r2.returncode, 0, r2.stderr)
plan = json.loads(r2.stdout)
video_req = plan["requests"][1]
self.assertEqual(video_req["params"]["start"], 100)
self.assertEqual(video_req["params"]["count"], 100)
self.assertNotIn("page", video_req["params"])
def test_login_to_me_chain_handoff(self):
# Stage 1: login plan lists the form fields (no values).
r1 = self.run_cli("login", "--username", "alice")
self.assertEqual(r1.returncode, 0, r1.stderr)
self.assertEqual(
self.run_jq("-r", ".path", stdin_text=r1.stdout).stdout.strip(), "/api/v1/users/token"
)
fields = json.loads(self.run_jq("-c", ".form_fields", stdin_text=r1.stdout).stdout)
self.assertIn("grant_type", fields)
# Stage 2: me plan rides the Authorization header the login persisted.
r2 = self.run_cli("me")
self.assertEqual(r2.returncode, 0, r2.stderr)
self.assertEqual(
self.run_jq("-r", ".path", stdin_text=r2.stdout).stdout.strip(), "/api/v1/users/me"
)
# Stage 3: logout plan revokes on the same instance.
r3 = self.run_cli("logout")
self.assertEqual(r3.returncode, 0, r3.stderr)
self.assertEqual(
self.run_jq("-r", ".path", stdin_text=r3.stdout).stdout.strip(),
"/api/v1/users/revoke-token",
)
def test_server_composition_plan_targets_both_endpoints(self):
r1 = self.run_cli("server")
self.assertEqual(r1.returncode, 0, r1.stderr)
paths = json.loads(self.run_jq("-c", "[.requests[].path]", stdin_text=r1.stdout).stdout)
self.assertEqual(paths, ["/api/v1/config/about", "/api/v1/server/stats"])
def test_mocked_browse_to_detail_stage_types(self):
"""Live-shape variant of recipe 1: the videos output's uuid (string)
feeds video --id, and the detail object carries description/url."""
pt.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False}
client = pt.PeerTubeClient(server="https://inst.example", config_dir=self.tmpdir.name)
detail = dict(VIDEO_ONE, description="full text", commentsEnabled=True)
with patch.object(
pt.requests,
"get",
side_effect=[
FakeResponse(200, {"total": 1, "data": [VIDEO_ONE]}),
FakeResponse(200, detail),
],
):
first = io.StringIO()
with contextlib.redirect_stdout(first):
pt.cmd_videos(client, ["--limit", "1"])
listing = json.loads(first.getvalue())
consumed_id = listing["videos"][0]["uuid"]
self.assertIsInstance(consumed_id, str)
second = io.StringIO()
with contextlib.redirect_stdout(second):
pt.cmd_video(client, ["--id", consumed_id])
video_detail = json.loads(second.getvalue())
self.assertEqual(video_detail["uuid"], consumed_id)
self.assertIsInstance(video_detail["description"], str)
self.assertIsInstance(video_detail["commentsEnabled"], bool)
class EnvGuardedLiveProbeTests(unittest.TestCase):
"""Optional anonymous instance probe (keyless public endpoint). Runs only
with PEERTUBE_LIVE_TESTS=1; skipped cleanly otherwise so the suite stays
fully offline under the proxy-trap."""
def test_public_instance_oauth_client_probe(self):
if os.getenv("PEERTUBE_LIVE_TESTS") != "1":
self.skipTest("live probe disabled (set PEERTUBE_LIVE_TESTS=1)")
result = subprocess.run(
[sys.executable, str(SCRIPT), "--json", "server", "--server", "https://framatube.org"],
capture_output=True,
text=True,
env=clean_env(),
timeout=60,
)
self.assertEqual(result.returncode, 0, result.stderr)
payload = json.loads(result.stdout)
self.assertEqual(payload["instance"]["name"], "Framatube")
self.assertIsInstance(payload["stats"]["totalLocalVideos"], int)
if __name__ == "__main__":
unittest.main()