#!/usr/bin/env python3 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)) 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) class DummyHandler: def __init__(self, path, body=b"{}", content_length=None): self.path = path self.client_address = ("127.0.0.1", 8421) 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 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) 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")) class DownloadQueueCancelTests(unittest.TestCase): 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"]) 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: calls.append(show_id) or {"show_id": show_id} result = APP.WatchlistStore.refresh_all(store) self.assertEqual(calls, ["show-a", "show-b", "show-c"]) self.assertEqual(result["count"], 3) self.assertEqual([item["show_id"] for item in result["items"]], calls) 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): 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) def test_refresh_start_reuses_pending_job(self): class FakeStore: def all_show_ids(self): return [] def refresh(self, _show_id): 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_refresh_history_marks_pending_jobs_interrupted_after_restart(self): class FakeStore: def all_show_ids(self): return [] def refresh(self, _show_id): 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 ThumbnailEventRecoveryTests(unittest.TestCase): 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, {}) class HandlerRouteTests(unittest.TestCase): 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_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) class TemplateHelperTests(unittest.TestCase): 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="/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) if __name__ == "__main__": unittest.main()