""" tests/backend/test_pipeline.py Integration tests that actually run the pipeline subprocess. These are slower than unit tests (~2-5 minutes) and require the excalibr310 conda environment and pipeline dependencies to be installed. Run with: make test-pipeline or: pytest tests/backend/test_pipeline.py -v """ import gzip import io import json import time import pytest from pathlib import Path # ── Fixtures ────────────────────────────────────────────────────────────────── FIXTURE_CSV_PATH = Path(__file__).parent.parent / "fixtures" / "MSH2_Jia_2021.csv" VALID_CSV = FIXTURE_CSV_PATH.read_text() VALID_PARAMS = { "components": [2], "nBootstraps": 20, "fitsPerBootstrap": 5, "benignMethod": "avg", "conservativeMonotonicity": True, } def _submit_and_wait(client, csv_content=VALID_CSV, params=VALID_PARAMS, timeout=300) -> dict: """Submit a job and poll until complete or failed. Returns final status response.""" res = client.post( "/api/jobs", data={"params": json.dumps(params), "email": ""}, files={"file": ("test.csv", io.BytesIO(csv_content.encode()), "text/csv")}, ) assert res.status_code == 200, f"Submission failed: {res.text}" job_id = res.json()["job_id"] deadline = time.time() + timeout while time.time() < deadline: status_res = client.get(f"/api/jobs/{job_id}/status") assert status_res.status_code == 200 status = status_res.json()["status"] if status in ("complete", "failed"): return status_res.json() time.sleep(5) pytest.fail(f"Job {job_id} did not complete within {timeout}s") # ── End-to-end pipeline run ─────────────────────────────────────────────────── class TestPipelineIntegration: def test_pipeline_completes_successfully(self, client): """Pipeline runs to completion with a valid BasicScoreset CSV.""" result = _submit_and_wait(client) assert result["status"] == "complete", \ f"Pipeline failed with error: {result.get('error')}" def test_output_files_exist(self, client): """All expected output files are created after a successful run.""" from backend.api import jobs, JOBS_DIR res = client.post( "/api/jobs", data={"params": json.dumps(VALID_PARAMS), "email": ""}, files={"file": ("test.csv", io.BytesIO(VALID_CSV.encode()), "text/csv")}, ) job_id = res.json()["job_id"] _submit_and_wait(client) # wait for completion # Re-fetch the latest completed job completed = [jid for jid, m in jobs.items() if m["status"] == "complete"] assert len(completed) > 0 job_id = completed[-1] output_dir = jobs[job_id]["output_dir"] dataset_name = jobs[job_id]["dataset_name"] assert (output_dir / f"{dataset_name}_2c_calibration.json").exists(), \ "Calibration JSON missing" assert (output_dir / f"{dataset_name}_bootstrap_fits.json.gz").exists(), \ "Bootstrap fits file missing" def test_calibration_json_has_required_fields(self, client): """Calibration JSON contains the fields the frontend expects.""" from backend.api import jobs _submit_and_wait(client) completed = [jid for jid, m in jobs.items() if m["status"] == "complete"] assert len(completed) > 0 job_id = completed[-1] output_dir = jobs[job_id]["output_dir"] dataset_name = jobs[job_id]["dataset_name"] calib_path = output_dir / f"{dataset_name}_2c_calibration.json" with open(calib_path) as f: calib = json.load(f) required_fields = ["prior", "point_ranges", "dataset", "n_c", "scoreset_flipped", "benign_method"] for field in required_fields: assert field in calib, f"Missing field in calibration JSON: {field}" def test_calibration_json_prior_is_valid(self, client): """Prior probability is between 0 and 1.""" from backend.api import jobs _submit_and_wait(client) completed = [jid for jid, m in jobs.items() if m["status"] == "complete"] job_id = completed[-1] output_dir = jobs[job_id]["output_dir"] dataset_name = jobs[job_id]["dataset_name"] calib_path = output_dir / f"{dataset_name}_2c_calibration.json" with open(calib_path) as f: calib = json.load(f) assert 0 < calib["prior"] < 1, \ f"Prior {calib['prior']} is outside (0, 1)" def test_bootstrap_fits_are_valid_json_gz(self, client): """Bootstrap fits file decompresses and parses as valid JSON.""" from backend.api import jobs _submit_and_wait(client) completed = [jid for jid, m in jobs.items() if m["status"] == "complete"] job_id = completed[-1] output_dir = jobs[job_id]["output_dir"] dataset_name = jobs[job_id]["dataset_name"] fits_path = output_dir / f"{dataset_name}_bootstrap_fits.json.gz" with gzip.open(fits_path, "rt", encoding="utf-8") as f: fits = json.load(f) assert isinstance(fits, (list, dict)), "Fits file is not a list or dict" assert len(fits) > 0, "Fits file is empty" def test_bootstrap_fits_have_correct_structure(self, client): """Each bootstrap iteration has the expected nested structure.""" from backend.api import jobs _submit_and_wait(client) completed = [jid for jid, m in jobs.items() if m["status"] == "complete"] job_id = completed[-1] output_dir = jobs[job_id]["output_dir"] dataset_name = jobs[job_id]["dataset_name"] fits_path = output_dir / f"{dataset_name}_bootstrap_fits.json.gz" with gzip.open(fits_path, "rt", encoding="utf-8") as f: fits = json.load(f) # Normalize to list iterations = list(fits.values()) if isinstance(fits, dict) else fits first = iterations[0] assert "2c" in first, "Model key '2c' missing from bootstrap iteration" fit = first["2c"]["fit"] assert "component_params" in fit assert "weights" in fit assert len(fit["component_params"]) == 2, "Expected 2 components" def test_fits_endpoint_serves_decompressed_json(self, client): """The /fits endpoint returns valid decompressed JSON.""" from backend.api import jobs res = client.post( "/api/jobs", data={"params": json.dumps(VALID_PARAMS), "email": ""}, files={"file": ("test.csv", io.BytesIO(VALID_CSV.encode()), "text/csv")}, ) job_id = res.json()["job_id"] # Wait for this specific job deadline = time.time() + 300 while time.time() < deadline: s = client.get(f"/api/jobs/{job_id}/status").json()["status"] if s in ("complete", "failed"): break time.sleep(5) assert jobs[job_id]["status"] == "complete" fits_res = client.get(f"/api/jobs/{job_id}/fits") assert fits_res.status_code == 200 assert fits_res.headers["content-type"] == "application/json" fits = json.loads(fits_res.text) assert len(fits) > 0 def test_pipeline_fails_gracefully_with_invalid_csv(self, client): """Pipeline returns failed status (not a crash) when given invalid data.""" bad_csv = "score,sample_assignments\nnotanumber,0\nalsobad,1\n" result = _submit_and_wait(client, csv_content=bad_csv, timeout=120) # Should fail cleanly, not hang or crash the server assert result["status"] == "failed" # Error message should be generic, not expose internals if "error" in result: assert "/var/www" not in result["error"] assert "Traceback" not in result["error"]