"""HTTP-layer tests for backend/main.py (FastAPI TestClient + real EPANET DLL). Run from the repo root: .venv/Scripts/python -m pytest backend/tests -q """ from pathlib import Path import pytest from fastapi.testclient import TestClient from backend import main from backend.main import app REPO = Path(__file__).resolve().parents[2] PUBLIC = REPO / "public" @pytest.fixture(scope="module") def client(): with TestClient(app, raise_server_exceptions=False) as c: yield c def post(client, name: str, data: bytes): return client.post("/api/analyze", files={"file": (name, data, "text/plain")}) SIMPLE = b"""[JUNCTIONS] J1 10 5 J2 12 3 [RESERVOIRS] R1 50 [PIPES] P1 R1 J1 1000 200 100 P2 J1 J2 1000 150 100 [OPTIONS] Units LPS [END] """ def test_health(client): r = client.get("/api/health") assert r.status_code == 200 assert r.json() == {"status": "ok"} def test_net1_runs(client): r = post(client, "Net1.inp", (PUBLIC / "Net1.inp").read_bytes()) assert r.status_code == 200 body = r.json() assert len(body["nodes"]) == 11 and len(body["links"]) == 13 assert body["units"] == {"flow": "GPM", "pressure": "psi"} assert body["reportStep"] > 0 assert body["engineWarnings"] == [] def test_wrong_extension_rejected(client): assert post(client, "a.txt", SIMPLE).status_code == 400 def test_oversized_upload_rejected(client): r = post(client, "big.inp", b"x" * (main.MAX_UPLOAD_BYTES + 1)) assert r.status_code == 413 def test_early_size_check_not_bypassed_by_bad_host(client): # request.url is rebuilt from Host; the early 413 must not depend on it. big = str(main.MAX_UPLOAD_BYTES + 10 * 1024 * 1024) for host in ("localhost", "bad host:abc", "evil.example/../x"): r = client.post("/api/analyze", headers={"content-length": big, "host": host}, content=b"") assert r.status_code == 413, (host, r.status_code) def test_invalid_inp_returns_json_with_root_cause(client): # Previously: EN_open failure leaked the .rpt handle, cleanup raised # PermissionError on Windows, and the client got plain-text 500. r = post(client, "bad.inp", b"[JUNCTIONS]\n J1 abc\n[END]\n") assert r.status_code == 500 assert r.headers["content-type"].startswith("application/json") detail = r.json()["detail"] assert "Error 200" in detail assert "Error 202" in detail # root cause pulled from the report file def test_warning_codes_collected_even_with_messages_no(client): # J1 sits above the reservoir head -> negative pressure (warning 6). inp = SIMPLE.replace(b" J1 10 5", b" J1 100 5").replace( b"[OPTIONS]", b"[REPORT]\n Messages No\n[OPTIONS]" ) r = post(client, "neg.inp", inp) assert r.status_code == 200 body = r.json() assert "WARNING" not in body["reportText"] assert any(w["code"] == 6 for w in body["engineWarnings"]) def test_step_amplification_rejected_fast(client): inp = SIMPLE.replace( b"[OPTIONS]", b"[TIMES]\n Duration 10000:00\n Hydraulic Timestep 0:00:01\n[OPTIONS]" ) r = post(client, "dos.inp", inp) assert r.status_code == 413 assert "Simulation too large" in r.json()["detail"] def test_columnar_format_carries_identical_values(client): import base64 from array import array data = (PUBLIC / "Net1.inp").read_bytes() # pump + chlorine: every series kind obj = client.post("/api/analyze", files={"file": ("Net1.inp", data, "text/plain")}).json() col = client.post("/api/analyze?format=columnar", files={"file": ("Net1.inp", data, "text/plain")}).json() assert col["format"] == "spipe-columnar-1" steps = len(obj["timeSteps"]) for side, keys in (("nodes", ("demand", "head", "pressure", "quality")), ("links", ("flow", "velocity", "headloss", "quality"))): ids = col[side]["ids"] assert ids == list(obj[side]) for key in keys: flat = array("d", base64.b64decode(col[side]["series"][key])) for i, element_id in enumerate(ids): assert list(flat[i * steps:(i + 1) * steps]) == obj[side][element_id]["timeSeries"][key] pump = col["links"]["pumps"]["9"] assert pump["energySeries"] == obj["links"]["9"]["timeSeries"]["energy"] assert pump["totalKwh"] == obj["links"]["9"]["totalKwh"] def test_overlong_utf8_id_is_engine_error(client): # 12 Korean chars = 36 UTF-8 bytes > EPANET MAXID (31 bytes) -> Error 252. long_id = "상리배수지유출관로제일번".encode("utf-8") inp = SIMPLE.replace(b" J2 12 3", b" " + long_id + b" 12 3").replace(b"J1 J2", b"J1 " + long_id) r = post(client, "longid.inp", inp) assert r.status_code == 500 assert "252" in r.json()["detail"] NET1 = (PUBLIC / "Net1.inp").read_bytes() def post_job(client, name, inp=SIMPLE, params="{}"): return client.post(f"/api/jobs/{name}", files={"file": ("n.inp", inp, "text/plain")}, data={"params": params}) def test_job_summary_runs(client): r = post_job(client, "summary", NET1) assert r.status_code == 200, r.text body = r.json() assert body["junctions"] == 9 assert len(body["times"]) == len(body["minPressure"]) == 25 assert all(lo <= hi for lo, hi in zip(body["minPressure"], body["maxPressure"])) def test_job_unknown_and_bad_params(client): assert post_job(client, "nope").status_code == 400 assert post_job(client, "summary", params="[1]").status_code == 400 assert post_job(client, "summary", params="not json").status_code == 400