""" 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 goes through the executor's auth block so the Scheduler # substitutes ${LIBRARY_API_KEY} from ITS environment at # execution time (plain headers are NOT substituted). assert config["auth"] == { "type": "bearer", "token": mod.API_KEY_PLACEHOLDER, } assert "Authorization" not in config.get("headers", {}) # The executor sends config["payload"] as the JSON body ("body" # would be silently ignored) assert "body" not in config # Explicit production tenant in payload or query string (Phase B) payload_user = config.get("payload", {}).get("user") assert payload_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_no_client_side_key_substitution(self): """The raw API key must never be resolved client-side — that would store it hardcoded in the Scheduler's scheduled_tasks.config.""" mod = self._load_module() assert not hasattr(mod, "substitute_api_key") for task in mod.TASKS: assert mod.API_KEY_PLACEHOLDER in task["config"]["auth"]["token"] 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 def test_execute_requires_scheduler_api_key(self, capsys, monkeypatch): """--execute must refuse to run without SCHEDULER_API_KEY (the Scheduler's task endpoints are Bearer-guarded; without the key every probe 401s and registration silently fails).""" mod = self._load_module() monkeypatch.setattr( "sys.argv", ["register_scheduler_tasks.py", "--execute"] ) monkeypatch.setenv("SCHEDULER_URL", "http://scheduler.test:8090") monkeypatch.delenv("SCHEDULER_API_KEY", raising=False) def _boom(*args, **kwargs): raise AssertionError("must not contact the Scheduler without a key") monkeypatch.setattr(mod.httpx, "Client", _boom) assert mod.main() == 1 assert "SCHEDULER_API_KEY" in capsys.readouterr().out def test_execute_sends_scheduler_bearer_auth(self, monkeypatch): """The registrar's own HTTP client must carry Authorization: Bearer $SCHEDULER_API_KEY on every call.""" import httpx as real_httpx mod = self._load_module() seen = {"auth_headers": [], "paths": []} def handler(request: real_httpx.Request) -> real_httpx.Response: seen["auth_headers"].append(request.headers.get("Authorization")) path = request.url.path seen["paths"].append(f"{request.method} {path}") if path == "/health": return real_httpx.Response(200, json={"status": "healthy"}) if request.method == "GET" and path.startswith("/tasks/"): return real_httpx.Response(404) # not registered yet return real_httpx.Response(200, json={"ok": True}) real_client = real_httpx.Client def client_factory(**kwargs): kwargs["transport"] = real_httpx.MockTransport(handler) return real_client(**kwargs) monkeypatch.setattr(mod.httpx, "Client", client_factory) assert mod.execute("http://scheduler.test:8090", "sched-key-123") == 0 assert seen["auth_headers"], "no HTTP calls were made" assert all(h == "Bearer sched-key-123" for h in seen["auth_headers"]) # All three tasks created (404 probe -> POST /tasks) assert seen["paths"].count("POST /tasks") == len(mod.TASKS)