#!/usr/bin/env python3 import base64 import importlib.util import io import os import sys import tempfile import threading import unittest from http import HTTPStatus from pathlib import Path from unittest import mock ROOT = Path(__file__).resolve().parent MODULE_PATH = ROOT / "app.py" TEMP_STATE = tempfile.TemporaryDirectory() if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) import queue_jobs import http_handler os.environ.setdefault("ANI_CLI_WEB_DISABLE_WORKER", "1") os.environ["ANI_CLI_WEB_STATE_ROOT"] = TEMP_STATE.name SPEC = importlib.util.spec_from_file_location("ani_cli_web_app", MODULE_PATH) APP = importlib.util.module_from_spec(SPEC) assert SPEC.loader is not None SPEC.loader.exec_module(APP) IMPORT_RUNTIME_WAS_DEFERRED = ( APP.CONFIG is None and APP.WATCHLIST is None and APP.DOWNLOAD_QUEUE is None and APP.WATCHLIST_REFRESH is None ) APP.initialize_runtime(start_workers=False) APP.reset_runtime(wait=True) APP.initialize_runtime(start_workers=False) unittest.addModuleCleanup(lambda: APP.reset_runtime(wait=True)) class DummyHandler: def __init__(self, path, body=b"{}", content_length=None): self.path = path self.client_address = ("127.0.0.1", 8421) self.handler_context = APP.Handler.handler_context self.headers = {} if content_length is not None: self.headers["Content-Length"] = str(content_length) self.rfile = io.BytesIO(body) self.json_payload = None self.json_status = None self.error_status = None self.error_message = None self.file_path = None self.response_status = None self.response_headers = [] self.wfile = io.BytesIO() def ensure_client_access(self): return APP.Handler.ensure_client_access(self) def body_json(self): return APP.Handler.body_json(self) def json(self, payload, status=HTTPStatus.OK): self.json_payload = payload self.json_status = status def error(self, status, message): self.error_status = status self.error_message = message def file(self, path): self.file_path = path def html(self, _content): raise AssertionError("HTML should not be served in this test") def exception(self, exc): return APP.Handler.exception(self, exc) def send_response(self, status): self.response_status = status def send_header(self, key, value): self.response_headers.append((key, value)) def end_headers(self): return None class NetworkGuardTests(unittest.TestCase): def test_runtime_bootstrap_is_deferred_until_initialize(self): self.assertTrue(IMPORT_RUNTIME_WAS_DEFERRED) def test_client_address_is_local_accepts_loopback(self): self.assertTrue(APP.client_address_is_local("127.0.0.1")) self.assertTrue(APP.client_address_is_local("::1")) self.assertTrue(APP.client_address_is_local("localhost")) def test_client_address_is_local_rejects_non_loopback(self): self.assertFalse(APP.client_address_is_local("192.168.1.25")) self.assertFalse(APP.client_address_is_local("10.0.0.8")) def test_remote_access_requires_credentials_for_non_local_clients(self): handler = DummyHandler("/api/config") handler.client_address = ("192.168.1.25", 8421) with mock.patch.dict(os.environ, {"ANI_CLI_WEB_ALLOW_REMOTE": "1"}, clear=False): with self.assertRaises(PermissionError) as ctx: APP.Handler.ensure_client_access(handler) self.assertIn("AUTH_USERNAME", str(ctx.exception)) def test_remote_access_accepts_valid_basic_auth_for_non_local_clients(self): handler = DummyHandler("/api/config") handler.client_address = ("192.168.1.25", 8421) encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") handler.headers["Authorization"] = f"Basic {encoded}" with mock.patch.dict( os.environ, { "ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_AUTH_USERNAME": "demo", "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", }, clear=False, ): APP.Handler.ensure_client_access(handler) def test_remote_access_accepts_valid_session_cookie_for_non_local_clients(self): handler = DummyHandler("/api/config") handler.client_address = ("192.168.1.25", 8421) session_value = http_handler.remote_access_session_value( {"auth_username": "demo", "auth_password": "secret-pass"} ) handler.headers["Cookie"] = f"ani_cli_web_session={session_value}" with mock.patch.dict( os.environ, { "ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_AUTH_USERNAME": "demo", "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", }, clear=False, ): APP.Handler.ensure_client_access(handler) def test_remote_browser_get_without_session_renders_login_page(self): handler = DummyHandler("/") handler.client_address = ("192.168.1.25", 8421) handler.command = "GET" with mock.patch.dict( os.environ, { "ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_AUTH_USERNAME": "demo", "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", }, clear=False, ): APP.Handler.do_GET(handler) body = handler.wfile.getvalue().decode("utf-8") self.assertEqual(handler.response_status, HTTPStatus.UNAUTHORIZED) self.assertIn('
', body) self.assertIn("Username", body) self.assertIn("Password", body) def test_remote_login_post_sets_cookie_and_redirects(self): body = b"username=demo&password=secret-pass&next=%2Fwatchlist" handler = DummyHandler("/auth/login", body=body, content_length=len(body)) handler.client_address = ("192.168.1.25", 8421) with mock.patch.dict( os.environ, { "ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_AUTH_USERNAME": "demo", "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", }, clear=False, ): APP.Handler.do_POST(handler) headers = dict(handler.response_headers) self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER) self.assertEqual(headers.get("Location"), "/watchlist") self.assertIn("ani_cli_web_session=", headers.get("Set-Cookie", "")) def test_loopback_proxy_with_forwarded_remote_ip_requires_credentials(self): handler = DummyHandler("/api/config") handler.client_address = ("127.0.0.1", 8421) handler.headers["X-Forwarded-For"] = "203.0.113.10" with mock.patch.dict(os.environ, {"ANI_CLI_WEB_ALLOW_REMOTE": "1"}, clear=False): with self.assertRaises(PermissionError) as ctx: APP.Handler.ensure_client_access(handler) self.assertIn("AUTH_USERNAME", str(ctx.exception)) class DownloadQueueCancelTests(unittest.TestCase): def setUp(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) with queue._connect() as conn: conn.execute("DELETE FROM jobs") def test_cancel_updates_inflight_job_state(self): queue = object.__new__(APP.DownloadQueue) queue.lock = threading.RLock() queue.current_job_id = "job-1" queue.current_process = mock.Mock(pid=4321) queue.current_job = {"id": "job-1", "status": "running", "cancel_requested": False} saved = [] queue._save_job_locked = lambda job: saved.append(dict(job)) queue._find = lambda job_id: {"id": job_id, "status": "running", "cancel_requested": False} with mock.patch.object(APP.os, "getpgid", return_value=4321), mock.patch.object(APP.os, "killpg") as killpg: result = APP.DownloadQueue.cancel(queue, "job-1") killpg.assert_called_once() self.assertTrue(result["cancel_requested"]) self.assertTrue(queue.current_job["cancel_requested"]) self.assertEqual(saved[-1]["id"], "job-1") self.assertTrue(saved[-1]["cancel_requested"]) def test_append_log_batches_persistence(self): queue = object.__new__(APP.DownloadQueue) queue.lock = threading.RLock() saved = [] def fake_save(job): saved.append(list(job.get("log", []))) job["_dirty_log_lines"] = 0 job["_last_persist_monotonic"] = 0.0 queue._save_job_locked = fake_save job = {"log": [], "updated_at": APP.now_iso()} with mock.patch.object(queue_jobs.time, "monotonic", return_value=0.0): for index in range(queue_jobs.LOG_PERSIST_LINE_BATCH - 1): APP.DownloadQueue._append_log(queue, job, f"line {index}") self.assertEqual(saved, []) APP.DownloadQueue._append_log(queue, job, "line flush") self.assertEqual(len(saved), 1) self.assertEqual(saved[0][-1], "line flush") class QueueApiTests(unittest.TestCase): def setUp(self): APP.reset_runtime(wait=True) APP.initialize_runtime(start_workers=False) self.queue = APP.DOWNLOAD_QUEUE with self.queue._connect() as conn: conn.execute("DELETE FROM jobs") def test_download_queue_get_returns_single_job(self): created = self.queue.add( { "query": "Example Show", "title": "Example Show", "anime_name": "Example Show", "mode": "sub", "quality": "best", "episodes": "1", "download_dir": "/tmp/example", "season": "1", } ) fetched = self.queue.get(created["id"]) self.assertEqual(fetched["id"], created["id"]) self.assertEqual(fetched["title"], "Example Show") def test_queue_item_get_route_returns_single_job_payload(self): created = self.queue.add( { "query": "Route Show", "title": "Route Show", "anime_name": "Route Show", "mode": "sub", "quality": "best", "episodes": "1", "download_dir": "/tmp/example", "season": "1", } ) handler = DummyHandler(f"/api/queue/{created['id']}") APP.Handler.do_GET(handler) self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_payload["id"], created["id"]) def test_save_job_splits_log_and_extra_payload_columns(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) job = { "id": "job-structured", "show_id": "show-1", "title": "Structured Show", "anime_name": "Structured Show", "season": "1", "query": "Structured Show", "result_index": 1, "mode": "sub", "quality": "best", "episodes": "1-2", "download_dir": "/tmp/example", "status": "pending", "created_at": APP.now_iso(), "updated_at": APP.now_iso(), "log": ["line 1", "line 2"], "custom_flag": True, } with queue.lock: queue._save_job_locked(job) with queue._connect() as conn: row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job["id"],)).fetchone() self.assertEqual(row["title"], "Structured Show") self.assertEqual(row["log_payload"], '["line 1", "line 2"]') self.assertEqual(APP.json.loads(row["payload"]), {"custom_flag": True}) restored = queue._job_from_row(row) self.assertEqual(restored["log"], ["line 1", "line 2"]) self.assertTrue(restored["custom_flag"]) def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self): queue = object.__new__(APP.DownloadQueue) queue.lock = threading.RLock() queue.stop_event = threading.Event() queue.wakeup = threading.Event() queue.current_process = mock.Mock(pid=4321) queue.current_job = {"id": "job-1", "cancel_requested": False} queue.worker = mock.Mock() queue.worker.is_alive.side_effect = [True, False, False] with mock.patch.object(APP.os, "getpgid", return_value=4321), mock.patch.object(APP.os, "killpg") as killpg: stopped = APP.DownloadQueue.shutdown(queue, wait=True, cancel_active=False) self.assertTrue(stopped) self.assertTrue(queue.current_job["cancel_requested"]) killpg.assert_called_once_with(4321, queue_jobs.signal.SIGTERM) class DownloadQueueWorkerFailureTests(unittest.TestCase): def setUp(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) with queue._connect() as conn: conn.execute("DELETE FROM jobs") def test_run_job_marks_startup_failure_and_cleans_staging(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) staging_dir = queue_jobs.job_staging_dir(job) with mock.patch.object(queue_jobs.subprocess, "Popen", side_effect=FileNotFoundError("ani-cli missing")): queue._run_job(job) stored = queue._find(job["id"]) self.assertEqual(stored["status"], "failed") self.assertEqual(stored["exit_code"], 1) self.assertIn("Could not start download", stored["log"][-1]) self.assertFalse(staging_dir.exists()) def test_failed_download_cleans_staging_dir(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) staging_dir = queue_jobs.job_staging_dir(job) staging_dir.mkdir(parents=True, exist_ok=True) (staging_dir / "partial.mp4").write_text("partial", encoding="utf-8") class FakeProc: pid = 4321 stdout = [] def wait(self): return 1 with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()): queue._run_job(job) stored = queue._find(job["id"]) self.assertEqual(stored["status"], "failed") self.assertFalse(staging_dir.exists()) def test_run_job_marks_post_start_exception_failed_without_killing_worker_state(self): queue = APP.DownloadQueue(lambda: {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, start_worker=False) job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1"}) staging_dir = queue_jobs.job_staging_dir(job) class BrokenStdout: def __iter__(self): return self def __next__(self): raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "bad output") class FakeProc: pid = 4321 stdout = BrokenStdout() def terminate(self): return None with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), mock.patch.object( queue_jobs.os, "getpgid", side_effect=OSError("gone") ): queue._run_job(job) stored = queue._find(job["id"]) self.assertEqual(stored["status"], "failed") self.assertEqual(stored["exit_code"], 1) self.assertIn("unexpectedly", stored["log"][-1].lower()) self.assertIsNone(queue.current_process) self.assertIsNone(queue.current_job_id) self.assertIsNone(queue.current_job) self.assertFalse(staging_dir.exists()) def test_successful_download_sends_completion_notification(self): queue = APP.DownloadQueue( lambda: { "mode": "sub", "quality": "best", "download_dir": "/tmp/example", "discord_webhook_url": "https://discord.example/webhook", "discord_webhook_events": ["download_completed"], }, watchlist_sync_fn=lambda _job: { "show_id": "show-55", "title": "Queue Show", "category": "finished", "thumbnail_path": None, }, start_worker=False, ) job = queue.add({"query": "Queue Show", "title": "Queue Show", "season": "1", "episodes": "1-2"}) class FakeProc: pid = 4321 stdout = [] def wait(self): return 0 with mock.patch.object(queue_jobs.subprocess, "Popen", return_value=FakeProc()), mock.patch.object( queue_jobs, "finalize_library_files", return_value=["/tmp/example/Queue Show - S01E01.mp4"] ), mock.patch.object(queue_jobs, "send_discord_webhook_event") as send_webhook: queue._run_job(job) stored = queue._find(job["id"]) self.assertEqual(stored["status"], "done") self.assertIn("Download completed.", stored["log"]) send_webhook.assert_called_once() self.assertEqual(send_webhook.call_args.args[1], "download_completed") self.assertEqual(send_webhook.call_args.args[2]["title"], "Queue Show") self.assertEqual(send_webhook.call_args.args[2]["category"], "finished") class WatchlistRefreshAllTests(unittest.TestCase): def test_refresh_all_iterates_every_show_id(self): store = object.__new__(APP.WatchlistStore) calls = [] store.all_show_ids = lambda: ["show-a", "show-b", "show-c"] store.refresh = lambda show_id, include_thumbnail=True: calls.append((show_id, include_thumbnail)) or {"show_id": show_id} result = APP.WatchlistStore.refresh_all(store) self.assertEqual(calls, [("show-a", False), ("show-b", False), ("show-c", False)]) self.assertEqual(result["count"], 3) self.assertEqual([item["show_id"] for item in result["items"]], ["show-a", "show-b", "show-c"]) class WatchlistRefreshJobTests(unittest.TestCase): def setUp(self): with APP.WatchlistRefreshJobs(APP.WATCHLIST, start_worker=False)._connect() as conn: conn.execute("DELETE FROM watchlist_refresh_jobs") def test_refresh_job_tracks_completion_and_errors(self): class FakeStore: def all_show_ids(self): return ["show-a", "show-b"] def refresh(self, show_id, include_thumbnail=True): self.last_include_thumbnail = include_thumbnail if show_id == "show-a": return {"title": "Show A", "status": "updated"} return {"title": "Show B", "status": "error"} service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) started = service.start() self.assertTrue(started["created"]) job = service._next_pending() self.assertIsNotNone(job) service._run_job(job) status = service.status()["job"] self.assertIsNotNone(status) self.assertEqual(status["status"], "done") self.assertEqual(status["completed"], 2) self.assertEqual(status["errors"], 1) self.assertFalse(service.store.last_include_thumbnail) def test_refresh_job_skips_removed_items_without_thumbnail_warmup(self): calls = [] class FakeStore: def all_show_ids(self): return ["show-a", "show-b"] def refresh(self, show_id, include_thumbnail=True): calls.append((show_id, include_thumbnail)) if show_id == "show-a": return None return {"title": "Show B", "status": "updated"} service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) started = service.start() self.assertTrue(started["created"]) job = service._next_pending() self.assertIsNotNone(job) service._run_job(job) status = service.status()["job"] self.assertEqual(status["status"], "done") self.assertEqual(status["completed"], 2) self.assertEqual(status["errors"], 0) self.assertCountEqual(calls, [("show-a", False), ("show-b", False)]) def test_refresh_job_marks_interrupted_when_shutdown_requested(self): service = APP.WatchlistRefreshJobs(mock.Mock(), start_worker=False) job = { "id": "refresh-1", "status": "running", "created_at": APP.now_iso(), "updated_at": APP.now_iso(), "completed": 0, "errors": 0, "current_title": None, "message": "", } service.store.all_show_ids = lambda: ["show-a"] service.store.refresh = mock.Mock(return_value={"title": "Show A", "status": "updated"}) service.stop_event.set() service._run_job(job) self.assertEqual(job["status"], "interrupted") self.assertIn("interrupted", job["message"].lower()) def test_refresh_start_reuses_pending_job(self): class FakeStore: def all_show_ids(self): return [] def refresh(self, _show_id, include_thumbnail=True): return {"title": "Unused", "status": "updated"} service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) first = service.start() second = service.start() self.assertTrue(first["created"]) self.assertFalse(second["created"]) self.assertEqual(second["job"]["id"], first["job"]["id"]) self.assertEqual(second["job"]["status"], "pending") def test_scheduled_refresh_logs_queue_and_completion(self): class FakeStore: def all_show_ids(self): return ["show-a"] def refresh(self, _show_id, include_thumbnail=True): return {"title": "Show A", "status": "updated"} service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) with mock.patch("builtins.print") as print_mock: started = service.start(source="scheduled") job = service._next_pending() service._run_job(job) self.assertTrue(started["created"]) logged = "\n".join(call.args[0] for call in print_mock.call_args_list if call.args) self.assertIn("Scheduled refresh queued", logged) self.assertIn("Scheduled refresh started", logged) self.assertIn("Scheduled refresh finished successfully", logged) def test_refresh_history_marks_pending_jobs_interrupted_after_restart(self): class FakeStore: def all_show_ids(self): return [] def refresh(self, _show_id, include_thumbnail=True): return {"title": "Unused", "status": "updated"} service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) started = service.start() restarted = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False) status = restarted.status()["job"] self.assertEqual(status["id"], started["job"]["id"]) self.assertEqual(status["status"], "interrupted") self.assertIn("restart", status["message"].lower()) class WatchlistAlternativeTitleTests(unittest.TestCase): def setUp(self): APP.reset_runtime(wait=True) APP.initialize_runtime(start_workers=False) self.watchlist = APP.WATCHLIST with self.watchlist._connect() as conn: conn.execute("DELETE FROM watchlist") def test_watchlist_schema_includes_alternative_title_column(self): with self.watchlist._connect() as conn: columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()} self.assertIn("alternative_titles_json", columns) def test_refresh_caches_english_anidb_alternative_titles(self): self.watchlist.add({"show_id": "show-1", "title": "Attack on Titan", "category": "watching"}) with mock.patch.object( APP, "fetch_show_episode_snapshot", return_value={"show_id": "show-1", "title": "Attack on Titan", "episodes": {"sub": ["1", "2"], "dub": ["1"]}}, ), mock.patch.object( APP, "resolve_animeschedule_anime", return_value={"route": "attack-on-titan", "title": "Attack on Titan", "episodes": 2, "status": "Finished"}, ), mock.patch.object( APP, "resolve_anidb_aid", return_value={"aid": "16498", "title": "Attack on Titan"}, ), mock.patch.object( APP, "get_cached_anidb_titles", return_value=[ {"aid": "16498", "title": "Attack on Titan", "type": "official", "lang": "en", "normalized_title": "attack on titan"}, {"aid": "16498", "title": "AoT", "type": "syn", "lang": "en", "normalized_title": "aot"}, {"aid": "16498", "title": "Shingeki no Kyojin", "type": "official", "lang": "x-jat", "normalized_title": "shingeki no kyojin"}, {"aid": "16498", "title": "Attack Titan", "type": "short", "lang": "en", "normalized_title": "attack titan"}, ], ): item = self.watchlist.refresh("show-1", include_thumbnail=False) self.assertEqual(item["anidb_aid"], "16498") self.assertEqual( item["alternative_titles"], [ {"label": "English", "value": "AoT"}, {"label": "English", "value": "Attack Titan"}, ], ) self.assertTrue(item["has_alternative_titles"]) class ThumbnailEventRecoveryTests(unittest.TestCase): def test_ensure_thumbnails_skips_show_removed_during_batch(self): store = object.__new__(APP.WatchlistStore) item = { "show_id": "show-1", "thumbnail_ready": False, } store.get = mock.Mock(side_effect=[item, KeyError("Anime not found in watchlist.")]) store.ensure_thumbnail = mock.Mock(return_value=None) result = APP.WatchlistStore.ensure_thumbnails(store, ["show-1"]) self.assertEqual(result, {"items": []}) store.ensure_thumbnail.assert_called_once_with("show-1", force=False) def test_ensure_thumbnail_waiter_returns_none_after_show_is_removed(self): store = object.__new__(APP.WatchlistStore) store.lock = threading.RLock() inflight = threading.Event() inflight.set() store.thumbnail_events = {"show-1": inflight} store.has_show = lambda _show_id: False store.get = mock.Mock(return_value={ "show_id": "show-1", "title": "Show One", "thumbnail_path": None, "thumbnail_checked_at": None, "thumbnail_ready": False, "animeschedule_route": None, "animeschedule_title": None, "anidb_aid": None, "anidb_title": None, }) result = APP.WatchlistStore.ensure_thumbnail(store, "show-1") self.assertIsNone(result) self.assertEqual(store.get.call_count, 1) def test_ensure_thumbnail_clears_inflight_event_after_update_failure(self): store = object.__new__(APP.WatchlistStore) store.lock = threading.RLock() store.thumbnail_events = {} store.get = lambda _show_id: { "show_id": "show-1", "title": "Show One", "thumbnail_path": None, "thumbnail_checked_at": None, "thumbnail_ready": False, "animeschedule_route": None, "animeschedule_title": None, "anidb_aid": None, "anidb_title": None, } class BrokenConn: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, _sql, _params): raise RuntimeError("db write failed") store._connect = lambda: BrokenConn() with mock.patch.object(APP, "fetch_animeschedule_cover", return_value={"route": "route-1", "title": "Show One", "url": "https://example.com/cover.jpg"}), mock.patch.object( APP, "cache_thumbnail_image", return_value="show-1.jpg" ): with self.assertRaises(RuntimeError): APP.WatchlistStore.ensure_thumbnail(store, "show-1", force=False) self.assertEqual(store.thumbnail_events, {}) def test_ensure_thumbnail_records_failed_attempt_time_for_retry_backoff(self): now = APP.now_iso() item = { "show_id": "show-thumb-1", "title": "Broken Cover Show", "category": "watching", "downloaded": False, "status": "tracked", "status_message": "Waiting for thumbnail.", "created_at": now, "updated_at": now, "last_checked": None, "sub_count": 0, "dub_count": 0, "expected_count": 0, "airing_status": None, "sub_latest_episode": None, "dub_latest_episode": None, "animeschedule_route": None, "animeschedule_title": None, "anidb_aid": None, "anidb_title": None, "thumbnail_path": None, "thumbnail_checked_at": None, } with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: APP.WATCHLIST._upsert_conn(conn, item) with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("lookup failed")) as fetch_cover, mock.patch.object( APP, "fetch_anidb_cover", side_effect=RuntimeError("fallback failed") ) as fetch_fallback: result = APP.WATCHLIST.ensure_thumbnail("show-thumb-1") self.assertIsNone(result) self.assertEqual(fetch_cover.call_count, 1) self.assertEqual(fetch_fallback.call_count, 1) failed_item = APP.WATCHLIST.get("show-thumb-1") self.assertTrue(failed_item["thumbnail_checked_at"]) self.assertFalse(APP.thumbnail_retry_due(failed_item["thumbnail_checked_at"])) with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=AssertionError("retry window should suppress another lookup")), mock.patch.object( APP, "fetch_anidb_cover", side_effect=AssertionError("retry window should suppress fallback lookup") ): second = APP.WATCHLIST.ensure_thumbnail("show-thumb-1") self.assertIsNone(second) def test_ensure_thumbnail_falls_back_from_stale_animeschedule_route(self): now = APP.now_iso() item = { "show_id": "show-thumb-2", "title": "Route Fallback Show", "category": "watching", "downloaded": False, "status": "tracked", "status_message": "Waiting for thumbnail.", "created_at": now, "updated_at": now, "last_checked": None, "sub_count": 0, "dub_count": 0, "expected_count": 0, "airing_status": None, "sub_latest_episode": None, "dub_latest_episode": None, "animeschedule_route": "stale-route", "animeschedule_title": "Route Fallback Show", "anidb_aid": None, "anidb_title": None, "thumbnail_path": None, "thumbnail_checked_at": None, } with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: APP.WATCHLIST._upsert_conn(conn, item) with mock.patch.object(APP, "fetch_animeschedule_cover_by_route", side_effect=RuntimeError("stale route")), mock.patch.object( APP, "fetch_animeschedule_cover", return_value={"route": "fresh-route", "title": "Route Fallback Show", "url": "https://example.com/cover.jpg"} ) as title_lookup, mock.patch.object(APP, "cache_thumbnail_image", return_value="show-thumb-2.jpg"): result = APP.WATCHLIST.ensure_thumbnail("show-thumb-2") title_lookup.assert_called_once_with("Route Fallback Show") self.assertEqual(result, APP.THUMBNAIL_ROOT / "show-thumb-2.jpg") refreshed = APP.WATCHLIST.get("show-thumb-2") self.assertEqual(refreshed["animeschedule_route"], "fresh-route") def test_ensure_thumbnail_falls_back_from_stale_anidb_aid(self): now = APP.now_iso() item = { "show_id": "show-thumb-3", "title": "AniDB Fallback Show", "category": "watching", "downloaded": False, "status": "tracked", "status_message": "Waiting for thumbnail.", "created_at": now, "updated_at": now, "last_checked": None, "sub_count": 0, "dub_count": 0, "expected_count": 0, "airing_status": None, "sub_latest_episode": None, "dub_latest_episode": None, "animeschedule_route": None, "animeschedule_title": None, "anidb_aid": "12345", "anidb_title": "AniDB Fallback Show", "thumbnail_path": None, "thumbnail_checked_at": None, } with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: APP.WATCHLIST._upsert_conn(conn, item) with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("animeschedule failed")), mock.patch.object( APP, "fetch_anidb_cover_by_aid", side_effect=RuntimeError("stale aid") ), mock.patch.object( APP, "fetch_anidb_cover", return_value={"aid": "67890", "title": "AniDB Fallback Show", "url": "https://example.com/cover.jpg"} ) as title_lookup, mock.patch.object(APP, "cache_thumbnail_image", return_value="show-thumb-3.jpg"): result = APP.WATCHLIST.ensure_thumbnail("show-thumb-3") title_lookup.assert_called_once_with("AniDB Fallback Show") self.assertEqual(result, APP.THUMBNAIL_ROOT / "show-thumb-3.jpg") refreshed = APP.WATCHLIST.get("show-thumb-3") self.assertEqual(refreshed["anidb_aid"], "67890") class WatchlistCompletionTests(unittest.TestCase): def setUp(self): with APP.WATCHLIST._connect() as conn: conn.execute("DELETE FROM watchlist") def seed_watchlist_item(self, **overrides): now = APP.now_iso() item = { "show_id": "show-1", "title": "Example Show", "category": "watching", "downloaded": False, "status": "tracked", "status_message": "Waiting for the first manual refresh.", "created_at": now, "updated_at": now, "last_checked": None, "sub_count": 0, "dub_count": 0, "expected_count": 0, "airing_status": None, "sub_latest_episode": None, "dub_latest_episode": None, "animeschedule_route": None, "animeschedule_title": None, "anidb_aid": None, "anidb_title": None, "thumbnail_path": None, "thumbnail_checked_at": None, } item.update(overrides) with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: APP.WATCHLIST._upsert_conn(conn, item) def test_watchlist_status_message_marks_ready_modes(self): message = APP.watchlist_status_message(12, 10, expected_count=12, airing_status="finished") self.assertIn("Sub: 12/12 available.", message) self.assertIn("Dub: 10/12 available.", message) self.assertIn("AnimeSchedule: Finished.", message) self.assertIn("Sub ready.", message) self.assertNotIn("Dub ready.", message) def test_refresh_persists_expected_episode_total_and_ready_flags(self): self.seed_watchlist_item() snapshot = { "show_id": "show-1", "title": "Example Show", "episodes": { "sub": [str(index) for index in range(1, 13)], "dub": [str(index) for index in range(1, 13)], }, } schedule = { "route": "example-show", "title": "Example Show", "episodes": 12, "status": "Finished", } with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot), mock.patch.object( APP, "resolve_animeschedule_anime", return_value=schedule ), mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None): item = APP.WATCHLIST.refresh("show-1") self.assertEqual(item["expected_count"], 12) self.assertEqual(item["airing_status"], "Finished") self.assertTrue(item["sub_complete"]) self.assertTrue(item["dub_complete"]) self.assertEqual(item["sub_progress"], "12/12") self.assertEqual(item["dub_progress"], "12/12") self.assertEqual(item["animeschedule_route"], "example-show") self.assertIn("Sub ready.", item["status_message"]) self.assertIn("Dub ready.", item["status_message"]) def test_list_filters_by_category_and_reports_category_counts(self): self.seed_watchlist_item(show_id="show-1", title="Watching Show", category="watching", status="queued") self.seed_watchlist_item(show_id="show-2", title="Finished Show", category="finished", downloaded=True) listing = APP.WATCHLIST.list(category="watching") self.assertEqual(listing["active_category"], "watching") self.assertEqual(listing["summary"]["shows"], 2) self.assertEqual(listing["categories"]["watching"], 1) self.assertEqual(listing["categories"]["finished"], 1) self.assertEqual(listing["queued_count"], 1) self.assertEqual([item["show_id"] for item in listing["items"]], ["show-1"]) def test_list_clamps_out_of_range_page_before_querying_rows(self): self.seed_watchlist_item(show_id="show-1", title="Alpha Show", category="watching") self.seed_watchlist_item(show_id="show-2", title="Beta Show", category="watching") listing = APP.WATCHLIST.list(page=3, per_page=1, category="watching") self.assertEqual(listing["page"], 2) self.assertEqual(listing["pages"], 2) self.assertEqual([item["show_id"] for item in listing["items"]], ["show-2"]) def test_queue_refresh_marks_item_queued_without_sync_refresh(self): self.seed_watchlist_item(show_id="show-11", title="Refresh Show", category="watching", status="updated") with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object( APP.WATCHLIST, "refresh", side_effect=AssertionError("queue_refresh should not refresh synchronously") ): item = APP.WATCHLIST.queue_refresh("show-11", status_message="Queued from test.") schedule_refresh.assert_called_once_with("show-11", source="manual") self.assertEqual(item["status"], "queued") self.assertEqual(item["status_message"], "Queued from test.") def test_mark_downloaded_moves_existing_category_to_finished_and_sets_download_flag(self): self.seed_watchlist_item(show_id="show-9", title="Queue Show", category="planned", downloaded=False) with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object( APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3"]): item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show", mode="dub", episodes="2-3") self.assertEqual(item["category"], "finished") self.assertTrue(item["downloaded"]) self.assertEqual(item["finished_badge"], "downloaded") self.assertEqual(item["status"], "queued") self.assertEqual(item["downloaded_dub_episodes"], ["2", "3"]) def test_mark_downloaded_creates_missing_show_in_finished_category(self): with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object( APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") ): item = APP.WATCHLIST.mark_downloaded("show-10", title="Downloaded Show") self.assertEqual(item["category"], "finished") self.assertTrue(item["downloaded"]) self.assertEqual(item["finished_badge"], "downloaded") self.assertEqual(item["status"], "queued") def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self): self.seed_watchlist_item(show_id="legacy-show-9", title="Queue Show", category="watching", downloaded=False) with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object( APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") ): item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show") self.assertEqual(item["show_id"], "resolved-show-9") self.assertEqual(item["category"], "finished") self.assertTrue(item["downloaded"]) self.assertEqual(item["finished_badge"], "downloaded") self.assertEqual(item["status"], "queued") self.assertFalse(APP.WATCHLIST.has_show("legacy-show-9")) self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"]) def test_add_queues_refresh_without_sync_refresh(self): with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object( APP.WATCHLIST, "refresh", side_effect=AssertionError("add should not refresh synchronously") ): result = APP.WATCHLIST.add({"show_id": "show-22", "title": "Queued Show", "category": "watching"}) schedule_refresh.assert_called_once_with("show-22", source="initial") self.assertTrue(result["created"]) self.assertEqual(result["item"]["status"], "queued") self.assertIn("Refresh queued", result["message"]) def test_add_uses_requested_auto_download_series(self): with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)): result = APP.WATCHLIST.add( {"show_id": "show-23", "title": "Queued Show", "category": "watching", "auto_download_series": "4"} ) self.assertEqual(result["item"]["auto_download_series"], "4") def test_refresh_uses_bounded_request_timeout(self): self.seed_watchlist_item(show_id="show-1", title="Example Show") snapshot = { "show_id": "show-1", "title": "Example Show", "episodes": {"sub": ["1"], "dub": []}, } schedule = { "route": "example-show", "title": "Example Show", "episodes": 12, "status": "Finished", } with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot) as fetch_snapshot, mock.patch.object( APP, "resolve_animeschedule_anime", return_value=schedule ) as resolve_schedule, mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None): APP.WATCHLIST.refresh("show-1") fetch_snapshot.assert_called_once_with("show-1", timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS) resolve_schedule.assert_called_once_with( "Example Show", route=None, timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS, ) def test_sync_downloaded_job_to_watchlist_resolves_missing_show_id_from_search(self): job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"} with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show"}]), mock.patch.object( APP.WATCHLIST, "mark_downloaded", return_value={"show_id": "show-42"} ) as mark_downloaded: APP.sync_downloaded_job_to_watchlist(job) mark_downloaded.assert_called_once_with("show-42", title="Queue Show", mode="sub", episodes=None) def test_prepare_download_job_persists_resolved_show_id(self): job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show", "show_id": ""} with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show"}]): prepared = APP.prepare_download_job(dict(job)) self.assertEqual(prepared["show_id"], "show-42") self.assertEqual(prepared["title"], "Queue Show") def test_prepare_download_job_accepts_equivalent_season_notation(self): job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""} with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 2"}]): prepared = APP.prepare_download_job(dict(job)) self.assertEqual(prepared["show_id"], "show-42") self.assertEqual(prepared["title"], "Queue Show Season 2") def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self): item = {"show_id": "show-42", "title": "Queue Show"} job = {"id": "job-1", "show_id": "show-42", "episodes": "1-12", "mode": "dub"} with mock.patch.object(APP, "ensure_runtime", return_value={"watchlist": APP.WATCHLIST, "download_queue": mock.Mock(), "config": {"quality": "best", "download_dir": "/tmp/downloads"}}), mock.patch.object( APP.WATCHLIST, "get", return_value=item ), mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show", "query": "Queue Show", "index": 3}]), mock.patch.object( APP, "episode_list", return_value=["1", "2", "12"] ): runtime = APP.ensure_runtime() runtime["download_queue"].add.return_value = job result = APP.download_watchlist_item("show-42", "dub") runtime["download_queue"].add.assert_called_once_with( { "show_id": "show-42", "query": "Queue Show", "title": "Queue Show", "anime_name": "Queue Show", "season": "1", "index": 3, "mode": "dub", "quality": "best", "episodes": "1-12", "download_dir": "/tmp/downloads", } ) self.assertEqual(result["job"], job) self.assertIn("Queued Queue Show for dub download.", result["message"]) def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(self): job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"} with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Different Anime"}]): with self.assertRaises(ValueError) as ctx: APP.sync_downloaded_job_to_watchlist(job) self.assertIn("title mismatch", str(ctx.exception).lower()) def test_sync_downloaded_job_to_watchlist_rejects_cross_season_match(self): job = {"query": "Queue Show Season 2", "mode": "sub", "result_index": 1, "title": "Queue Show Season 2"} with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 1"}]): with self.assertRaises(ValueError) as ctx: APP.sync_downloaded_job_to_watchlist(job) self.assertIn("title mismatch", str(ctx.exception).lower()) class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase): def setUp(self): with APP.WATCHLIST._connect() as conn: conn.execute("DELETE FROM watchlist") def seed_watchlist_item(self, **overrides): now = APP.now_iso() item = { "show_id": "show-1", "title": "Example Show", "category": "watching", "downloaded": False, "status": "tracked", "status_message": "Waiting for the first manual refresh.", "created_at": now, "updated_at": now, "last_checked": None, "sub_count": 0, "dub_count": 0, "expected_count": 0, "airing_status": None, "sub_latest_episode": None, "dub_latest_episode": None, "animeschedule_route": None, "animeschedule_title": None, "anidb_aid": None, "anidb_title": None, "thumbnail_path": None, "thumbnail_checked_at": None, } item.update(overrides) with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: APP.WATCHLIST._upsert_conn(conn, item) def test_restart_restores_queued_watchlist_refreshes(self): self.seed_watchlist_item( show_id="show-b", title="Beta Show", status="queued", status_message="Queued refresh", updated_at="2026-05-15T10:00:00+00:00", ) self.seed_watchlist_item( show_id="show-a", title="Alpha Show", status="queued", status_message="Queued refresh", updated_at="2026-05-15T09:00:00+00:00", ) self.seed_watchlist_item( show_id="show-c", title="Gamma Show", status="updated", status_message="Updated", updated_at="2026-05-15T11:00:00+00:00", ) restored = APP.WatchlistStore(start_worker=False) self.assertEqual(restored.refresh_pending, [("show-a", "manual"), ("show-b", "manual")]) self.assertEqual(restored.refresh_pending_set, {"show-a", "show-b"}) class WatchlistRefreshQueueTests(unittest.TestCase): def test_schedule_refresh_skips_show_already_in_flight(self): store = object.__new__(APP.WatchlistStore) store.lock = threading.RLock() store.thumbnail_events = {} store.refresh_pending = [] store.refresh_pending_set = set() store.refresh_inflight_set = {"show-1"} store.refresh_wakeup = threading.Event() store.refresh_stop_event = threading.Event() store.refresh_worker = None store.worker_enabled = False store.ensure_worker = lambda: None store.get = lambda show_id: {"show_id": show_id, "status": "queued"} item = APP.WatchlistStore.schedule_refresh(store, "show-1") self.assertEqual(item["show_id"], "show-1") self.assertEqual(store.refresh_pending, []) self.assertEqual(store.refresh_pending_set, set()) self.assertEqual(store.refresh_inflight_set, {"show-1"}) def test_refresh_loop_clears_inflight_marker_after_refresh(self): store = object.__new__(APP.WatchlistStore) store.lock = threading.RLock() store.thumbnail_events = {} store.refresh_pending = [("show-2", "manual")] store.refresh_pending_set = {"show-2"} store.refresh_inflight_set = set() store.refresh_wakeup = threading.Event() store.refresh_stop_event = threading.Event() store.refresh_worker = None store.worker_enabled = False calls = [] def fake_refresh(show_id, refresh_source="manual"): calls.append((show_id, refresh_source)) store.refresh_stop_event.set() store.refresh = fake_refresh APP.WatchlistStore._refresh_loop(store) self.assertEqual(calls, [("show-2", "manual")]) self.assertEqual(store.refresh_inflight_set, set()) self.assertEqual(store.refresh_pending, []) self.assertEqual(store.refresh_pending_set, set()) def test_remove_drops_pending_and_inflight_refresh_state(self): store = object.__new__(APP.WatchlistStore) store.lock = threading.RLock() thumbnail_event = threading.Event() store.thumbnail_events = {"show-3": thumbnail_event} store.refresh_pending = [("show-3", "manual"), ("show-4", "manual")] store.refresh_pending_set = {"show-3", "show-4"} store.refresh_inflight_set = {"show-3"} store._connect = APP.WATCHLIST._connect store.get = lambda show_id: {"show_id": show_id, "title": "Queued Show", "thumbnail_path": None} class DummyCursor: rowcount = 1 class DummyConn: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, _sql, _params): return DummyCursor() store._connect = lambda: DummyConn() result = APP.WatchlistStore.remove(store, "show-3") self.assertEqual(result["item"]["show_id"], "show-3") self.assertEqual(store.refresh_pending, [("show-4", "manual")]) self.assertEqual(store.refresh_pending_set, {"show-4"}) self.assertEqual(store.refresh_inflight_set, set()) self.assertTrue(thumbnail_event.is_set()) self.assertNotIn("show-3", store.thumbnail_events) class ConfigSnapshotTests(unittest.TestCase): def test_get_config_snapshot_returns_copy(self): original = APP.CONFIG APP.CONFIG = { "mode": "sub", "quality": "best", "download_dir": "/tmp/example", "watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_minutes": 60, "watchlist_refresh_delay_seconds": 5, "auto_download_enabled": False, "auto_download_mode": "dub", "auto_download_quality": "best", "discord_webhook_url": "", "discord_webhook_events": [], } try: snapshot = APP.get_config_snapshot() snapshot["mode"] = "dub" self.assertEqual(APP.CONFIG["mode"], "sub") finally: APP.CONFIG = original def test_normalize_config_includes_watchlist_schedule_defaults(self): config = APP.normalize_config({}) self.assertFalse(config["watchlist_auto_refresh_enabled"]) self.assertEqual(config["watchlist_auto_refresh_minutes"], 60) self.assertEqual(config["watchlist_refresh_delay_seconds"], 5) self.assertFalse(config["auto_download_enabled"]) self.assertEqual(config["auto_download_mode"], "dub") self.assertEqual(config["auto_download_quality"], "best") self.assertEqual(config["discord_webhook_url"], "") self.assertEqual(config["discord_webhook_events"], []) def test_normalize_config_parses_watchlist_schedule_fields(self): config = APP.normalize_config( { "watchlist_auto_refresh_enabled": "true", "watchlist_auto_refresh_minutes": "15", "watchlist_refresh_delay_seconds": "7", "auto_download_enabled": "true", "auto_download_mode": "sub", "auto_download_quality": "720p", } ) self.assertTrue(config["watchlist_auto_refresh_enabled"]) self.assertEqual(config["watchlist_auto_refresh_minutes"], 15) self.assertEqual(config["watchlist_refresh_delay_seconds"], 7) self.assertTrue(config["auto_download_enabled"]) self.assertEqual(config["auto_download_mode"], "sub") self.assertEqual(config["auto_download_quality"], "720") def test_normalize_config_filters_discord_webhook_events(self): config = APP.normalize_config( { "discord_webhook_url": " https://discord.example/webhook ", "discord_webhook_events": [ "watchlist_refresh_new_episodes", "runtime_error", "runtime_error", "nope", ], } ) self.assertEqual(config["discord_webhook_url"], "https://discord.example/webhook") self.assertEqual( config["discord_webhook_events"], ["watchlist_refresh_new_episodes", "runtime_error"], ) def test_test_discord_webhook_config_requires_url(self): with self.assertRaises(ValueError): APP.test_discord_webhook_config({"discord_webhook_url": ""}) def test_test_discord_webhook_config_sends_test_event(self): with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: result = APP.test_discord_webhook_config( { "discord_webhook_url": "https://discord.example/webhook", "discord_webhook_events": [], } ) self.assertEqual(result["message"], "Discord webhook test notification sent.") send_webhook.assert_called_once() config, event_name = send_webhook.call_args.args[:2] self.assertEqual(config["discord_webhook_url"], "https://discord.example/webhook") self.assertEqual(event_name, "test") self.assertTrue(send_webhook.call_args.kwargs["force"]) class WatchlistAutoRefreshSchedulerTests(unittest.TestCase): def test_tick_waits_then_triggers_refresh(self): config = { "watchlist_auto_refresh_enabled": True, "watchlist_auto_refresh_minutes": 10, } start = mock.Mock(return_value={"created": True}) scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False) self.assertEqual(scheduler.tick(now_monotonic=100.0), 600) self.assertEqual(scheduler.tick(now_monotonic=699.0), 1) self.assertEqual(scheduler.tick(now_monotonic=700.0), 600) start.assert_called_once_with(source="scheduled") def test_refresh_job_waits_between_series_using_configured_delay(self): class FakeStore: def all_show_ids(self): return ["show-a", "show-b", "show-c"] def refresh(self, show_id, include_thumbnail=True): return {"title": show_id, "status": "updated"} service = APP.WatchlistRefreshJobs( FakeStore(), config_getter=lambda: {"watchlist_refresh_delay_seconds": 5}, start_worker=False, ) job = service.start()["job"] job = service._next_pending() with mock.patch.object(service.stop_event, "wait", return_value=False) as wait_mock: service._run_job(job) self.assertEqual(wait_mock.call_count, 2) wait_mock.assert_called_with(5) def test_refresh_job_sends_new_episode_and_failure_notifications(self): class FakeStore: def all_show_ids(self): return ["show-a", "show-b"] def refresh(self, show_id, include_thumbnail=True): if show_id == "show-a": return { "show_id": "show-a", "title": "Show A", "status": "updated", "had_new_episodes": True, "sub_count": 12, "dub_count": 4, "sub_delta": 1, "dub_delta": 0, "expected_count": 12, "airing_status": "Finished", "thumbnail_path": None, } return { "show_id": "show-b", "title": "Show B", "status": "error", "status_message": "Could not refresh episodes: boom", "had_new_episodes": False, } service = APP.WatchlistRefreshJobs( FakeStore(), config_getter=lambda: { "discord_webhook_url": "https://discord.example/webhook", "discord_webhook_events": [ "watchlist_refresh_new_episodes", "watchlist_refresh_failed", ], }, start_worker=False, ) job = service.start()["job"] job = service._next_pending() with mock.patch.object(queue_jobs, "send_discord_webhook_event") as send_webhook: service._run_job(job) self.assertEqual(send_webhook.call_count, 2) self.assertEqual(send_webhook.call_args_list[0].args[1], "watchlist_refresh_new_episodes") self.assertEqual(send_webhook.call_args_list[0].args[2]["items"][0]["title"], "Show A") self.assertEqual(send_webhook.call_args_list[1].args[1], "watchlist_refresh_failed") self.assertEqual(send_webhook.call_args_list[1].args[2]["items"][0]["title"], "Show B") def test_tick_resets_timer_when_schedule_changes(self): config = { "watchlist_auto_refresh_enabled": True, "watchlist_auto_refresh_minutes": 10, } start = mock.Mock(return_value={"created": True}) scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False) self.assertEqual(scheduler.tick(now_monotonic=100.0), 600) config["watchlist_auto_refresh_minutes"] = 5 self.assertEqual(scheduler.tick(now_monotonic=150.0), 300) start.assert_not_called() def test_tick_returns_none_when_disabled(self): config = { "watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_minutes": 10, } start = mock.Mock(return_value={"created": True}) scheduler = queue_jobs.WatchlistAutoRefreshScheduler(lambda: config, start, start_worker=False) self.assertIsNone(scheduler.tick(now_monotonic=100.0)) start.assert_not_called() class AutoDownloadQueueTests(unittest.TestCase): def test_queue_watchlist_auto_download_skips_initial_refresh(self): item = { "show_id": "show-1", "title": "Example Show", "category": "watching", "previous_last_checked": None, "had_new_episodes": True, } with mock.patch.object( APP, "ensure_runtime", return_value={"config": {"auto_download_enabled": True}, "download_queue": mock.Mock()}, ): result = APP.queue_watchlist_auto_download(item, refresh_source="initial") self.assertFalse(result["queued"]) self.assertEqual(result["reason"], "source:initial") def test_queue_watchlist_auto_download_queues_only_missing_new_episodes(self): item = { "show_id": "show-42", "title": "Queue Show", "category": "watching", "previous_last_checked": "2026-05-25T10:00:00+00:00", "had_new_episodes": False, "previous_dub_count": 10, "previous_sub_count": 10, "dub_episode_values": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"], "sub_episode_values": ["1", "2", "3"], "auto_download_mode": "dub", "auto_download_quality": "best", "auto_download_name": 'Queue Show: Alt/Name', "auto_download_series": "3", "downloaded_dub_episodes": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], "downloaded_sub_episodes": [], } download_queue = mock.Mock() download_queue.covered_episodes.return_value = {"11"} download_queue.add.side_effect = [ {"id": "job-12", "episodes": "12"}, ] runtime = { "config": { "auto_download_enabled": True, "auto_download_mode": "dub", "auto_download_quality": "best", "download_dir": "/tmp/downloads", }, "download_queue": download_queue, } with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show", "query": "Queue Show", "index": 2}], ): result = APP.queue_watchlist_auto_download(item, refresh_source="scheduled") self.assertTrue(result["queued"]) self.assertEqual(result["episodes"], ["12"]) download_queue.add.assert_called_once_with( { "show_id": "show-42", "query": "Queue Show", "title": "Queue Show", "anime_name": "Queue Show- Alt-Name", "season": "3", "index": 2, "mode": "dub", "quality": "best", "episodes": "12", "download_dir": "/tmp/downloads", } ) def test_queue_watchlist_auto_download_can_queue_existing_backlog_after_initial_seed_refresh(self): item = { "show_id": "show-99", "title": "Backlog Show", "category": "watching", "previous_last_checked": "2026-05-25T10:00:00+00:00", "had_new_episodes": False, "dub_episode_values": ["1", "2", "3"], "sub_episode_values": [], "auto_download_mode": "dub", "auto_download_quality": "best", "auto_download_name": "Backlog Show", "auto_download_series": "1", "downloaded_dub_episodes": [], "downloaded_sub_episodes": [], } download_queue = mock.Mock() download_queue.covered_episodes.return_value = set() download_queue.add.return_value = {"id": "job-backlog", "episodes": "1-3"} runtime = { "config": { "auto_download_enabled": True, "auto_download_mode": "dub", "auto_download_quality": "best", "download_dir": "/tmp/downloads", }, "download_queue": download_queue, } with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( APP, "search_anime", return_value=[{"id": "show-99", "title": "Backlog Show", "query": "Backlog Show", "index": 1}], ): result = APP.queue_watchlist_auto_download(item, refresh_source="manual") self.assertTrue(result["queued"]) self.assertEqual(result["episodes"], ["1-3"]) class StartupBehaviorTests(unittest.TestCase): def test_main_does_not_eagerly_initialize_runtime(self): server = mock.Mock() server.serve_forever.side_effect = KeyboardInterrupt() with mock.patch.object(APP, "initialize_runtime") as initialize_runtime, mock.patch.object( APP, "ThreadingHTTPServer", return_value=server ), mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime: shutdown_runtime.return_value = True APP.main() initialize_runtime.assert_not_called() shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=True) server.serve_forever.assert_called_once() server.server_close.assert_called_once() def test_reset_runtime_wait_defaults_to_cancel_active_downloads(self): APP.initialize_runtime(start_workers=False) try: with mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime: shutdown_runtime.return_value = True APP.reset_runtime(wait=True) shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=None) self.assertIsNone(APP.CONFIG) finally: APP.initialize_runtime(start_workers=False) def test_reset_runtime_refuses_to_clear_globals_when_shutdown_does_not_finish(self): APP.initialize_runtime(start_workers=False) try: current_config = APP.CONFIG current_watchlist = APP.WATCHLIST current_queue = APP.DOWNLOAD_QUEUE current_refresh = APP.WATCHLIST_REFRESH current_auto_refresh = APP.WATCHLIST_AUTO_REFRESH with mock.patch.object(APP, "shutdown_runtime", return_value=False): with self.assertRaises(RuntimeError): APP.reset_runtime(wait=True) self.assertIs(APP.CONFIG, current_config) self.assertIs(APP.WATCHLIST, current_watchlist) self.assertIs(APP.DOWNLOAD_QUEUE, current_queue) self.assertIs(APP.WATCHLIST_REFRESH, current_refresh) self.assertIs(APP.WATCHLIST_AUTO_REFRESH, current_auto_refresh) finally: APP.reset_runtime(wait=True) APP.initialize_runtime(start_workers=False) def test_save_runtime_config_replaces_runtime_config(self): original = APP.CONFIG original_scheduler = APP.WATCHLIST_AUTO_REFRESH try: APP.CONFIG = { "mode": "sub", "quality": "best", "download_dir": "/tmp/old", "watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_minutes": 60, "watchlist_refresh_delay_seconds": 5, "auto_download_enabled": False, "auto_download_mode": "dub", "auto_download_quality": "best", "discord_webhook_url": "", "discord_webhook_events": [], } APP.WATCHLIST_AUTO_REFRESH = mock.Mock() saved = APP.save_runtime_config( { "mode": "dub", "quality": "720", "download_dir": "/tmp/new", "watchlist_auto_refresh_enabled": True, "watchlist_auto_refresh_minutes": 15, "watchlist_refresh_delay_seconds": 8, "auto_download_enabled": True, "auto_download_mode": "sub", "auto_download_quality": "720", "discord_webhook_url": "https://discord.example/webhook", "discord_webhook_events": ["runtime_error"], } ) self.assertEqual(saved["mode"], "dub") self.assertEqual(APP.CONFIG["mode"], "dub") self.assertEqual(APP.CONFIG["quality"], "720") self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"]) self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15) self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8) self.assertTrue(APP.CONFIG["auto_download_enabled"]) self.assertEqual(APP.CONFIG["auto_download_mode"], "sub") self.assertEqual(APP.CONFIG["auto_download_quality"], "720") self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.example/webhook") self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"]) APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with() finally: APP.CONFIG = original APP.WATCHLIST_AUTO_REFRESH = original_scheduler def test_reset_runtime_clears_runtime_services(self): APP.initialize_runtime(start_workers=False) APP.reset_runtime(wait=True) try: self.assertIsNone(APP.CONFIG) self.assertIsNone(APP.WATCHLIST) self.assertIsNone(APP.DOWNLOAD_QUEUE) self.assertIsNone(APP.WATCHLIST_REFRESH) self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH) finally: APP.initialize_runtime(start_workers=False) class HandlerRouteTests(unittest.TestCase): def test_runtime_backed_route_respects_worker_disable_flag(self): handler = DummyHandler("/api/watchlist/refresh-status") APP.reset_runtime(wait=True) try: with mock.patch.dict(os.environ, {"ANI_CLI_WEB_DISABLE_WORKER": "1"}, clear=False): APP.Handler.do_GET(handler) self.assertFalse(APP.WATCHLIST.worker_enabled) self.assertIsNone(APP.WATCHLIST.refresh_worker) self.assertIsNone(APP.DOWNLOAD_QUEUE.worker) self.assertIsNone(APP.WATCHLIST_REFRESH.worker) self.assertIsNone(APP.WATCHLIST_AUTO_REFRESH.worker) finally: APP.reset_runtime(wait=True) APP.initialize_runtime(start_workers=False) def test_config_post_accepts_watchlist_schedule_fields(self): body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720"}' handler = DummyHandler("/api/config", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"]) self.assertEqual(handler.json_payload["watchlist_auto_refresh_minutes"], 25) self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9) self.assertTrue(handler.json_payload["auto_download_enabled"]) self.assertEqual(handler.json_payload["auto_download_mode"], "dub") self.assertEqual(handler.json_payload["auto_download_quality"], "720") def test_version_route_includes_ani_cli_version(self): handler = DummyHandler("/api/version") APP.Handler.do_GET(handler) self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_payload["name"], APP.APP_NAME) self.assertEqual(handler.json_payload["version"], APP.VERSION) self.assertIn("ani_cli_version", handler.json_payload) def test_config_post_accepts_discord_webhook_fields(self): body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}' handler = DummyHandler("/api/config", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_payload["discord_webhook_url"], "https://discord.example/webhook") self.assertEqual( handler.json_payload["discord_webhook_events"], ["runtime_error", "watchlist_refresh_failed"], ) def test_config_routes_hide_auth_fields_and_preserve_saved_credentials(self): APP.save_runtime_config( { "download_dir": "/tmp/example", "mode": "sub", "quality": "best", "auth_username": "demo", "auth_password": "secret-pass", } ) post_body = b'{"download_dir":"/tmp/updated","mode":"dub","quality":"720"}' post_handler = DummyHandler("/api/config", body=post_body, content_length=len(post_body)) APP.Handler.do_POST(post_handler) self.assertEqual(post_handler.json_status, HTTPStatus.OK) self.assertNotIn("auth_username", post_handler.json_payload) self.assertNotIn("auth_password", post_handler.json_payload) self.assertEqual(APP.get_config_snapshot()["auth_username"], "demo") self.assertEqual(APP.get_config_snapshot()["auth_password"], "secret-pass") get_handler = DummyHandler("/api/config") APP.Handler.do_GET(get_handler) self.assertEqual(get_handler.json_status, HTTPStatus.OK) self.assertNotIn("auth_username", get_handler.json_payload) self.assertNotIn("auth_password", get_handler.json_payload) def test_config_webhook_test_route_uses_unsaved_payload(self): body = b'{"discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error"]}' handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body)) test_webhook = mock.Mock(return_value={"message": "ok"}) handler.handler_context = mock.Mock(test_discord_webhook_config=test_webhook) APP.Handler.do_POST(handler) self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_payload["message"], "ok") test_webhook.assert_called_once_with( {"discord_webhook_url": "https://discord.example/webhook", "discord_webhook_events": ["runtime_error"]} ) def test_body_json_rejects_oversized_request(self): handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"{}", content_length=APP.MAX_JSON_BODY_BYTES + 1) with self.assertRaises(APP.HttpError) as ctx: APP.Handler.body_json(handler) self.assertEqual(ctx.exception.status, HTTPStatus.REQUEST_ENTITY_TOO_LARGE) def test_body_json_rejects_non_utf8_payload(self): handler = DummyHandler("/api/watchlist/upload-thumbnail", body=b"\xff", content_length=1) with self.assertRaises(ValueError) as ctx: APP.Handler.body_json(handler) self.assertIn("utf-8", str(ctx.exception).lower()) def test_config_post_rejects_non_object_json_body(self): body = b"[]" handler = DummyHandler("/api/config", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) self.assertIn("json object", handler.error_message.lower()) def test_remove_post_missing_show_id_returns_bad_request(self): handler = DummyHandler("/api/watchlist/remove", body=b"{}", content_length=2) APP.Handler.do_POST(handler) self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) self.assertIn("show_id", handler.error_message) def test_upload_thumbnail_post_missing_required_fields_returns_bad_request(self): body = b'{"show_id":"show-1"}' handler = DummyHandler("/api/watchlist/upload-thumbnail", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) self.assertIn("data_url", handler.error_message) def test_watchlist_add_duplicate_returns_ok_instead_of_created(self): body = b'{"show_id":"show-1","title":"Show 1","category":"watching"}' handler = DummyHandler("/api/watchlist/add", body=body, content_length=len(body)) result = {"message": "Already tracking Show 1.", "item": {"show_id": "show-1", "title": "Show 1"}, "created": False} with mock.patch.object(APP.WATCHLIST, "add", return_value=result): APP.Handler.do_POST(handler) self.assertEqual(handler.json_payload, result) self.assertEqual(handler.json_status, HTTPStatus.OK) def test_watchlist_add_rejects_non_object_json_body(self): body = b"[]" handler = DummyHandler("/api/watchlist/add", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) self.assertIn("json object", handler.error_message.lower()) def test_ensure_thumbnails_rejects_non_array_show_ids(self): body = b'{"show_ids":"show-1"}' handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) self.assertIn("show_ids", handler.error_message) def test_ensure_thumbnails_parses_string_false_force(self): body = b'{"show_ids":["show-1"],"force":"false"}' handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body)) with mock.patch.object(APP.WATCHLIST, "ensure_thumbnails", return_value={"items": []}) as ensure_thumbnails: APP.Handler.do_POST(handler) ensure_thumbnails.assert_called_once_with(["show-1"], limit=6, force=False) self.assertEqual(handler.json_status, HTTPStatus.OK) def test_ensure_thumbnails_rejects_invalid_force_value(self): body = b'{"show_ids":["show-1"],"force":"maybe"}' handler = DummyHandler("/api/watchlist/ensure-thumbnails", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) self.assertIn("force", handler.error_message) def test_thumbnail_route_serves_cached_file_without_fetching(self): thumb_path = APP.THUMBNAIL_ROOT / "show-1.jpg" thumb_path.parent.mkdir(parents=True, exist_ok=True) thumb_path.write_bytes(b"jpg") handler = DummyHandler("/api/watchlist/thumb/show-1") with mock.patch.object(APP.WATCHLIST, "get", return_value={"thumbnail_path": "show-1.jpg"}) as get_item, mock.patch.object( APP.WATCHLIST, "ensure_thumbnail", side_effect=AssertionError("thumbnail fetch should not run") ): APP.Handler.do_GET(handler) get_item.assert_called_once_with("show-1") self.assertEqual(handler.file_path, thumb_path) self.assertIsNone(handler.error_status) def test_watchlist_refresh_status_route_returns_json(self): handler = DummyHandler("/api/watchlist/refresh-status") payload = {"job": {"id": "refresh-1", "status": "running"}, "running": True} with mock.patch.object(APP.WATCHLIST_REFRESH, "status", return_value=payload): APP.Handler.do_GET(handler) self.assertEqual(handler.json_payload, payload) self.assertEqual(handler.json_status, HTTPStatus.OK) def test_update_all_post_returns_accepted_for_new_job(self): handler = DummyHandler("/api/watchlist/update-all", body=b"{}", content_length=2) payload = {"message": "Watchlist refresh started.", "job": {"id": "refresh-1"}, "created": True} with mock.patch.object(APP.WATCHLIST_REFRESH, "start", return_value=payload): APP.Handler.do_POST(handler) self.assertEqual(handler.json_payload, payload) self.assertEqual(handler.json_status, HTTPStatus.ACCEPTED) def test_update_status_post_queues_refresh(self): body = b'{"show_id":"show-1"}' handler = DummyHandler("/api/watchlist/update-status", body=body, content_length=len(body)) queued_item = {"show_id": "show-1", "title": "Show 1", "status": "queued"} with mock.patch.object(APP.WATCHLIST, "queue_refresh", return_value=queued_item): APP.Handler.do_POST(handler) self.assertEqual(handler.json_payload, {"message": "Queued refresh for Show 1.", "item": queued_item}) self.assertEqual(handler.json_status, HTTPStatus.OK) def test_watchlist_download_all_post_returns_created_job(self): body = b'{"show_id":"show-1","mode":"sub"}' handler = DummyHandler("/api/watchlist/download-all", body=body, content_length=len(body)) payload = {"message": "Queued Show 1 for sub download.", "job": {"id": "job-1"}, "item": {"show_id": "show-1"}} handler.handler_context = mock.Mock(download_watchlist_item=mock.Mock(return_value=payload)) APP.Handler.do_POST(handler) self.assertEqual(handler.json_payload, payload) self.assertEqual(handler.json_status, HTTPStatus.CREATED) def test_watchlist_auto_download_settings_post_returns_updated_item(self): body = b'{"show_id":"show-1","mode":"dub","quality":"best","name":"Example Name","series":"2"}' handler = DummyHandler("/api/watchlist/update-auto-download", body=body, content_length=len(body)) payload = {"message": "Saved auto-download settings for Show 1.", "item": {"show_id": "show-1"}} handler.handler_context = mock.Mock(update_watchlist_auto_download=mock.Mock(return_value=payload)) APP.Handler.do_POST(handler) self.assertEqual(handler.json_payload, payload) self.assertEqual(handler.json_status, HTTPStatus.OK) def test_generic_handler_exception_skips_traceback_when_debug_disabled(self): handler = DummyHandler("/api/config") with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object( http_handler, "debug_log" ) as debug_log, mock.patch.object(http_handler, "send_discord_webhook_event") as send_webhook, mock.patch.object( http_handler.traceback, "print_exc" ) as print_exc: APP.Handler.exception(handler, RuntimeError("boom")) print_exc.assert_not_called() send_webhook.assert_called_once() debug_log.assert_called_once() self.assertEqual(handler.error_status, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertEqual(handler.error_message, "Internal server error.") class TemplateHelperTests(unittest.TestCase): def test_log_message_skips_queue_poll_requests(self): handler = object.__new__(APP.Handler) handler.path = "/api/queue?page=1&per_page=10" handler.command = "GET" handler.address_string = lambda: "127.0.0.1" with mock.patch("builtins.print") as print_mock: APP.Handler.log_message(handler, '"GET /api/queue?page=1&per_page=10 HTTP/1.1" %s -', 200) print_mock.assert_not_called() def test_log_message_keeps_non_poll_requests(self): handler = object.__new__(APP.Handler) handler.path = "/api/config" handler.command = "GET" handler.address_string = lambda: "127.0.0.1" with mock.patch("builtins.print") as print_mock: APP.Handler.log_message(handler, '"GET /api/config HTTP/1.1" %s -', 200) print_mock.assert_called_once() def test_page_links_marks_active_page(self): markup = APP.render_page_links("config") self.assertIn('class="page-link active" href="/config"', markup) self.assertIn('class="page-link" href="/queue"', markup) self.assertIn('class="page-link" href="/watchlist"', markup) def test_sidebar_brand_can_attach_optional_badge_id(self): markup = APP.render_sidebar_brand("Shared note", "Search", badge_id="serverBadge") self.assertIn('id="serverBadge"', markup) self.assertIn("Shared note", markup) def test_queue_page_queue_loader_guards_against_stale_responses(self): self.assertIn("queueRequestId", APP.QUEUE_HTML) self.assertIn("if (requestId !== state.queueRequestId) return data;", APP.QUEUE_HTML) def test_queue_page_renders_queue_logs_newest_first(self): self.assertIn('[...(job.log || [])].slice(-28).reverse().join("\\n")', APP.QUEUE_HTML) def test_pages_share_browser_api_helper(self): self.assertIn("async function api(path, options = {})", APP.INDEX_HTML) self.assertIn("async function api(path, options = {})", APP.QUEUE_HTML) self.assertIn("async function api(path, options = {})", APP.CONFIG_HTML) self.assertIn("async function api(path, options = {})", APP.WATCHLIST_HTML) def test_config_page_places_dependencies_before_settings_grid(self): self.assertIn('
', APP.CONFIG_HTML) self.assertIn('
', APP.CONFIG_HTML) self.assertLess(APP.CONFIG_HTML.index('
'), APP.CONFIG_HTML.index('
')) self.assertIn("@media (max-width: 1080px)", APP.CONFIG_HTML) self.assertIn("justify-items: stretch;", APP.CONFIG_HTML) self.assertIn('id="aniCliVersionLine"', APP.CONFIG_HTML) self.assertIn('ani-cli ${data.ani_cli_version || "Unavailable"}', APP.CONFIG_HTML) def test_search_page_sends_watchlist_auto_download_series(self): self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML) def test_pages_share_serial_polling_helper(self): self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML) self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML) self.assertIn("startSerialPoll(() => pollVisibleJobs(), 2500)", APP.QUEUE_HTML) self.assertIn("startSerialPoll(async () => {", APP.WATCHLIST_HTML) self.assertIn("window.clearTimeout(timer);", APP.QUEUE_HTML) self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.QUEUE_HTML) self.assertIn('window.addEventListener("beforeunload", stop, { once: true });', APP.WATCHLIST_HTML) def test_watchlist_page_polls_when_queued_items_exist(self): self.assertIn("queuedCount: 0", APP.WATCHLIST_HTML) self.assertIn("else if (state.queuedCount > 0)", APP.WATCHLIST_HTML) self.assertIn("fetchWatchlist(state.watchlistPage, { silent: true })", APP.WATCHLIST_HTML) def test_watchlist_page_highlights_fully_ready_cards(self): self.assertIn(".anime-card.fully-ready", APP.WATCHLIST_HTML) self.assertIn("const isFullyReady = item.sub_complete && item.dub_complete;", APP.WATCHLIST_HTML) self.assertIn("card.className = watchlistCardClassName(item);", APP.WATCHLIST_HTML) def test_watchlist_page_download_all_replaces_open_action(self): self.assertIn("Download all", APP.WATCHLIST_HTML) self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML) self.assertIn('class="download-picker"', APP.WATCHLIST_HTML) self.assertIn('downloadPicker.classList.toggle("visible")', APP.WATCHLIST_HTML) self.assertIn('downloadWatchlistItem(item, "sub"', APP.WATCHLIST_HTML) self.assertIn('downloadWatchlistItem(item, "dub"', APP.WATCHLIST_HTML) def test_watchlist_page_renders_alternative_titles_toggle(self): self.assertIn("Alternative titles", APP.WATCHLIST_HTML) self.assertIn('class="ghost alt-titles-toggle"', APP.WATCHLIST_HTML) self.assertIn('id="altTitlesOverlay"', APP.WATCHLIST_HTML) self.assertIn("openAltTitlesOverlay(item)", APP.WATCHLIST_HTML) self.assertIn("closeAltTitlesOverlay()", APP.WATCHLIST_HTML) self.assertIn("AniDB English alternative titles were not available for this show.", APP.WATCHLIST_HTML) def test_watchlist_page_renders_auto_download_editor(self): self.assertIn("Auto-download", APP.WATCHLIST_HTML) self.assertIn('id="autoDownloadOverlay"', APP.WATCHLIST_HTML) self.assertIn('api("/api/watchlist/update-auto-download"', APP.WATCHLIST_HTML) self.assertIn("openAutoDownloadOverlay(item)", APP.WATCHLIST_HTML) self.assertIn("closeAutoDownloadOverlay()", APP.WATCHLIST_HTML) self.assertIn("Save auto-download", APP.WATCHLIST_HTML) self.assertIn("Manual add does not trigger it", APP.WATCHLIST_HTML) self.assertIn("Name source", APP.WATCHLIST_HTML) self.assertIn('class="wide-half">Name source', APP.WATCHLIST_HTML) self.assertIn('class="wide-half">Name', APP.WATCHLIST_HTML) self.assertIn("Custom", APP.WATCHLIST_HTML) self.assertIn("Series", APP.WATCHLIST_HTML) def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self): self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML) self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML) self.assertIn("if (state.thumbnailWarmupTokens[item.show_id] === token) continue;", APP.WATCHLIST_HTML) self.assertIn("state.thumbnailWarmupPending.delete(showId);", APP.WATCHLIST_HTML) class AniDbCacheTests(unittest.TestCase): def test_get_cached_anidb_titles_reuses_cached_entries(self): original_cache = dict(APP.ANIDB_TITLES_CACHE) try: cached_entries = [{"aid": "1", "title": "Example", "normalized_title": "example"}] APP.ANIDB_TITLES_CACHE.update( { "mtime": 123.0, "entries": cached_entries, "title_index": {"example": [0]}, "token_index": {"example": [0]}, "match_cache": {}, } ) with mock.patch.object(APP, "ensure_anidb_titles_dump") as ensure_dump: ensure_dump.return_value = mock.Mock(exists=lambda: True, stat=lambda: mock.Mock(st_mtime=123.0)) result = APP.get_cached_anidb_titles() self.assertIs(result, cached_entries) finally: APP.ANIDB_TITLES_CACHE.clear() APP.ANIDB_TITLES_CACHE.update(original_cache) if __name__ == "__main__": unittest.main()