Release ani-cli-web 0.26.0

This commit is contained in:
Dymas
2026-05-15 23:56:11 +02:00
parent a6ef93cc95
commit 6b1e0ce7f0
13 changed files with 5667 additions and 3440 deletions
+640 -6
View File
@@ -18,6 +18,9 @@ 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
@@ -29,12 +32,16 @@ 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)
@@ -82,8 +89,46 @@ class NetworkGuardTests(unittest.TestCase):
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_token_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("REMOTE_TOKEN", str(ctx.exception))
def test_remote_access_accepts_valid_token_for_non_local_clients(self):
handler = DummyHandler("/api/config")
handler.client_address = ("192.168.1.25", 8421)
handler.headers["Authorization"] = "Bearer secret-token"
with mock.patch.dict(
os.environ,
{"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"},
clear=False,
):
APP.Handler.ensure_client_access(handler)
def test_loopback_proxy_with_forwarded_remote_ip_requires_token(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("REMOTE_TOKEN", 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()
@@ -104,19 +149,94 @@ class DownloadQueueCancelTests(unittest.TestCase):
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")
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 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}
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", "show-b", "show-c"])
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"]], calls)
self.assertEqual([item["show_id"] for item in result["items"]], ["show-a", "show-b", "show-c"])
class WatchlistRefreshJobTests(unittest.TestCase):
@@ -129,7 +249,8 @@ class WatchlistRefreshJobTests(unittest.TestCase):
def all_show_ids(self):
return ["show-a", "show-b"]
def refresh(self, show_id):
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"}
@@ -147,13 +268,41 @@ class WatchlistRefreshJobTests(unittest.TestCase):
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_start_reuses_pending_job(self):
class FakeStore:
def all_show_ids(self):
return []
def refresh(self, _show_id):
def refresh(self, _show_id, include_thumbnail=True):
return {"title": "Unused", "status": "updated"}
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
@@ -170,7 +319,7 @@ class WatchlistRefreshJobTests(unittest.TestCase):
def all_show_ids(self):
return []
def refresh(self, _show_id):
def refresh(self, _show_id, include_thumbnail=True):
return {"title": "Unused", "status": "updated"}
service = APP.WatchlistRefreshJobs(FakeStore(), start_worker=False)
@@ -184,6 +333,44 @@ class WatchlistRefreshJobTests(unittest.TestCase):
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()
@@ -221,7 +408,384 @@ class ThumbnailEventRecoveryTests(unittest.TestCase):
self.assertEqual(store.thumbnail_events, {})
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_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: 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")
self.assertEqual(item["status"], "queued")
self.assertEqual(item["status_message"], "Queued from test.")
def test_mark_downloaded_keeps_existing_category_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: 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-9", title="Queue Show")
self.assertEqual(item["category"], "planned")
self.assertTrue(item["downloaded"])
self.assertEqual(item["finished_badge"], "")
self.assertEqual(item["status"], "queued")
def test_mark_downloaded_creates_missing_show_in_finished_category(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: 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_add_queues_refresh_without_sync_refresh(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: 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")
self.assertTrue(result["created"])
self.assertEqual(result["item"]["status"], "queued")
self.assertIn("Refresh queued", result["message"])
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")
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")
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", "show-b"])
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"]
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):
calls.append(show_id)
store.refresh_stop_event.set()
store.refresh = fake_refresh
APP.WatchlistStore._refresh_loop(store)
self.assertEqual(calls, ["show-2"])
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", "show-4"]
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"])
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"}
try:
snapshot = APP.get_config_snapshot()
snapshot["mode"] = "dub"
self.assertEqual(APP.CONFIG["mode"], "sub")
finally:
APP.CONFIG = original
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:
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:
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_save_runtime_config_replaces_runtime_config(self):
original = APP.CONFIG
try:
APP.CONFIG = {"mode": "sub", "quality": "best", "download_dir": "/tmp/old"}
saved = APP.save_runtime_config({"mode": "dub", "quality": "720", "download_dir": "/tmp/new"})
self.assertEqual(saved["mode"], "dub")
self.assertEqual(APP.CONFIG["mode"], "dub")
self.assertEqual(APP.CONFIG["quality"], "720")
finally:
APP.CONFIG = original
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)
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)
finally:
APP.reset_runtime(wait=True)
APP.initialize_runtime(start_workers=False)
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:
@@ -265,6 +829,27 @@ class HandlerRouteTests(unittest.TestCase):
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_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.traceback, "print_exc") as print_exc:
APP.Handler.exception(handler, RuntimeError("boom"))
print_exc.assert_not_called()
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_page_links_marks_active_page(self):
@@ -277,6 +862,55 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn('id="serverBadge"', markup)
self.assertIn("Shared note", markup)
def test_search_page_queue_loader_guards_against_stale_responses(self):
self.assertIn("queueRequestId", APP.INDEX_HTML)
self.assertIn("if (requestId !== state.queueRequestId) return data;", APP.INDEX_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.CONFIG_HTML)
self.assertIn("async function api(path, options = {})", APP.WATCHLIST_HTML)
def test_pages_share_serial_polling_helper(self):
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.INDEX_HTML)
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)
self.assertIn("startSerialPoll(() => loadQueue(), 2500)", APP.INDEX_HTML)
self.assertIn("startSerialPoll(async () => {", 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_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()