test(services): use placeholder tags instead of real service names

This commit is contained in:
imSp4rky
2026-09-05 22:16:22 -06:00
parent 0f9997be4c
commit adea7583f0
13 changed files with 97 additions and 87 deletions
+3 -3
View File
@@ -117,7 +117,7 @@ never keeps it alive or makes it look active.
``` ```
GET /api/dashboard/keys GET /api/dashboard/keys
[{"id": "3ed054be9371", "role": "user", "label": "tier1", "services": ["ATV", "DSNP"], [{"id": "3ed054be9371", "role": "user", "label": "tier1", "services": ["EXAMPLE", "DEMO"],
"server_cdm": false, "server_accounts": false, "server_proxy": false, "server_cdm": false, "server_accounts": false, "server_proxy": false,
"tier": "bot", "rate_limit": 600, "window_used": 412, "tier": "bot", "rate_limit": 600, "window_used": 412,
"requests": 41300, "rejected": 4, "bytes_out": 103079215104, "last_seen": 1756909188.2}, "requests": 41300, "rejected": 4, "bytes_out": 103079215104, "last_seen": 1756909188.2},
@@ -179,10 +179,10 @@ limit never applies to the dashboard key, and never to `/api/health`.
``` ```
GET /api/dashboard/services GET /api/dashboard/services
[{"tag": "MAX", "state": "staged", "error": null, [{"tag": "EXAMPLE", "state": "staged", "error": null,
"commit": "9cc230c98f13b83f6c6fe831595b9702d3ba2786", "staged_commit": "5f0aa17...", "commit": "9cc230c98f13b83f6c6fe831595b9702d3ba2786", "staged_commit": "5f0aa17...",
"staged_since": 1756908900.0, "sessions": 1, "jobs": 1, "staged_since": 1756908900.0, "sessions": 1, "jobs": 1,
"aliases": ["HBO"], "geofence": ["US"]}] "aliases": ["EXMPL"], "geofence": ["US"]}]
``` ```
`state` is `loaded`, `staged` or `failed`. A service that failed to import keeps its error `state` is `loaded`, `staged` or `failed`. A service that failed to import keeps its error
+7 -7
View File
@@ -2,9 +2,9 @@
Covers the predicates in ``unshackle.core.cdm.detect`` (playready/widevine, local/remote, Covers the predicates in ``unshackle.core.cdm.detect`` (playready/widevine, local/remote,
and the remote combinations) and the loader stamping that sets a remote CDM's DRM type at and the remote combinations) and the loader stamping that sets a remote CDM's DRM type at
load time instead of guessing it on each call. Services such as AMZN route to a device load time instead of guessing it on each call. Services such as EXAMPLE choose a device
profile off that type, so a wrong classification here is what produces a Downgrade.Hd profile from that type, so a wrong classification here is what produces a licence denial
license denial. for the requested resolution.
""" """
from __future__ import annotations from __future__ import annotations
@@ -143,7 +143,7 @@ def test_stamp_remote_is_best_effort_on_unsettable_object():
def test_load_remote_decryptlabs_playready_is_stamped(): def test_load_remote_decryptlabs_playready_is_stamped():
cdm_api = {"name": "dl-pr", "type": "decrypt_labs", "secret": "x", "device_name": "SL3000"} cdm_api = {"name": "dl-pr", "type": "decrypt_labs", "secret": "x", "device_name": "SL3000"}
cdm = loader_mod.load_remote_cdm(dict(cdm_api), "dl-pr", "AMZN", None) cdm = loader_mod.load_remote_cdm(dict(cdm_api), "dl-pr", "EXAMPLE", None)
assert cdm.drm == "playready" assert cdm.drm == "playready"
assert cdm.is_remote_cdm is True assert cdm.is_remote_cdm is True
assert is_remote_playready_cdm(cdm) assert is_remote_playready_cdm(cdm)
@@ -151,7 +151,7 @@ def test_load_remote_decryptlabs_playready_is_stamped():
def test_load_remote_decryptlabs_widevine_is_stamped(): def test_load_remote_decryptlabs_widevine_is_stamped():
cdm_api = {"name": "dl-wv", "type": "decrypt_labs", "secret": "x", "device_name": "ChromeCDM"} cdm_api = {"name": "dl-wv", "type": "decrypt_labs", "secret": "x", "device_name": "ChromeCDM"}
cdm = loader_mod.load_remote_cdm(dict(cdm_api), "dl-wv", "AMZN", None) cdm = loader_mod.load_remote_cdm(dict(cdm_api), "dl-wv", "EXAMPLE", None)
assert cdm.drm == "widevine" assert cdm.drm == "widevine"
assert is_remote_widevine_cdm(cdm) assert is_remote_widevine_cdm(cdm)
@@ -165,7 +165,7 @@ def test_load_remote_native_playready_is_stamped(monkeypatch):
monkeypatch.setattr(prmod, "RemoteCdm", FakePlayReadyRemote) monkeypatch.setattr(prmod, "RemoteCdm", FakePlayReadyRemote)
cdm_api = {"name": "pr", "Device Type": "PLAYREADY", "host": "h", "secret": "s", "device_name": "d"} cdm_api = {"name": "pr", "Device Type": "PLAYREADY", "host": "h", "secret": "s", "device_name": "d"}
cdm = loader_mod.load_remote_cdm(dict(cdm_api), "pr", "AMZN", None) cdm = loader_mod.load_remote_cdm(dict(cdm_api), "pr", "EXAMPLE", None)
assert isinstance(cdm, FakePlayReadyRemote) assert isinstance(cdm, FakePlayReadyRemote)
assert cdm.drm == "playready" assert cdm.drm == "playready"
assert cdm.is_remote_cdm is True assert cdm.is_remote_cdm is True
@@ -181,7 +181,7 @@ def test_load_remote_native_widevine_is_stamped(monkeypatch):
monkeypatch.setattr(wvmod, "RemoteCdm", FakeWidevineRemote) monkeypatch.setattr(wvmod, "RemoteCdm", FakeWidevineRemote)
cdm_api = {"name": "wv", "Device Type": "ANDROID", "host": "h", "secret": "s", "device_name": "d"} cdm_api = {"name": "wv", "Device Type": "ANDROID", "host": "h", "secret": "s", "device_name": "d"}
cdm = loader_mod.load_remote_cdm(dict(cdm_api), "wv", "AMZN", None) cdm = loader_mod.load_remote_cdm(dict(cdm_api), "wv", "EXAMPLE", None)
assert isinstance(cdm, FakeWidevineRemote) assert isinstance(cdm, FakeWidevineRemote)
assert cdm.drm == "widevine" assert cdm.drm == "widevine"
assert is_remote_widevine_cdm(cdm) assert is_remote_widevine_cdm(cdm)
+22 -22
View File
@@ -16,29 +16,29 @@ pytestmark = pytest.mark.unit
def test_ci_get_exact_and_case_insensitive(): def test_ci_get_exact_and_case_insensitive():
m = {"NF": "a", "Default": "b"} m = {"DEMO": "a", "Default": "b"}
assert ci_get(m, "NF") == "a" assert ci_get(m, "DEMO") == "a"
assert ci_get(m, "nf") == "a" assert ci_get(m, "demo") == "a"
assert ci_get(m, "default") == "b" assert ci_get(m, "default") == "b"
assert ci_get(m, "missing") is None assert ci_get(m, "missing") is None
assert ci_get(m, "missing", "fallback") == "fallback" assert ci_get(m, "missing", "fallback") == "fallback"
assert ci_get({}, "NF", "fallback") == "fallback" assert ci_get({}, "DEMO", "fallback") == "fallback"
def test_decryption_map_keys_upper_cased_on_build(): def test_decryption_map_keys_upper_cased_on_build():
c = Config(decryption={"cr": "mp4decrypt", "default": "shaka"}) c = Config(decryption={"example": "mp4decrypt", "default": "shaka"})
assert c.decryption_map == {"CR": "mp4decrypt", "DEFAULT": "shaka"} assert c.decryption_map == {"EXAMPLE": "mp4decrypt", "DEFAULT": "shaka"}
assert c.decryption == "shaka" assert c.decryption == "shaka"
def test_resolve_decryption_per_service_and_default(): def test_resolve_decryption_per_service_and_default():
c = Config(decryption={"CR": "mp4decrypt", "default": "shaka"}) c = Config(decryption={"EXAMPLE": "mp4decrypt", "default": "shaka"})
assert resolve_decryption(c.decryption_map, c.decryption, "CR") == "mp4decrypt" assert resolve_decryption(c.decryption_map, c.decryption, "EXAMPLE") == "mp4decrypt"
# service not mapped -> default # service not mapped -> default
assert resolve_decryption(c.decryption_map, c.decryption, "NF") == "shaka" assert resolve_decryption(c.decryption_map, c.decryption, "DEMO") == "shaka"
# case-insensitive both ways (lowercase yaml key, mixed-case lookup) # case-insensitive both ways (lowercase yaml key, mixed-case lookup)
c2 = Config(decryption={"cr": "mp4decrypt", "default": "shaka"}) c2 = Config(decryption={"example": "mp4decrypt", "default": "shaka"})
assert resolve_decryption(c2.decryption_map, c2.decryption, "Cr") == "mp4decrypt" assert resolve_decryption(c2.decryption_map, c2.decryption, "Example") == "mp4decrypt"
def test_decryption_scalar_form(): def test_decryption_scalar_form():
@@ -48,26 +48,26 @@ def test_decryption_scalar_form():
def test_cdm_keys_preserved_on_build(): def test_cdm_keys_preserved_on_build():
c = Config(cdm={"nf": "dev_a", "default": "dev_b"}) c = Config(cdm={"demo": "dev_a", "default": "dev_b"})
assert c.cdm == {"nf": "dev_a", "default": "dev_b"} assert c.cdm == {"demo": "dev_a", "default": "dev_b"}
def test_resolve_cdm_name_case_insensitive(): def test_resolve_cdm_name_case_insensitive():
# lowercase yaml key must resolve for an uppercase service tag (the bug being fixed) # lowercase yaml key must resolve for an uppercase service tag (the bug being fixed)
c = Config(cdm={"nf": "dev_a", "default": "dev_b"}) c = Config(cdm={"demo": "dev_a", "default": "dev_b"})
assert resolve_cdm_name(c.cdm, "NF") == "dev_a" assert resolve_cdm_name(c.cdm, "DEMO") == "dev_a"
# unmapped service -> default # unmapped service -> default
assert resolve_cdm_name(c.cdm, "ATV") == "dev_b" assert resolve_cdm_name(c.cdm, "EXAMPLE") == "dev_b"
# uppercase yaml key, lowercase lookup # uppercase yaml key, lowercase lookup
c2 = Config(cdm={"NF": "dev_a", "default": "dev_b"}) c2 = Config(cdm={"DEMO": "dev_a", "default": "dev_b"})
assert resolve_cdm_name(c2.cdm, "nf") == "dev_a" assert resolve_cdm_name(c2.cdm, "demo") == "dev_a"
def test_resolve_cdm_name_override_wins(): def test_resolve_cdm_name_override_wins():
c = Config(cdm={"NF": "dev_a", "default": "dev_b"}) c = Config(cdm={"DEMO": "dev_a", "default": "dev_b"})
assert resolve_cdm_name(c.cdm, "NF", override="dev_override") == "dev_override" assert resolve_cdm_name(c.cdm, "DEMO", override="dev_override") == "dev_override"
def test_resolve_cdm_name_no_match_no_default(): def test_resolve_cdm_name_no_match_no_default():
c = Config(cdm={"NF": "dev_a"}) c = Config(cdm={"DEMO": "dev_a"})
assert resolve_cdm_name(c.cdm, "ATV") is None assert resolve_cdm_name(c.cdm, "EXAMPLE") is None
+4 -2
View File
@@ -212,9 +212,11 @@ def test_local_dirty_services_lists_edited_dirs(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
service_repo.subprocess, service_repo.subprocess,
"run", "run",
lambda *a, **k: subprocess.CompletedProcess(a, 0, b"CR/__init__.py\nCR/config.yaml\nNF/__init__.py\n", b""), lambda *a, **k: subprocess.CompletedProcess(
a, 0, b"EXAMPLE/__init__.py\nEXAMPLE/config.yaml\nDEMO/__init__.py\n", b""
),
) )
assert service_repo.local_dirty_services(Path("/x")) == ["CR", "NF"] assert service_repo.local_dirty_services(Path("/x")) == ["DEMO", "EXAMPLE"]
def test_refresh_reports_discarded_local_edits(monkeypatch, tmp_path): def test_refresh_reports_discarded_local_edits(monkeypatch, tmp_path):
@@ -23,7 +23,7 @@ pytestmark = pytest.mark.unit
def test_redact_parameters_masks_secrets_and_proxy_userinfo(): def test_redact_parameters_masks_secrets_and_proxy_userinfo():
params = { params = {
"service": "ATV", "service": "EXAMPLE",
"credential": "user:hunter2", "credential": "user:hunter2",
"password": "pw", "password": "pw",
"token": "tok", "token": "tok",
@@ -70,7 +70,7 @@ def test_to_dict_full_details_redacts_error_fields_and_parameters():
job_id="j1", job_id="j1",
status=JobStatus.FAILED, status=JobStatus.FAILED,
created_time=datetime(2026, 1, 1), created_time=datetime(2026, 1, 1),
service="ATV", service="EXAMPLE",
title_id="t", title_id="t",
parameters={"credential": "user:hunter2"}, parameters={"credential": "user:hunter2"},
) )
@@ -102,35 +102,37 @@ def stub_handler(monkeypatch):
async def test_cdm_override_forbidden_by_default(stub_handler): async def test_cdm_override_forbidden_by_default(stub_handler):
stub_handler.setattr(handlers.config, "serve", {}) stub_handler.setattr(handlers.config, "serve", {})
with pytest.raises(APIError) as ei: with pytest.raises(APIError) as ei:
await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "dev"}) await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "cdm": "dev"})
assert ei.value.error_code == APIErrorCode.FORBIDDEN assert ei.value.error_code == APIErrorCode.FORBIDDEN
async def test_cdm_override_allowed_when_enabled(stub_handler): async def test_cdm_override_allowed_when_enabled(stub_handler):
stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": True}) stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": True})
# passing the gate reaches the stubbed Services.load, whose error is caught and returned as a response # passing the gate reaches the stubbed Services.load, whose error is caught and returned as a response
resp = await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "dev"}) resp = await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "cdm": "dev"})
assert isinstance(resp, web.Response) assert isinstance(resp, web.Response)
async def test_cdm_override_allowlist_permits_only_named_device(stub_handler): async def test_cdm_override_allowlist_permits_only_named_device(stub_handler):
stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": ["good"]}) stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": ["good"]})
assert isinstance(await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "good"}), web.Response) assert isinstance(
await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "cdm": "good"}), web.Response
)
with pytest.raises(APIError) as ei: with pytest.raises(APIError) as ei:
await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "other"}) await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "cdm": "other"})
assert ei.value.error_code == APIErrorCode.FORBIDDEN assert ei.value.error_code == APIErrorCode.FORBIDDEN
async def test_credential_forbidden_by_default(stub_handler): async def test_credential_forbidden_by_default(stub_handler):
stub_handler.setattr(handlers.config, "serve", {}) stub_handler.setattr(handlers.config, "serve", {})
with pytest.raises(APIError) as ei: with pytest.raises(APIError) as ei:
await handlers.download_handler({"service": "ATV", "title_id": "t", "credential": "u:p"}) await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "credential": "u:p"})
assert ei.value.error_code == APIErrorCode.FORBIDDEN assert ei.value.error_code == APIErrorCode.FORBIDDEN
async def test_credential_allowed_when_enabled(stub_handler): async def test_credential_allowed_when_enabled(stub_handler):
stub_handler.setattr(handlers.config, "serve", {"allow_job_credentials": True}) stub_handler.setattr(handlers.config, "serve", {"allow_job_credentials": True})
resp = await handlers.download_handler({"service": "ATV", "title_id": "t", "credential": "u:p"}) resp = await handlers.download_handler({"service": "EXAMPLE", "title_id": "t", "credential": "u:p"})
assert isinstance(resp, web.Response) assert isinstance(resp, web.Response)
+13 -13
View File
@@ -23,17 +23,17 @@ def manager() -> DownloadQueueManager:
def test_create_job_returns_queued_job(manager: DownloadQueueManager) -> None: def test_create_job_returns_queued_job(manager: DownloadQueueManager) -> None:
job = manager.create_job("ATV", "movie-123", profile="default") job = manager.create_job("EXAMPLE", "movie-123", profile="default")
assert isinstance(job, DownloadJob) assert isinstance(job, DownloadJob)
assert job.status is JobStatus.QUEUED assert job.status is JobStatus.QUEUED
assert job.service == "ATV" assert job.service == "EXAMPLE"
assert job.title_id == "movie-123" assert job.title_id == "movie-123"
assert job.parameters == {"profile": "default"} assert job.parameters == {"profile": "default"}
def test_get_and_list_jobs(manager: DownloadQueueManager) -> None: def test_get_and_list_jobs(manager: DownloadQueueManager) -> None:
a = manager.create_job("ATV", "a") a = manager.create_job("EXAMPLE", "a")
b = manager.create_job("NF", "b") b = manager.create_job("DEMO", "b")
assert manager.get_job(a.job_id) is a assert manager.get_job(a.job_id) is a
assert manager.get_job("missing") is None assert manager.get_job("missing") is None
listed = manager.list_jobs() listed = manager.list_jobs()
@@ -41,11 +41,11 @@ def test_get_and_list_jobs(manager: DownloadQueueManager) -> None:
def test_to_dict_short_vs_full(manager: DownloadQueueManager) -> None: def test_to_dict_short_vs_full(manager: DownloadQueueManager) -> None:
job = manager.create_job("ATV", "t", profile="p") job = manager.create_job("EXAMPLE", "t", profile="p")
short = job.to_dict() short = job.to_dict()
assert "parameters" not in short assert "parameters" not in short
assert short["status"] == "queued" assert short["status"] == "queued"
assert short["service"] == "ATV" assert short["service"] == "EXAMPLE"
full = job.to_dict(include_full_details=True) full = job.to_dict(include_full_details=True)
assert full["parameters"] == {"profile": "p"} assert full["parameters"] == {"profile": "p"}
assert "error_message" in full assert "error_message" in full
@@ -53,7 +53,7 @@ def test_to_dict_short_vs_full(manager: DownloadQueueManager) -> None:
def test_cancel_queued_job_sets_cancelled_and_signals_event(manager: DownloadQueueManager) -> None: def test_cancel_queued_job_sets_cancelled_and_signals_event(manager: DownloadQueueManager) -> None:
job = manager.create_job("ATV", "t") job = manager.create_job("EXAMPLE", "t")
assert manager.cancel_job(job.job_id) is True assert manager.cancel_job(job.job_id) is True
assert job.status is JobStatus.CANCELLED assert job.status is JobStatus.CANCELLED
assert job.cancel_event.is_set() assert job.cancel_event.is_set()
@@ -64,13 +64,13 @@ def test_cancel_unknown_job_returns_false(manager: DownloadQueueManager) -> None
def test_cancel_completed_job_returns_false(manager: DownloadQueueManager) -> None: def test_cancel_completed_job_returns_false(manager: DownloadQueueManager) -> None:
job = manager.create_job("ATV", "t") job = manager.create_job("EXAMPLE", "t")
job.status = JobStatus.COMPLETED job.status = JobStatus.COMPLETED
assert manager.cancel_job(job.job_id) is False assert manager.cancel_job(job.job_id) is False
def test_cancel_downloading_job_signals(manager: DownloadQueueManager) -> None: def test_cancel_downloading_job_signals(manager: DownloadQueueManager) -> None:
job = manager.create_job("ATV", "t") job = manager.create_job("EXAMPLE", "t")
job.status = JobStatus.DOWNLOADING job.status = JobStatus.DOWNLOADING
assert manager.cancel_job(job.job_id) is True assert manager.cancel_job(job.job_id) is True
assert job.status is JobStatus.CANCELLED assert job.status is JobStatus.CANCELLED
@@ -80,10 +80,10 @@ def test_cancel_downloading_job_signals(manager: DownloadQueueManager) -> None:
def test_cleanup_old_jobs_drops_old_terminal_states(manager: DownloadQueueManager) -> None: def test_cleanup_old_jobs_drops_old_terminal_states(manager: DownloadQueueManager) -> None:
now = datetime.now() now = datetime.now()
old = now - timedelta(hours=48) old = now - timedelta(hours=48)
keep_recent = manager.create_job("ATV", "recent") keep_recent = manager.create_job("EXAMPLE", "recent")
drop_old_done = manager.create_job("ATV", "old-done") drop_old_done = manager.create_job("EXAMPLE", "old-done")
drop_old_failed = manager.create_job("ATV", "old-failed") drop_old_failed = manager.create_job("EXAMPLE", "old-failed")
keep_running = manager.create_job("ATV", "running") keep_running = manager.create_job("EXAMPLE", "running")
keep_recent.status = JobStatus.COMPLETED keep_recent.status = JobStatus.COMPLETED
keep_recent.completed_time = now keep_recent.completed_time = now
+2 -2
View File
@@ -118,9 +118,9 @@ def test_build_error_response_debug_mode_includes_traceback() -> None:
], ],
) )
def test_categorize_exception(exc: Exception, expected_code: APIErrorCode) -> None: def test_categorize_exception(exc: Exception, expected_code: APIErrorCode) -> None:
api_err = categorize_exception(exc, context={"service": "ATV"}) api_err = categorize_exception(exc, context={"service": "EXAMPLE"})
assert api_err.error_code == expected_code assert api_err.error_code == expected_code
assert api_err.details.get("service") == "ATV" assert api_err.details.get("service") == "EXAMPLE"
def test_categorize_preserves_context() -> None: def test_categorize_preserves_context() -> None:
+2 -2
View File
@@ -55,9 +55,9 @@ def test_post_sends_json_body(client: RemoteClient) -> None:
responses.add_callback( responses.add_callback(
responses.POST, "http://srv:8786/api/session/create", callback=cb, content_type="application/json" responses.POST, "http://srv:8786/api/session/create", callback=cb, content_type="application/json"
) )
result = client.post("/api/session/create", {"service": "ATV"}) result = client.post("/api/session/create", {"service": "EXAMPLE"})
assert result == {"session_id": "abc"} assert result == {"session_id": "abc"}
assert captured["body"] == {"service": "ATV"} assert captured["body"] == {"service": "EXAMPLE"}
@responses.activate @responses.activate
@@ -10,8 +10,14 @@ import pytest
from unshackle.core.services import Services from unshackle.core.services import Services
SERVED = [ SERVED = [
{"tag": "AMZN", "url": "https://example.com", "help": "Amazon.", "cli_params": [], "aliases": []}, {"tag": "EXAMPLE", "url": "https://example.com", "help": "Example service.", "cli_params": [], "aliases": []},
{"tag": "HMAX", "url": "https://example.com", "help": "Max.", "cli_params": [], "aliases": ["MAX", "hbomax"]}, {
"tag": "DEMO",
"url": "https://example.com",
"help": "Demo service.",
"cli_params": [],
"aliases": ["ALT", "alt-demo"],
},
] ]
@@ -21,31 +27,31 @@ def _make(name: str) -> click.Command:
def test_unknown_name_raises_with_typed_name_and_available_list() -> None: def test_unknown_name_raises_with_typed_name_and_available_list() -> None:
with patch.object(Services, "fetch_remote_services", return_value=SERVED): with patch.object(Services, "fetch_remote_services", return_value=SERVED):
with pytest.raises(click.ClickException, match=r"does not offer a service named 'BBC'.*AMZN, HMAX"): with pytest.raises(click.ClickException, match=r"does not offer a service named 'NOPE'.*DEMO, EXAMPLE"):
_make("BBC") _make("NOPE")
def test_served_tag_builds_full_command() -> None: def test_served_tag_builds_full_command() -> None:
with patch.object(Services, "fetch_remote_services", return_value=SERVED): with patch.object(Services, "fetch_remote_services", return_value=SERVED):
cmd = _make("AMZN") cmd = _make("EXAMPLE")
assert cmd.help == "Amazon." assert cmd.help == "Example service."
def test_server_alias_resolves_to_server_tag() -> None: def test_server_alias_resolves_to_server_tag() -> None:
with patch.object(Services, "fetch_remote_services", return_value=SERVED): with patch.object(Services, "fetch_remote_services", return_value=SERVED):
cmd = _make("max") cmd = _make("alt")
assert cmd.name == "HMAX" assert cmd.name == "DEMO"
assert cmd.help == "Max." assert cmd.help == "Demo service."
def test_empty_service_list_raises_no_services_message() -> None: def test_empty_service_list_raises_no_services_message() -> None:
with patch.object(Services, "fetch_remote_services", return_value=[]): with patch.object(Services, "fetch_remote_services", return_value=[]):
with pytest.raises(click.ClickException, match=r"offers no services to your API key"): with pytest.raises(click.ClickException, match=r"offers no services to your API key"):
_make("AMZN") _make("EXAMPLE")
def test_fetch_failure_keeps_stub_instead_of_raising() -> None: def test_fetch_failure_keeps_stub_instead_of_raising() -> None:
with patch.object(Services, "fetch_remote_services", return_value=None): with patch.object(Services, "fetch_remote_services", return_value=None):
cmd = _make("AMZN") cmd = _make("EXAMPLE")
assert cmd.name == "AMZN" assert cmd.name == "EXAMPLE"
assert cmd.help is None assert cmd.help is None
+2 -2
View File
@@ -28,7 +28,7 @@ def single_remote_service(monkeypatch: pytest.MonkeyPatch) -> None:
"primary": { "primary": {
"url": "https://primary:8080", "url": "https://primary:8080",
"api_key": "key-abc", "api_key": "key-abc",
"services": {"ATV": True, "NF": True}, "services": {"EXAMPLE": True, "DEMO": True},
"server_cdm": True, "server_cdm": True,
} }
}, },
@@ -97,7 +97,7 @@ def test_resolve_server_single_picks_only_entry(single_remote_service) -> None:
assert url == "https://primary:8080" assert url == "https://primary:8080"
assert key == "key-abc" assert key == "key-abc"
assert services["_server_cdm"] is True assert services["_server_cdm"] is True
assert services.get("ATV") is True assert services.get("EXAMPLE") is True
def test_resolve_server_explicit_name(single_remote_service) -> None: def test_resolve_server_explicit_name(single_remote_service) -> None:
@@ -182,7 +182,7 @@ def test_match_track_no_candidates_returns_none() -> None:
def test_build_title_movie() -> None: def test_build_title_movie() -> None:
info = {"type": "movie", "id": "movie-0001", "name": "Foo", "year": 2024, "language": "en"} info = {"type": "movie", "id": "movie-0001", "name": "Foo", "year": 2024, "language": "en"}
title = build_title(info, "ATV", "fallback") title = build_title(info, "EXAMPLE", "fallback")
assert isinstance(title, Movie) assert isinstance(title, Movie)
assert title.id == "movie-0001" assert title.id == "movie-0001"
assert title.name == "Foo" assert title.name == "Foo"
@@ -199,7 +199,7 @@ def test_build_title_episode() -> None:
"year": 2024, "year": 2024,
"language": "en", "language": "en",
} }
title = build_title(info, "ATV", "fallback") title = build_title(info, "EXAMPLE", "fallback")
assert isinstance(title, Episode) assert isinstance(title, Episode)
assert title.season == 1 assert title.season == 1
assert title.number == 2 assert title.number == 2
@@ -207,7 +207,7 @@ def test_build_title_episode() -> None:
def test_build_title_falls_back_to_id_when_missing() -> None: def test_build_title_falls_back_to_id_when_missing() -> None:
title = build_title({"type": "movie", "name": "x"}, "ATV", "fallback-id") title = build_title({"type": "movie", "name": "x"}, "EXAMPLE", "fallback-id")
assert title.id == "fallback-id" assert title.id == "fallback-id"
+9 -9
View File
@@ -25,15 +25,15 @@ def store() -> SessionStore:
async def test_create_returns_entry_with_uuid(store: SessionStore) -> None: async def test_create_returns_entry_with_uuid(store: SessionStore) -> None:
entry = await store.create("ATV", _FakeService()) entry = await store.create("EXAMPLE", _FakeService())
assert isinstance(entry, SessionEntry) assert isinstance(entry, SessionEntry)
assert entry.service_tag == "ATV" assert entry.service_tag == "EXAMPLE"
assert entry.session_id and len(entry.session_id) >= 32 assert entry.session_id and len(entry.session_id) >= 32
assert store.session_count == 1 assert store.session_count == 1
async def test_create_with_explicit_session_id(store: SessionStore) -> None: async def test_create_with_explicit_session_id(store: SessionStore) -> None:
entry = await store.create("NF", _FakeService(), session_id="fixed-id") entry = await store.create("DEMO", _FakeService(), session_id="fixed-id")
assert entry.session_id == "fixed-id" assert entry.session_id == "fixed-id"
@@ -42,7 +42,7 @@ async def test_get_returns_none_for_missing(store: SessionStore) -> None:
async def test_get_touches_last_accessed(store: SessionStore) -> None: async def test_get_touches_last_accessed(store: SessionStore) -> None:
entry = await store.create("DSNP", _FakeService()) entry = await store.create("DEMO", _FakeService())
before = entry.last_accessed before = entry.last_accessed
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
fetched = await store.get(entry.session_id) fetched = await store.get(entry.session_id)
@@ -68,7 +68,7 @@ async def test_delete_returns_false_when_missing(store: SessionStore) -> None:
async def test_cleanup_expired_drops_old_authenticated(store: SessionStore, monkeypatch: pytest.MonkeyPatch) -> None: async def test_cleanup_expired_drops_old_authenticated(store: SessionStore, monkeypatch: pytest.MonkeyPatch) -> None:
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
entry = await store.create("ATV", _FakeService()) entry = await store.create("EXAMPLE", _FakeService())
entry.last_accessed = datetime.now(timezone.utc) - timedelta(seconds=store.ttl + 100) entry.last_accessed = datetime.now(timezone.utc) - timedelta(seconds=store.ttl + 100)
removed = await store.cleanup_expired() removed = await store.cleanup_expired()
assert removed == 1 assert removed == 1
@@ -77,7 +77,7 @@ async def test_cleanup_expired_drops_old_authenticated(store: SessionStore, monk
async def test_cleanup_expired_keeps_pending_input_under_grace(store: SessionStore) -> None: async def test_cleanup_expired_keeps_pending_input_under_grace(store: SessionStore) -> None:
"""Sessions awaiting user input get a longer grace period (10 min) than authenticated TTL.""" """Sessions awaiting user input get a longer grace period (10 min) than authenticated TTL."""
entry = await store.create("ATV", _FakeService()) entry = await store.create("EXAMPLE", _FakeService())
entry.input_bridge = InputBridge() entry.input_bridge = InputBridge()
entry.auth_status = AuthStatus.PENDING_INPUT entry.auth_status = AuthStatus.PENDING_INPUT
removed = await store.cleanup_expired() removed = await store.cleanup_expired()
@@ -91,7 +91,7 @@ async def test_cleanup_expired_drops_pending_input_past_grace(store: SessionStor
from unshackle.core.api.input_bridge import AUTH_INPUT_TIMEOUT from unshackle.core.api.input_bridge import AUTH_INPUT_TIMEOUT
entry = await store.create("ATV", _FakeService()) entry = await store.create("EXAMPLE", _FakeService())
entry.input_bridge = InputBridge() entry.input_bridge = InputBridge()
entry.auth_status = AuthStatus.PENDING_INPUT entry.auth_status = AuthStatus.PENDING_INPUT
entry.last_accessed = datetime.now(timezone.utc) - timedelta(seconds=AUTH_INPUT_TIMEOUT + 1) entry.last_accessed = datetime.now(timezone.utc) - timedelta(seconds=AUTH_INPUT_TIMEOUT + 1)
@@ -101,8 +101,8 @@ async def test_cleanup_expired_drops_pending_input_past_grace(store: SessionStor
async def test_cancel_all_bridges(store: SessionStore) -> None: async def test_cancel_all_bridges(store: SessionStore) -> None:
a = await store.create("ATV", _FakeService()) a = await store.create("EXAMPLE", _FakeService())
b = await store.create("NF", _FakeService()) b = await store.create("DEMO", _FakeService())
a.input_bridge = InputBridge() a.input_bridge = InputBridge()
b.input_bridge = InputBridge() b.input_bridge = InputBridge()
+2 -2
View File
@@ -14,7 +14,7 @@ Covers the selection primitives that back `-r ...,DV,HYBRID` downloads:
The remaining ``dl`` glue (the Cartesian deliverable product and the mux loop) The remaining ``dl`` glue (the Cartesian deliverable product and the mux loop)
is orchestration inside the Click command; these tests lock down the pure is orchestration inside the Click command; these tests lock down the pure
units it relies on plus the documented end-state of a realistic ATV-style ladder. units it relies on plus the documented end-state of a realistic streaming ladder.
""" """
from __future__ import annotations from __future__ import annotations
@@ -39,7 +39,7 @@ def make_video(track_id: str, *, range_: Video.Range, height: int, bitrate: int,
@pytest.fixture @pytest.fixture
def ladder() -> list[Video]: def ladder() -> list[Video]:
"""Mirrors the reported ATV ladder: HDR10+, DV and SDR at multiple resolutions.""" """Mirrors the ladder from the reported fault: HDR10+, DV and SDR at multiple resolutions."""
H = Video.Codec.HEVC H = Video.Codec.HEVC
A = Video.Codec.AVC A = Video.Codec.AVC
return [ return [