This commit is contained in:
HotSwapp
2025-08-18 20:20:04 -05:00
parent 89b2bc0aa2
commit bac8cc4bd5
114 changed files with 30258 additions and 1341 deletions

292
tests/test_jobs_api.py Normal file
View 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