""" Offline unit tests for job + Scheduler task plumbing (Phase C item 5). Covers: - job_cleanup_loop: invokes cleanup_expired_jobs per pass, survives transient errors, honors cancellation - register_scheduler_tasks.py: dry-run default, payload contents (explicit production user, auth placeholder, schedules) """ import asyncio from unittest.mock import AsyncMock import pytest from src.jobs.job_manager import job_cleanup_loop class TestJobCleanupLoop: @pytest.mark.asyncio async def test_invokes_cleanup_each_pass(self): manager = AsyncMock() passes = await job_cleanup_loop(manager, interval_seconds=0, max_iterations=3) assert passes == 3 assert manager.cleanup_expired_jobs.await_count == 3 @pytest.mark.asyncio async def test_transient_error_does_not_kill_loop(self): manager = AsyncMock() manager.cleanup_expired_jobs = AsyncMock( side_effect=[RuntimeError("redis hiccup"), None] ) passes = await job_cleanup_loop(manager, interval_seconds=0, max_iterations=2) assert passes == 2 assert manager.cleanup_expired_jobs.await_count == 2 @pytest.mark.asyncio async def test_cancellation_stops_loop(self): manager = AsyncMock() task = asyncio.create_task(job_cleanup_loop(manager, interval_seconds=60)) await asyncio.sleep(0) # let it start sleeping task.cancel() with pytest.raises(asyncio.CancelledError): await task class TestSchedulerTaskDefinitions: def _load_module(self): import importlib return importlib.import_module("scripts.register_scheduler_tasks") def test_four_production_payloads_defined(self): mod = self._load_module() names = {t["task_name"] for t in mod.TASKS} assert names == { "library_integrity_check", "library_quality_report", "library_paperless_orphan_cleanup", } updates = {u["task_name"]: u["updates"] for u in mod.TASK_UPDATES} assert updates == {"test_example_task": {"enabled": False}} def test_schedules(self): mod = self._load_module() by_name = {t["task_name"]: t for t in mod.TASKS} integrity = by_name["library_integrity_check"] assert (integrity["hour"], integrity["minute"], integrity["day_of_week"]) == (4, 30, -1) quality = by_name["library_quality_report"] # Sunday 03:00 (Scheduler: 0 = Monday .. 6 = Sunday) assert (quality["hour"], quality["minute"], quality["day_of_week"]) == (3, 0, 6) paperless = by_name["library_paperless_orphan_cleanup"] assert (paperless["hour"], paperless["minute"], paperless["day_of_week"]) == (5, 0, -1) def test_payloads_use_explicit_production_user_and_placeholder(self): mod = self._load_module() for task in mod.TASKS: config = task["config"] auth = config["headers"]["Authorization"] assert auth == f"Bearer {mod.API_KEY_PLACEHOLDER}" # Explicit production tenant in body or query string (Phase B) body_user = config.get("body", {}).get("user") assert body_user == "jpmschweitzer" or "user=jpmschweitzer" in config["url"] def test_paperless_task_hits_existing_endpoint(self): mod = self._load_module() task = next(t for t in mod.TASKS if t["task_name"] == "library_paperless_orphan_cleanup") assert "/maintenance/cleanup/paperless" in task["config"]["url"] assert "dry_run=false" in task["config"]["url"] def test_substitute_api_key_replaces_placeholder_without_mutating(self): mod = self._load_module() original = mod.TASKS[0] resolved = mod.substitute_api_key(original, "sekret") assert resolved["config"]["headers"]["Authorization"] == "Bearer sekret" # The module-level definition keeps the placeholder assert mod.API_KEY_PLACEHOLDER in original["config"]["headers"]["Authorization"] def test_dry_run_is_default_and_sends_nothing(self, capsys, monkeypatch): mod = self._load_module() monkeypatch.setattr("sys.argv", ["register_scheduler_tasks.py"]) monkeypatch.setenv("SCHEDULER_URL", "http://scheduler.test:8090") def _boom(*args, **kwargs): # any HTTP client construction = failure raise AssertionError("dry-run must not contact the Scheduler") monkeypatch.setattr(mod.httpx, "Client", _boom) assert mod.main() == 0 out = capsys.readouterr().out assert "DRY RUN" in out assert "library_integrity_check" in out assert mod.API_KEY_PLACEHOLDER in out # placeholder, never a real key