Files
library-desk/tests/test_job_plumbing.py
T
jpmschweitzerandClaude Fable 5 b4a5a92fee fix: send Scheduler API Bearer auth from the task registrar
The Scheduler's task-management endpoints (GET/POST /tasks, PUT
/tasks/{name}) are guarded by verify_api_key, but execute() built a bare
httpx.Client with no Authorization header: the existence probe 401'd
(misread as 'task absent') and every POST/PUT registration failed, so
--execute was never runnable end-to-end against the real Scheduler.

--execute now requires SCHEDULER_API_KEY from the environment (never
stored) and sends Authorization: Bearer on all registrar HTTP calls.
Deploy notes updated alongside the LIBRARY_API_KEY requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
2026-07-14 15:13:16 +02:00

182 lines
7.3 KiB
Python

"""
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)