"""Unit tests for DownloadJob + DownloadQueueManager state machine. These tests focus on the queue manager's data layer (create/get/list/cancel/ cleanup/serialize); they do not exercise the actual subprocess download path. """ from __future__ import annotations from datetime import datetime, timedelta from pathlib import Path from typing import Any import pytest from unshackle.core.api.download_manager import DownloadJob, DownloadQueueManager, JobStatus, get_download_manager pytestmark = pytest.mark.unit @pytest.fixture def manager() -> DownloadQueueManager: """Fresh manager. We never call start_workers() so no async tasks are created.""" return DownloadQueueManager(max_concurrent_downloads=2, job_retention_hours=24) def test_create_job_returns_queued_job(manager: DownloadQueueManager) -> None: job = manager.create_job("EXAMPLE", "movie-123", profile="default") assert isinstance(job, DownloadJob) assert job.status is JobStatus.QUEUED assert job.service == "EXAMPLE" assert job.title_id == "movie-123" assert job.parameters == {"profile": "default"} def test_get_and_list_jobs(manager: DownloadQueueManager) -> None: a = manager.create_job("EXAMPLE", "a") b = manager.create_job("DEMO", "b") assert manager.get_job(a.job_id) is a assert manager.get_job("missing") is None listed = manager.list_jobs() assert {j.job_id for j in listed} == {a.job_id, b.job_id} def test_to_dict_short_vs_full(manager: DownloadQueueManager) -> None: job = manager.create_job("EXAMPLE", "t", profile="p") short = job.to_dict() assert "parameters" not in short assert short["status"] == "queued" assert short["service"] == "EXAMPLE" full = job.to_dict(include_full_details=True) assert full["parameters"] == {"profile": "p"} assert "error_message" in full assert "output_files" in full def test_cancel_queued_job_sets_cancelled_and_signals_event(manager: DownloadQueueManager) -> None: job = manager.create_job("EXAMPLE", "t") assert manager.cancel_job(job.job_id) is True assert job.status is JobStatus.CANCELLED assert job.cancel_event.is_set() def test_cancel_unknown_job_returns_false(manager: DownloadQueueManager) -> None: assert manager.cancel_job("never-existed") is False def test_cancel_completed_job_returns_false(manager: DownloadQueueManager) -> None: job = manager.create_job("EXAMPLE", "t") job.status = JobStatus.COMPLETED assert manager.cancel_job(job.job_id) is False def test_cancel_downloading_job_signals(manager: DownloadQueueManager) -> None: job = manager.create_job("EXAMPLE", "t") job.status = JobStatus.DOWNLOADING assert manager.cancel_job(job.job_id) is True assert job.status is JobStatus.CANCELLED assert job.cancel_event.is_set() def test_cleanup_old_jobs_drops_old_terminal_states(manager: DownloadQueueManager) -> None: now = datetime.now() old = now - timedelta(hours=48) keep_recent = manager.create_job("EXAMPLE", "recent") drop_old_done = manager.create_job("EXAMPLE", "old-done") drop_old_failed = manager.create_job("EXAMPLE", "old-failed") keep_running = manager.create_job("EXAMPLE", "running") keep_recent.status = JobStatus.COMPLETED keep_recent.completed_time = now drop_old_done.status = JobStatus.COMPLETED drop_old_done.completed_time = old drop_old_failed.status = JobStatus.FAILED drop_old_failed.created_time = old # never set completed_time keep_running.status = JobStatus.DOWNLOADING removed = manager.cleanup_old_jobs() assert removed == 2 remaining = {j.job_id for j in manager.list_jobs()} assert keep_recent.job_id in remaining assert keep_running.job_id in remaining assert drop_old_done.job_id not in remaining assert drop_old_failed.job_id not in remaining def test_get_download_manager_returns_singleton() -> None: a = get_download_manager() b = get_download_manager() assert a is b async def test_busy_services_counts_a_live_remote_session() -> None: from unshackle.core.api.download_manager import busy_services from unshackle.core.api.session_store import get_session_store store = get_session_store() session = await store.create("EXAMPLE", object(), session_id="busy-session") try: assert "EXAMPLE" in busy_services() finally: await store.delete(session.session_id) assert "EXAMPLE" not in busy_services() def test_job_status_values() -> None: assert {s.value for s in JobStatus} == { "queued", "downloading", "completed", "failed", "cancelled", } def test_normalize_sub_format_preserves_original_sentinel() -> None: """An API job asking for sub_format="original" must keep the "original" sentinel. dl.py treats "original" as "keep source format" (skip conversion). If it were normalized to None (as to_enum(["original"]) yields, since there is no such enum member), dl.py would fall into the default branch and re-encode TTML -> WebVTT, contradicting the CLI. """ from unshackle.core.api.download_manager import normalize_sub_format from unshackle.core.tracks import Subtitle from unshackle.core.utils.click_types import SubtitleCodecChoice # API path keeps the sentinel, case-insensitively. assert normalize_sub_format("original") == "original" assert normalize_sub_format("ORIGINAL") == "original" # The CLI produces the exact same sentinel value, so dl.py's `== "original"` branch handles both. assert SubtitleCodecChoice(Subtitle.Codec).convert("original") == "original" # A real codec still maps to its enum member; unknowns collapse to None. assert normalize_sub_format("srt") is Subtitle.Codec.SubRip assert normalize_sub_format("nonsense") is None def test_worker_write_result_retries_denied_replace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Windows denies os.replace while the parent holds the progress file open, so the write must retry. Without the retry a denied replace loses that update. A value the worker writes once, such as a phase change, then stays invisible until the next write. """ import json import os from unshackle.core.api import download_worker target = tmp_path / "progress.json" real_replace = os.replace attempts = [] def flaky_replace(src: Path, dst: Path) -> None: attempts.append(dst) if len(attempts) == 1: raise PermissionError(13, "Permission denied", str(dst)) real_replace(src, dst) monkeypatch.setattr(download_worker.os, "replace", flaky_replace) download_worker.write_result(target, {"progress": 42.0}) assert json.loads(target.read_text(encoding="utf-8")) == {"progress": 42.0} assert len(attempts) == 2 class _CapturedCtx(Exception): pass @pytest.mark.parametrize( ("params", "expected"), [ ({}, (["orig"], [], [], ["all"], [], False, False)), ( { "lang": ["es-419"], "v_lang": ["en"], "a_lang": ["ja"], "s_lang": ["ko"], "acodec": "AAC,EC3", "forced_subs": True, "exact_lang": True, }, (["es-419"], ["en"], ["ja"], ["ko"], ["AAC", "EC3"], True, True), ), ], ) def test_perform_download_puts_the_lang_selection_in_the_service_ctx( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, params: dict, expected: tuple ) -> None: """Services read the language selection from ctx.parent.params, not from dl.result().""" import click import unshackle.commands.dl as dl_module from unshackle.core.api import download_manager, handlers from unshackle.core.services import Services class FakeDl: cli = click.Command("dl") def __init__(self, ctx: click.Context, **kwargs: object) -> None: raise _CapturedCtx(ctx) monkeypatch.setattr(dl_module, "dl", FakeDl) monkeypatch.setattr(Services, "get_path", staticmethod(lambda s: tmp_path)) monkeypatch.setattr(handlers, "load_full_cdm", lambda *a: None) with pytest.raises(_CapturedCtx) as exc: download_manager.perform_download("job-1", "EXAMPLE", "t1", dict(params)) ctx = exc.value.args[0] p = ctx.params acodec = [c.name for c in p["acodec"]] selection = (p["lang"], p["v_lang"], p["a_lang"], p["s_lang"], acodec, p["forced_subs"], p["exact_lang"]) assert selection == expected def test_perform_download_passes_latest_episodes_to_dl_result(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: import click import unshackle.commands.dl as dl_module from unshackle.core.api import download_manager, handlers from unshackle.core.services import Services class FakeDl: cli = click.Command("dl") def __init__(self, ctx: click.Context, **kwargs: object) -> None: pass def result(self, **kwargs: object) -> None: raise _CapturedCtx(kwargs) class FakeService: def __init__(self, ctx: click.Context) -> None: pass monkeypatch.setattr(dl_module, "dl", FakeDl) monkeypatch.setattr(Services, "get_path", staticmethod(lambda s: tmp_path)) monkeypatch.setattr(Services, "load", staticmethod(lambda s: FakeService)) monkeypatch.setattr(handlers, "load_full_cdm", lambda *a: None) with pytest.raises(_CapturedCtx) as exc: download_manager.perform_download("job-1", "EXAMPLE", "t1", {"latest_episodes": 3}) assert exc.value.args[0]["latest_episodes"] == 3 def test_worker_relays_a_prompt_and_reads_the_answer(monkeypatch: pytest.MonkeyPatch) -> None: import json import os import sys from types import SimpleNamespace from unshackle.core.api import download_worker from unshackle.core.console import prompt_user, set_prompt_handler read_fd, write_fd = os.pipe() monkeypatch.setattr(sys, "stdin", SimpleNamespace(fileno=lambda: read_fd)) updates: list[dict] = [] def answer_on_prompt(update: dict) -> None: """Answer only once the prompt is out: the prompt discards answers that arrived before it.""" updates.append(update) if update["input_prompt"]: os.write(write_fd, (json.dumps("12\n34") + "\n").encode("utf-8")) download_worker.relay_prompts(answer_on_prompt) try: assert prompt_user("Enter PIN") == "12\n34" finally: set_prompt_handler(None) os.close(write_fd) assert updates == [{"input_prompt": "Enter PIN"}, {"input_prompt": None}] def test_worker_relays_a_notice_as_progress(monkeypatch: pytest.MonkeyPatch) -> None: """A show_notice() message reaches the parent as ``notice`` progress, and None clears it.""" import os import sys from types import SimpleNamespace from unshackle.core.api import download_worker from unshackle.core.console import set_notice_handler, set_prompt_handler, show_notice read_fd, write_fd = os.pipe() monkeypatch.setattr(sys, "stdin", SimpleNamespace(fileno=lambda: read_fd)) updates: list[dict] = [] download_worker.relay_prompts(updates.append) try: show_notice("Enter pair code ABCD") show_notice(None) finally: set_prompt_handler(None) set_notice_handler(None) os.close(write_fd) assert updates == [{"notice": "Enter pair code ABCD"}, {"notice": None}] def test_a_prompt_discards_an_answer_sent_before_it(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A late answer to an earlier prompt must not answer the next one; the timeout is not retryable.""" import sys import threading from unshackle.core.api import download_worker from unshackle.core.api.errors import APIErrorCode, categorize_exception from unshackle.core.console import prompt_user, set_prompt_handler answers = tmp_path / "stdin" answers.write_text('"stale"\n', encoding="utf-8") monkeypatch.setattr(sys, "stdin", answers.open("rb")) monkeypatch.setattr(download_worker, "AUTH_INPUT_TIMEOUT", 0.2) download_worker.relay_prompts(lambda update: None) # The reader exits at EOF, so a finished join means the stale line is already queued. for reader in [t for t in threading.enumerate() if t.name == "prompt-answers"]: reader.join(timeout=5) try: with pytest.raises(TimeoutError) as exc_info: prompt_user("Enter PIN") finally: set_prompt_handler(None) assert categorize_exception(exc_info.value).error_code is APIErrorCode.AUTH_FAILED def test_concurrent_prompts_go_out_one_at_a_time(monkeypatch: pytest.MonkeyPatch) -> None: import json import os import sys import threading import time from types import SimpleNamespace from unshackle.core.api import download_worker from unshackle.core.console import prompt_user, set_prompt_handler read_fd, write_fd = os.pipe() monkeypatch.setattr(sys, "stdin", SimpleNamespace(fileno=lambda: read_fd)) monkeypatch.setattr(download_worker, "AUTH_INPUT_TIMEOUT", 5.0) updates: list[dict] = [] download_worker.relay_prompts(updates.append) results: list[str] = [] askers = [threading.Thread(target=lambda p=p: results.append(prompt_user(p)), daemon=True) for p in ("A", "B")] try: for asker in askers: asker.start() time.sleep(0.2) assert len(updates) == 1 for answer, prompts_sent in (("1", 1), ("2", 3)): deadline = time.monotonic() + 5 while len(updates) < prompts_sent and time.monotonic() < deadline: time.sleep(0.01) os.write(write_fd, (json.dumps(answer) + "\n").encode("utf-8")) for asker in askers: asker.join(timeout=5) finally: set_prompt_handler(None) os.close(write_fd) assert sorted(results) == ["1", "2"] assert [u["input_prompt"] is None for u in updates] == [False, True, False, True] def test_submit_input_writes_one_json_line_to_the_worker(manager: DownloadQueueManager) -> None: import io from types import SimpleNamespace job = manager.create_job("EXAMPLE", "t") stdin = io.BytesIO() manager._download_processes[job.job_id] = SimpleNamespace(stdin=stdin) # type: ignore[assignment] assert manager.submit_input(job, "1234") is False job.input_prompt = "Enter PIN" assert manager.submit_input(job, "1234") is True assert stdin.getvalue() == b'"1234"\n' assert job.input_prompt is None assert manager.submit_input(job, "5678") is False FAKE_PROMPT_WORKER = """ import sys from pathlib import Path from typing import Any from unshackle.core.api import download_worker from unshackle.core.console import prompt_user result_path, progress_path = Path(sys.argv[-2]), Path(sys.argv[-1]) state = {} def progress(update): state.update(update) download_worker.write_result(progress_path, state) download_worker.relay_prompts(progress) answers = [prompt_user("Enter OTP"), prompt_user("Enter OTP")] download_worker.write_result(result_path, {"status": "success", "output_files": answers}) """ async def test_a_repeated_prompt_reaches_an_event_listener( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, manager: DownloadQueueManager ) -> None: """A re-prompt with the same text right after an answer must still publish an event.""" import asyncio from unshackle.core.api import download_manager worker = tmp_path / "worker.py" worker.write_text(FAKE_PROMPT_WORKER, encoding="utf-8") spawn = asyncio.create_subprocess_exec async def spawn_fake_worker(executable: str, *args: str, **kwargs: Any) -> Any: return await spawn(executable, str(worker), *args[2:], **kwargs) monkeypatch.setattr(download_manager.asyncio, "create_subprocess_exec", spawn_fake_worker) job = manager.create_job("EXAMPLE", "t") events = manager.subscribe(job.job_id) run = asyncio.create_task(manager.run_download_async(job)) async def answer_next_prompt(answer: str) -> None: while True: event = await events.get() if event and event["data"]["input_prompt"]: assert manager.submit_input(job, answer) is True return await asyncio.wait_for(answer_next_prompt("111111"), timeout=20) await asyncio.wait_for(answer_next_prompt("222222"), timeout=20) assert await asyncio.wait_for(run, timeout=20) == ["111111", "222222"] assert job.input_prompt is None async def test_a_notice_reaches_the_job_and_an_event_listener( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, manager: DownloadQueueManager ) -> None: """The job carries the worker's notice while the worker waits, and drops it at the end.""" import asyncio from unshackle.core.api import download_manager worker = tmp_path / "worker.py" # The prompt holds the worker open until the test has seen the notice. source = FAKE_PROMPT_WORKER.replace("import prompt_user", "import prompt_user, show_notice").replace( 'answers = [prompt_user("Enter OTP"), prompt_user("Enter OTP")]', 'show_notice("Enter pair code ABCD")\nanswers = [prompt_user("Continue")]', ) assert source.count("show_notice") == 2, "FAKE_PROMPT_WORKER changed; update the replacements" worker.write_text(source, encoding="utf-8") spawn = asyncio.create_subprocess_exec async def spawn_fake_worker(executable: str, *args: str, **kwargs: Any) -> Any: return await spawn(executable, str(worker), *args[2:], **kwargs) monkeypatch.setattr(download_manager.asyncio, "create_subprocess_exec", spawn_fake_worker) job = manager.create_job("EXAMPLE", "t") events = manager.subscribe(job.job_id) run = asyncio.create_task(manager.run_download_async(job)) async def answer_after_notice() -> None: while True: event = await events.get() if event and event["data"]["input_prompt"]: assert event["data"]["notice"] == "Enter pair code ABCD" assert job.to_dict()["notice"] == "Enter pair code ABCD" assert manager.submit_input(job, "ok") is True return await asyncio.wait_for(answer_after_notice(), timeout=20) assert await asyncio.wait_for(run, timeout=20) == ["ok"] assert job.notice is None async def test_input_handler_rejects_bad_requests( monkeypatch: pytest.MonkeyPatch, manager: DownloadQueueManager ) -> None: from unshackle.core.api import download_manager from unshackle.core.api.errors import APIError, APIErrorCode from unshackle.core.api.handlers import download_job_input_handler monkeypatch.setattr(download_manager, "get_download_manager", lambda: manager) job = manager.create_job("EXAMPLE", "t") async def error_code(data: dict, job_id: str) -> APIErrorCode: with pytest.raises(APIError) as exc_info: await download_job_input_handler(data, job_id) return exc_info.value.error_code assert await error_code({"response": "1"}, "missing") is APIErrorCode.JOB_NOT_FOUND assert await error_code({}, job.job_id) is APIErrorCode.INVALID_INPUT assert await error_code({"response": "1"}, job.job_id) is APIErrorCode.CONFLICT job.owner_key = "another-key" assert await error_code({"response": "1"}, job.job_id) is APIErrorCode.JOB_NOT_FOUND FAKE_FAILING_WORKER = """ import sys from unshackle.core.api import download_worker def fail(*args, **kwargs): {}[[1]] = 2 download_worker.perform_download = fail sys.exit(download_worker.main(["worker"] + sys.argv[1:])) """ async def test_a_failed_job_keeps_the_worker_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, manager: DownloadQueueManager ) -> None: """The job records the worker's traceback and error, not the parent's "Worker exited" wrapper.""" import asyncio from unshackle.core.api import download_manager worker = tmp_path / "worker.py" worker.write_text(FAKE_FAILING_WORKER, encoding="utf-8") spawn = asyncio.create_subprocess_exec async def spawn_fake_worker(executable: str, *args: str, **kwargs: Any) -> Any: return await spawn(executable, str(worker), *args[2:], **kwargs) monkeypatch.setattr(download_manager.asyncio, "create_subprocess_exec", spawn_fake_worker) job = manager.create_job("EXAMPLE", "t") with pytest.raises(Exception, match="Worker exited"): await asyncio.wait_for(manager.execute_download(job), timeout=60) assert job.status is JobStatus.FAILED assert "{}[[1]] = 2" in (job.error_traceback or "") assert not (job.error_details or "").startswith("Worker exited") def test_client_job_view_hides_debug_fields_without_debug_api() -> None: from types import SimpleNamespace from unshackle.core.api.handlers import client_job_view job = {"job_id": "j", "error_traceback": "tb", "worker_stderr": "err"} off: Any = SimpleNamespace(app={"debug_api": False}) on: Any = SimpleNamespace(app={"debug_api": True}) assert client_job_view(job, off) == {"job_id": "j"} assert client_job_view(job, None) == {"job_id": "j"} assert client_job_view(job, on) == job