changes
This commit is contained in:
292
tests/test_jobs_api.py
Normal file
292
tests/test_jobs_api.py
Normal file
@@ -0,0 +1,292 @@
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user