""" tests/backend/test_api.py Tests for the ExCALIBR API flow: - Job submission - Status transitions - Results, CSV, meta, fits endpoints - Cleanup - Startup recovery """ import io import json import shutil from datetime import datetime, timedelta from pathlib import Path import pytest # ── Helpers ─────────────────────────────────────────────────────────────────── VALID_CSV = ( "score,sample_assignments\n" + "1.5,0\n" * 10 + "-1.5,1\n" * 10 + "0.1,3\n" * 5 ) VALID_PARAMS = { "components": [2, 3], "nBootstraps": 10, "fitsPerBootstrap": 5, "benignMethod": "avg", "conservativeMonotonicity": True, } def _submit(client, csv_content=VALID_CSV, params=VALID_PARAMS, email=""): return client.post( "/api/jobs", data={"params": json.dumps(params), "email": email}, files={"file": ("test.csv", io.BytesIO(csv_content.encode()), "text/csv")}, ) def _submit_and_get_id(client) -> str: res = _submit(client) assert res.status_code == 200 return res.json()["job_id"] # ── Job submission ──────────────────────────────────────────────────────────── class TestJobSubmission: def test_returns_job_id(self, client): res = _submit(client) assert res.status_code == 200 body = res.json() assert "job_id" in body assert len(body["job_id"]) == 36 # UUID format def test_job_id_is_valid_uuid(self, client): import uuid res = _submit(client) job_id = res.json()["job_id"] # Should not raise uuid.UUID(job_id) def test_job_directory_created(self, client): from backend.api import JOBS_DIR job_id = _submit_and_get_id(client) assert (JOBS_DIR / job_id).is_dir() def test_input_csv_saved(self, client): from backend.api import JOBS_DIR job_id = _submit_and_get_id(client) assert (JOBS_DIR / job_id / "input.csv").exists() def test_meta_json_created(self, client): from backend.api import JOBS_DIR job_id = _submit_and_get_id(client) meta_path = JOBS_DIR / job_id / "meta.json" assert meta_path.exists() meta = json.loads(meta_path.read_text()) assert meta["job_id"] == job_id assert meta["status"] == "pending" def test_original_filename_stored(self, client): res = client.post( "/api/jobs", data={"params": json.dumps(VALID_PARAMS), "email": ""}, files={"file": ("my_dataset.csv", io.BytesIO(VALID_CSV.encode()), "text/csv")}, ) job_id = res.json()["job_id"] from backend.api import jobs assert jobs[job_id]["original_filename"] == "my_dataset.csv" def test_email_stored_when_provided(self, client): res = _submit(client, email="user@example.com") job_id = res.json()["job_id"] from backend.api import jobs assert jobs[job_id]["email"] == "user@example.com" def test_email_none_when_not_provided(self, client): job_id = _submit_and_get_id(client) from backend.api import jobs assert jobs[job_id]["email"] is None # ── Status endpoint ─────────────────────────────────────────────────────────── class TestStatusEndpoint: def test_initial_status_is_pending_or_running(self, client): job_id = _submit_and_get_id(client) res = client.get(f"/api/jobs/{job_id}/status") assert res.status_code == 200 assert res.json()["status"] in ("pending", "running") def test_status_contains_job_id(self, client): job_id = _submit_and_get_id(client) res = client.get(f"/api/jobs/{job_id}/status") assert res.json()["job_id"] == job_id def test_failed_status_includes_error(self, client): job_id = _submit_and_get_id(client) from backend.api import jobs jobs[job_id]["status"] = "failed" jobs[job_id]["error"] = "Pipeline encountered an error processing your dataset." res = client.get(f"/api/jobs/{job_id}/status") assert res.json()["status"] == "failed" assert "error" in res.json() def test_failed_status_does_not_expose_internal_error(self, client): job_id = _submit_and_get_id(client) from backend.api import jobs jobs[job_id]["status"] = "failed" jobs[job_id]["error"] = "Pipeline encountered an error processing your dataset." jobs[job_id]["error_internal"] = "/var/www/secret/path.py traceback" res = client.get(f"/api/jobs/{job_id}/status") assert "secret" not in res.text assert "/var/www" not in res.text def test_complete_status_has_no_error(self, client): job_id = _submit_and_get_id(client) from backend.api import jobs jobs[job_id]["status"] = "complete" res = client.get(f"/api/jobs/{job_id}/status") assert "error" not in res.json() # ── Results endpoint ────────────────────────────────────────────────────────── class TestResultsEndpoint: def _make_complete_job(self, client) -> tuple[str, Path]: """Submit a job, mark it complete, and create fake output files.""" from backend.api import jobs, JOBS_DIR job_id = _submit_and_get_id(client) output_dir = JOBS_DIR / job_id / "output" output_dir.mkdir(exist_ok=True) dataset_name = jobs[job_id]["dataset_name"] # Create fake output files (output_dir / f"{dataset_name}_2c_calibration.json").write_text('{"dataset": "test"}') (output_dir / f"{dataset_name}_bootstrap_fits.json.gz").write_bytes(b"fake") jobs[job_id]["status"] = "complete" jobs[job_id]["output_dir"] = output_dir return job_id, output_dir def test_returns_409_when_pending(self, client): job_id = _submit_and_get_id(client) from backend.api import jobs jobs[job_id]["status"] = "pending" res = client.get(f"/api/jobs/{job_id}/results") assert res.status_code == 409 def test_returns_409_when_running(self, client): job_id = _submit_and_get_id(client) from backend.api import jobs jobs[job_id]["status"] = "running" res = client.get(f"/api/jobs/{job_id}/results") assert res.status_code == 409 def test_returns_409_when_failed(self, client): job_id = _submit_and_get_id(client) from backend.api import jobs jobs[job_id]["status"] = "failed" res = client.get(f"/api/jobs/{job_id}/results") assert res.status_code == 409 def test_returns_calibration_and_fits_urls_when_complete(self, client): job_id, _ = self._make_complete_job(client) res = client.get(f"/api/jobs/{job_id}/results") assert res.status_code == 200 body = res.json() assert "calibration" in body assert "fits" in body assert isinstance(body["calibration"], list) assert len(body["calibration"]) > 0 def test_calibration_url_contains_job_id(self, client): job_id, _ = self._make_complete_job(client) res = client.get(f"/api/jobs/{job_id}/results") assert job_id in res.json()["calibration"][0] # ── CSV endpoint ────────────────────────────────────────────────────────────── class TestCSVEndpoint: def test_serves_original_csv(self, client): job_id = _submit_and_get_id(client) res = client.get(f"/api/jobs/{job_id}/csv") assert res.status_code == 200 assert "score" in res.text assert "sample_assignments" in res.text def test_csv_content_matches_upload(self, client): job_id = _submit_and_get_id(client) res = client.get(f"/api/jobs/{job_id}/csv") assert res.text == VALID_CSV def test_csv_content_type(self, client): job_id = _submit_and_get_id(client) res = client.get(f"/api/jobs/{job_id}/csv") assert "text/csv" in res.headers["content-type"] # ── Meta endpoint ───────────────────────────────────────────────────────────── class TestMetaEndpoint: def test_returns_job_id(self, client): job_id = _submit_and_get_id(client) res = client.get(f"/api/jobs/{job_id}/meta") assert res.status_code == 200 assert res.json()["job_id"] == job_id def test_returns_original_filename(self, client): res = client.post( "/api/jobs", data={"params": json.dumps(VALID_PARAMS), "email": ""}, files={"file": ("my_data.csv", io.BytesIO(VALID_CSV.encode()), "text/csv")}, ) job_id = res.json()["job_id"] meta = client.get(f"/api/jobs/{job_id}/meta").json() assert meta["original_filename"] == "my_data.csv" def test_returns_dataset_name(self, client): job_id = _submit_and_get_id(client) meta = client.get(f"/api/jobs/{job_id}/meta").json() assert "dataset_name" in meta # ── Cleanup ─────────────────────────────────────────────────────────────────── class TestCleanup: def test_old_jobs_removed_from_memory(self, client): from backend.api import jobs, JOBS_DIR, _cleanup_old_jobs job_id = _submit_and_get_id(client) # Age the job artificially jobs[job_id]["created_at"] = datetime.utcnow() - timedelta(weeks=2) _cleanup_old_jobs() assert job_id not in jobs def test_old_job_directory_deleted(self, client): from backend.api import jobs, JOBS_DIR, _cleanup_old_jobs job_id = _submit_and_get_id(client) job_dir = JOBS_DIR / job_id assert job_dir.exists() jobs[job_id]["created_at"] = datetime.utcnow() - timedelta(weeks=2) _cleanup_old_jobs() # Give filesystem a moment import time time.sleep(0.1) assert not job_dir.exists() def test_recent_jobs_not_removed(self, client): from backend.api import jobs, _cleanup_old_jobs job_id = _submit_and_get_id(client) _cleanup_old_jobs() assert job_id in jobs # ── Startup recovery ────────────────────────────────────────────────────────── class TestStartupRecovery: def test_running_jobs_marked_failed_on_recovery(self, client): from backend.api import jobs, JOBS_DIR, _write_meta, _recover_jobs_from_disk job_id = _submit_and_get_id(client) jobs[job_id]["status"] = "running" _write_meta(job_id) # Simulate restart — clear memory and recover from disk saved = jobs.pop(job_id) _recover_jobs_from_disk() assert job_id in jobs assert jobs[job_id]["status"] == "failed" def test_complete_jobs_recovered_correctly(self, client): from backend.api import jobs, JOBS_DIR, _write_meta, _recover_jobs_from_disk job_id = _submit_and_get_id(client) jobs[job_id]["status"] = "complete" _write_meta(job_id) jobs.pop(job_id) _recover_jobs_from_disk() assert job_id in jobs assert jobs[job_id]["status"] == "complete" def test_expired_jobs_not_recovered(self, client): from backend.api import jobs, JOBS_DIR, _write_meta, _recover_jobs_from_disk job_id = _submit_and_get_id(client) jobs[job_id]["created_at"] = datetime.utcnow() - timedelta(weeks=2) _write_meta(job_id) jobs.pop(job_id) _recover_jobs_from_disk() assert job_id not in jobs