import os import uuid from datetime import datetime, timezone, timedelta import pytest from fastapi.testclient import TestClient # Ensure required env vars for app import/config os.environ.setdefault("SECRET_KEY", "x" * 32) os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite") from app.main import app # noqa: E402 from app.auth.security import get_current_user, get_admin_user # noqa: E402 from app.database.base import SessionLocal # noqa: E402 from app.models.jobs import JobRecord # noqa: E402 from app.models.audit import AuditLog # noqa: E402 class _User: def __init__(self, is_admin: bool): self.id = 1 if is_admin else 2 self.username = "admin" if is_admin else "user" self.is_admin = is_admin self.is_active = True self.first_name = "Test" self.last_name = "User" @pytest.fixture() def client_admin(): app.dependency_overrides[get_current_user] = lambda: _User(True) app.dependency_overrides[get_admin_user] = lambda: _User(True) try: yield TestClient(app) finally: app.dependency_overrides.pop(get_current_user, None) app.dependency_overrides.pop(get_admin_user, None) @pytest.fixture() def client_user(): app.dependency_overrides[get_current_user] = lambda: _User(False) try: yield TestClient(app) finally: app.dependency_overrides.pop(get_current_user, None) @pytest.fixture() def make_job(): """Factory to create a JobRecord in the test database.""" created_job_ids = [] def _create( *, job_type: str, status: str = "queued", requested_by_username: str = "user", started_at: datetime | None = None, completed_at: datetime | None = None, total_requested: int = 0, total_success: int = 0, total_failed: int = 0, details: dict | None = None, ) -> JobRecord: db = SessionLocal() try: j = JobRecord( job_id=uuid.uuid4().hex, job_type=job_type, status=status, requested_by_username=requested_by_username, started_at=started_at or datetime.now(timezone.utc), completed_at=completed_at, total_requested=total_requested, total_success=total_success, total_failed=total_failed, details=details or {}, ) db.add(j) db.commit() db.refresh(j) created_job_ids.append(j.job_id) return j finally: db.close() yield _create # Optional: cleanup created jobs to reduce cross-test noise if created_job_ids: db = SessionLocal() try: for jid in created_job_ids: row = db.query(JobRecord).filter(JobRecord.job_id == jid).first() if row: db.delete(row) db.commit() finally: db.close() def test_jobs_list_and_filtering(client_admin: TestClient, client_user: TestClient, make_job): jt = f"pytest_jobs_{uuid.uuid4().hex[:6]}" # Create jobs: two for non-admin user, one for admin j_user_running = make_job(job_type=jt, status="running", requested_by_username="user") j_user_failed = make_job(job_type=jt, status="failed", requested_by_username="user") j_admin_completed = make_job(job_type=jt, status="completed", requested_by_username="admin") # Non-admin sees only their jobs by default (mine=True) resp = client_user.get("/api/jobs/", params={"include_total": 1}) assert resp.status_code == 200 body = resp.json() assert set(body.keys()) == {"items", "total"} assert body["total"] >= 2 ids = [it["job_id"] for it in body["items"]] assert j_user_running.job_id in ids and j_user_failed.job_id in ids assert j_admin_completed.job_id not in ids # Filter by status resp = client_user.get("/api/jobs/", params={"include_total": 1, "status_filter": "failed"}) assert resp.status_code == 200 body = resp.json() assert body["total"] >= 1 assert any(it["job_id"] == j_user_failed.job_id for it in body["items"]) # Filter by type resp = client_user.get("/api/jobs/", params={"include_total": 1, "type_filter": jt}) assert resp.status_code == 200 body = resp.json() assert body["total"] >= 2 # Search across job_type resp = client_user.get("/api/jobs/", params={"search": jt}) assert resp.status_code == 200 ids = [it["job_id"] for it in resp.json()] assert j_user_running.job_id in ids and j_user_failed.job_id in ids # Admin can list all with mine=false and filter by requested_by # Because fixtures share global dependency overrides, ensure admin override is active for this request from app.main import app as _app from app.auth.security import get_current_user as _get_current_user _prev = _app.dependency_overrides.get(_get_current_user) try: _app.dependency_overrides[_get_current_user] = lambda: _User(True) c = TestClient(_app) resp = c.get("/api/jobs/", params={"mine": 0, "include_total": 1}) assert resp.status_code == 200 body = resp.json() assert body["total"] >= 3 finally: if _prev is not None: _app.dependency_overrides[_get_current_user] = _prev else: _app.dependency_overrides.pop(_get_current_user, None) resp = client_admin.get("/api/jobs/", params={"mine": 0, "include_total": 1, "requested_by": "user"}) assert resp.status_code == 200 body = resp.json() assert body["total"] >= 2 def test_jobs_get_by_id_permissions(client_admin: TestClient, client_user: TestClient, make_job): jt = f"pytest_jobs_{uuid.uuid4().hex[:6]}" j_admin = make_job(job_type=jt, status="completed", requested_by_username="admin") j_user = make_job(job_type=jt, status="running", requested_by_username="user") # Non-admin cannot access someone else's job resp = client_user.get(f"/api/jobs/{j_admin.job_id}") assert resp.status_code in (403, 404) if resp.status_code == 403: assert "Not enough permissions" in resp.text # Non-admin can access own job resp = client_user.get(f"/api/jobs/{j_user.job_id}") assert resp.status_code == 200 assert resp.json()["job_id"] == j_user.job_id # Admin can access any job resp = client_admin.get(f"/api/jobs/{j_user.job_id}") assert resp.status_code == 200 assert resp.json()["job_id"] == j_user.job_id def _audit_exists(job_id: str, action: str) -> bool: db = SessionLocal() try: return ( db.query(AuditLog) .filter(AuditLog.resource_type == "JOB", AuditLog.resource_id == job_id, AuditLog.action == action) .count() > 0 ) finally: db.close() def test_jobs_state_transitions_audit_and_metrics(client_admin: TestClient, make_job): jt = f"pytest_jobs_{uuid.uuid4().hex[:6]}" # Create a job and transition to running -> completed j = make_job(job_type=jt, status="queued", requested_by_username="admin") resp = client_admin.post(f"/api/jobs/{j.job_id}/mark-running") assert resp.status_code == 200, resp.text body = resp.json() assert body["status"] == "running" assert body["started_at"] is not None assert body["completed_at"] is None assert _audit_exists(j.job_id, "RUNNING") resp = client_admin.post( f"/api/jobs/{j.job_id}/mark-completed", json={ "total_success": 5, "total_failed": 0, "result_storage_path": "exports/test_bundle.html", "result_mime_type": "text/html", "result_size": 123, "details_update": {"note": "done"}, }, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["status"] == "completed" assert body["total_success"] == 5 assert body["total_failed"] == 0 assert body["has_result_bundle"] is True assert _audit_exists(j.job_id, "COMPLETE") # Create another job, transition to running -> failed j2 = make_job(job_type=jt, status="queued", requested_by_username="admin") resp = client_admin.post(f"/api/jobs/{j2.job_id}/mark-running") assert resp.status_code == 200 resp = client_admin.post( f"/api/jobs/{j2.job_id}/mark-failed", json={"reason": "manual-error", "details_update": {"code": "E1"}}, ) assert resp.status_code == 200 body = resp.json() assert body["status"] == "failed" # Fetch job to verify details include last_error resp = client_admin.get(f"/api/jobs/{j2.job_id}") assert resp.status_code == 200 details = resp.json().get("details") or {} assert details.get("last_error") == "manual-error" assert _audit_exists(j2.job_id, "FAIL") # Retry an existing job note = "retry it" resp = client_admin.post(f"/api/jobs/{j2.job_id}/retry", json={"note": note}) assert resp.status_code == 200 new_job_id = resp.json().get("job_id") assert isinstance(new_job_id, str) and new_job_id resp = client_admin.get(f"/api/jobs/{new_job_id}") assert resp.status_code == 200 new_details = resp.json().get("details") or {} assert new_details.get("retry_of") == j2.job_id assert new_details.get("retry_note") == note # Leave one job running for metrics j3 = make_job(job_type=jt, status="queued", requested_by_username="admin") resp = client_admin.post(f"/api/jobs/{j3.job_id}/mark-running") assert resp.status_code == 200 # Metrics summary (admin-only) resp = client_admin.get("/api/jobs/metrics/summary") assert resp.status_code == 200 metrics = resp.json() # Shape assertions assert set(metrics.keys()) == { "by_status", "by_type", "avg_duration_seconds", "running_count", "failed_last_24h", "completed_last_24h", } assert isinstance(metrics["by_status"], dict) assert isinstance(metrics["by_type"], dict) # Our type appears with count at least the number we created in this test # Created: j (completed), j2 (failed), retry (queued), j3 (running) => 4 with this type assert metrics["by_type"].get(jt, 0) >= 4 # Running count should be at least 1 (j3) assert metrics.get("running_count", 0) >= 1 # Ensure status buckets contain our transitions (may include others as well) assert metrics["by_status"].get("completed", 0) >= 1 assert metrics["by_status"].get("failed", 0) >= 1