- CORS: drop allow_credentials (wildcard origin + credentials told
browsers to attach credentials for any site); origins configurable via
CORS_ALLOW_ORIGINS (default * is safe without credentials). Verified
live: preflight no longer advertises access-control-allow-credentials.
- Scheduler tasks: auth moved from a plain Authorization header (which
the Scheduler's rest_api_executor does NOT env-substitute) to its
auth {type: bearer, token: ${LIBRARY_API_KEY}} block, substituted from
the Scheduler's own environment at execution time. The registrar no
longer resolves the real key client-side, so it can never be persisted
into the scheduled_tasks.config JSONB column. Also fixed: JSON bodies
moved from the ignored "body" key to "payload" (the executor only
reads config["payload"], so the tasks would have POSTed empty bodies
and failed required-user validation).
- Reranker: parsed ranking indices are deduplicated preserving first
occurrence (an LLM answer like "3,3,1" duplicated a result).
- HybridRAG wiring consolidated into dependencies.get_hybrid_rag_service
(now including volatile_service); the inline copies in /query/hybrid
and /wiki/pages/smart-create are gone - smart-create previously ran
without the volatile leg, and the singleton was unused.
- Remaining Neo4j writes (GraphService ingestion/deletes/purges/entity
mentions, webhook rename+delete cleanup, document-sync _index_graph,
consolidation mark-processed/add-entity) moved from auto-commit
execute_query to execute_write managed transactions with retry.
Verified end-to-end on the local dev server as llm_tester: /query/hybrid
200 with all five legs ok (volatile now active), background persistence
landed as one transaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
131 lines
5.1 KiB
Python
131 lines
5.1 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
|