Files
SPipe/backend/tests/test_api.py
T
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

156 lines
5.5 KiB
Python

"""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