feat: defer runtime bootstrap and harden watchlist refresh

This commit is contained in:
Dymas
2026-05-14 07:00:48 +02:00
parent 5feee92bcd
commit a6ef93cc95
6 changed files with 2998 additions and 1663 deletions
+282
View File
@@ -0,0 +1,282 @@
#!/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()