Reorganize algorithm packages by business responsibility, move orchestration into services, and keep database access behind pooled repositories. Harden analysis API validation, remove unsafe legacy simulation endpoints, and add regression and architecture boundary coverage. BREAKING CHANGE: legacy algorithm module paths and obsolete simulation endpoints are removed.
552 lines
17 KiB
Python
552 lines
17 KiB
Python
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from tests.conftest import build_test_app, install_stub, load_module_from_path
|
|
|
|
|
|
def _load_simulation_module(monkeypatch):
|
|
install_stub(monkeypatch, "app.services", package=True)
|
|
def parse_aware_time(value, field_name="datetime"):
|
|
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
if dt.tzinfo is None:
|
|
raise ValueError(f"{field_name} is missing timezone information.")
|
|
return dt
|
|
|
|
def parse_utc_time(value, field_name="datetime"):
|
|
return parse_aware_time(value, field_name=field_name).astimezone(
|
|
timezone.utc
|
|
)
|
|
|
|
def parse_clock_duration_seconds(value, field_name="duration"):
|
|
parts = [int(part) for part in value.split(":")]
|
|
hours, minutes = parts[0], parts[1]
|
|
seconds = parts[2] if len(parts) == 3 else 0
|
|
return hours * 3600 + minutes * 60 + seconds
|
|
|
|
install_stub(
|
|
monkeypatch,
|
|
"app.services.simulation",
|
|
{
|
|
"get_time": lambda name: {"HYDRAULIC TIMESTEP": "0:15:00"},
|
|
"run_simulation": lambda **kwargs: None,
|
|
"query_corresponding_element_id_and_query_id": lambda name: SimpleNamespace(
|
|
fixed_pumps={}, variable_pumps={}
|
|
),
|
|
"query_corresponding_pattern_id_and_query_id": lambda name: None,
|
|
"query_non_realtime_region": lambda name: [],
|
|
"get_source_outflow_region_id": lambda name, region_result: {},
|
|
"query_realtime_region_pipe_flow_and_demand_id": lambda name, region_result: {},
|
|
"query_pipe_flow_region_patterns": lambda name: {},
|
|
"query_non_realtime_region_patterns": lambda name, region_result: {},
|
|
"get_realtime_region_patterns": lambda name, source_outflow_region_id, realtime_region_pipe_flow_and_demand_id: ({}, {}),
|
|
},
|
|
)
|
|
install_stub(
|
|
monkeypatch,
|
|
"app.services.tjnetwork",
|
|
{
|
|
"run_project": lambda network: "report",
|
|
"run_project_return_dict": lambda network: {"output": {}, "report": "ok"},
|
|
"run_inp": lambda network: "inp-report",
|
|
"dump_output": lambda output: f"dump::{output}",
|
|
},
|
|
)
|
|
install_stub(
|
|
monkeypatch,
|
|
"app.services.simulation_scenarios",
|
|
{
|
|
"burst_analysis": lambda *args, **kwargs: "burst",
|
|
"valve_close_analysis": lambda *args, **kwargs: "valve",
|
|
"flushing_analysis": lambda *args, **kwargs: "flush",
|
|
"contaminant_simulation": lambda *args, **kwargs: "contaminant",
|
|
"pressure_regulation": lambda *args, **kwargs: "pressure",
|
|
},
|
|
)
|
|
install_stub(
|
|
monkeypatch,
|
|
"app.services.network_import",
|
|
{"network_update": lambda *args, **kwargs: "updated"},
|
|
)
|
|
install_stub(
|
|
monkeypatch,
|
|
"app.services.valve_isolation",
|
|
{"analyze_valve_isolation": lambda *args, **kwargs: {}},
|
|
)
|
|
return load_module_from_path(
|
|
"tests_simulation_endpoints_module",
|
|
"app/api/v1/endpoints/simulation.py",
|
|
)
|
|
|
|
|
|
def _build_authenticated_client(module) -> TestClient:
|
|
app = build_test_app(module.router, "/api/v1")
|
|
app.dependency_overrides[module.get_current_keycloak_username] = lambda: "alice"
|
|
return TestClient(app)
|
|
|
|
|
|
def test_run_project_endpoint_returns_plain_text(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
monkeypatch.setattr(module, "run_project", lambda network: f"report::{network}")
|
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
|
|
|
response = client.post("/api/v1/project-runs", params={"network": "demo"})
|
|
|
|
assert response.status_code == 200
|
|
assert response.text == "report::demo"
|
|
|
|
|
|
def test_removed_legacy_simulation_workflows_are_not_exposed(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
|
|
|
for path in (
|
|
"/api/v1/project-managements",
|
|
"/api/v1/scheduling-analyses",
|
|
"/api/v1/daily-scheduling-analyses",
|
|
"/api/v1/water-age-analyses",
|
|
"/api/v1/inp-runs",
|
|
"/api/v1/outputs",
|
|
"/api/v1/pump-failure-events",
|
|
):
|
|
assert client.post(path).status_code == 404
|
|
|
|
|
|
def test_removed_basic_pressure_regulation_is_not_exposed(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
|
|
|
response = client.post(
|
|
"/api/v1/pressure-regulation-calculations",
|
|
params={
|
|
"network": "demo",
|
|
"target_node": "J1",
|
|
"target_pressure": 50,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_pressure_regulation_requires_scheme_name(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
|
|
|
response = client.post(
|
|
"/api/v1/pressure-regulation-analyses",
|
|
json={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"pump_control": {},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured_calls = []
|
|
|
|
monkeypatch.setattr(
|
|
module.simulation,
|
|
"run_simulation",
|
|
lambda **kwargs: captured_calls.append(kwargs),
|
|
)
|
|
|
|
module.run_simulation_manually_by_date(
|
|
"demo",
|
|
datetime(2025, 1, 1, 19, 4, 5, tzinfo=timezone.utc),
|
|
30,
|
|
)
|
|
|
|
assert [call["modify_pattern_start_time"] for call in captured_calls] == [
|
|
"2025-01-01T19:04:05+00:00",
|
|
"2025-01-01T19:19:05+00:00",
|
|
]
|
|
|
|
|
|
def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured_calls = []
|
|
|
|
monkeypatch.setattr(
|
|
module.simulation,
|
|
"get_time",
|
|
lambda name: {"HYDRAULIC TIMESTEP": "1:00"},
|
|
)
|
|
monkeypatch.setattr(
|
|
module.simulation,
|
|
"run_simulation",
|
|
lambda **kwargs: captured_calls.append(kwargs),
|
|
)
|
|
|
|
module.run_simulation_manually_by_date(
|
|
"demo",
|
|
datetime(2025, 1, 1, 16, 0, 0, tzinfo=timezone.utc),
|
|
120,
|
|
)
|
|
|
|
assert [call["modify_pattern_start_time"] for call in captured_calls] == [
|
|
"2025-01-01T16:00:00+00:00",
|
|
"2025-01-01T17:00:00+00:00",
|
|
]
|
|
|
|
|
|
def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured = {}
|
|
|
|
def fake_run(network_name, start_time, duration):
|
|
captured["network_name"] = network_name
|
|
captured["start_time"] = start_time
|
|
captured["duration"] = duration
|
|
|
|
monkeypatch.setattr(module, "run_simulation_manually_by_date", fake_run)
|
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
|
|
|
response = client.post(
|
|
"/api/v1/simulation-runs",
|
|
json={
|
|
"name": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"duration": 30,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "success"}
|
|
assert captured["network_name"] == "demo"
|
|
assert captured["duration"] == 30
|
|
assert captured["start_time"].isoformat() == "2025-01-01T19:04:05+00:00"
|
|
|
|
|
|
def test_runsimulationmanuallybydate_endpoint_rejects_naive_start_time(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
|
|
|
response = client.post(
|
|
"/api/v1/simulation-runs",
|
|
json={
|
|
"name": "demo",
|
|
"start_time": "2025-01-02T03:04:05",
|
|
"duration": 30,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_valve_close_endpoint_passes_scheme_name(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured = {}
|
|
|
|
def fake_valve_close_analysis(**kwargs):
|
|
captured.update(kwargs)
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(module, "valve_close_analysis", fake_valve_close_analysis)
|
|
client = TestClient(build_test_app(module.router, "/api/v1"))
|
|
|
|
response = client.post(
|
|
"/api/v1/valve-closure-analyses",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"valves": ["V1", "V2"],
|
|
"duration": 900,
|
|
"scheme_name": "valve_case_01",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.text == "ok"
|
|
assert captured == {
|
|
"name": "demo",
|
|
"modify_pattern_start_time": "2025-01-02T03:04:05+08:00",
|
|
"modify_total_duration": 900,
|
|
"modify_valve_opening": {"V1": 0.0, "V2": 0.0},
|
|
"scheme_name": "valve_case_01",
|
|
}
|
|
|
|
|
|
def test_burst_endpoint_passes_current_username(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured = {}
|
|
|
|
def fake_burst_analysis(**kwargs):
|
|
captured.update(kwargs)
|
|
|
|
monkeypatch.setattr(module, "burst_analysis", fake_burst_analysis)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/burst-analyses",
|
|
params={
|
|
"network": "demo",
|
|
"modify_pattern_start_time": "2025-01-02T03:04:05+08:00",
|
|
"burst_ID": ["P1"],
|
|
"burst_size": [10.0],
|
|
"modify_total_duration": 900,
|
|
"scheme_name": "burst_case_01",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.text == '"success"'
|
|
assert captured["username"] == "alice"
|
|
|
|
|
|
def test_burst_endpoint_rejects_mismatched_pipe_and_size_counts(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
called = False
|
|
|
|
def fake_burst_analysis(**kwargs):
|
|
nonlocal called
|
|
called = True
|
|
|
|
monkeypatch.setattr(module, "burst_analysis", fake_burst_analysis)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/burst-analyses",
|
|
params=[
|
|
("network", "demo"),
|
|
("modify_pattern_start_time", "2025-01-02T03:04:05+08:00"),
|
|
("burst_ID", "P1"),
|
|
("burst_ID", "P2"),
|
|
("burst_size", "10.0"),
|
|
("modify_total_duration", "900"),
|
|
("scheme_name", "burst_case_01"),
|
|
],
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert called is False
|
|
|
|
|
|
def test_flushing_endpoint_passes_required_scheme_name(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured = {}
|
|
|
|
def fake_flushing_analysis(**kwargs):
|
|
captured.update(kwargs)
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/flushing-analyses",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"valves": ["V1"],
|
|
"valves_k": [0.5],
|
|
"drainage_node_ID": "N1",
|
|
"flush_flow": 100.0,
|
|
"duration": 900,
|
|
"scheme_name": "flush_case_01",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.text == "ok"
|
|
assert captured == {
|
|
"name": "demo",
|
|
"modify_pattern_start_time": "2025-01-02T03:04:05+08:00",
|
|
"modify_total_duration": 900,
|
|
"modify_valve_opening": {"V1": 0.5},
|
|
"valve_control": None,
|
|
"drainage_node_ID": "N1",
|
|
"flushing_flow": 100.0,
|
|
"scheme_name": "flush_case_01",
|
|
"username": "alice",
|
|
}
|
|
|
|
|
|
def test_flushing_endpoint_allows_omitting_valves(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured = {}
|
|
|
|
def fake_flushing_analysis(**kwargs):
|
|
captured.update(kwargs)
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/flushing-analyses",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"drainage_node_ID": "N1",
|
|
"scheme_name": "flush_without_valves",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.text == "ok"
|
|
assert captured["modify_valve_opening"] is None
|
|
assert captured["valve_control"] is None
|
|
assert captured["drainage_node_ID"] == "N1"
|
|
|
|
|
|
def test_flushing_endpoint_passes_explicit_valve_control(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured = {}
|
|
|
|
def fake_flushing_analysis(**kwargs):
|
|
captured.update(kwargs)
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/flushing-analyses",
|
|
params=[
|
|
("network", "demo"),
|
|
("start_time", "2025-01-02T03:04:05+08:00"),
|
|
("valves", "V1"),
|
|
("valves", "V2"),
|
|
("valve_statuses", "ACTIVE"),
|
|
("valve_statuses", "CLOSED"),
|
|
("valve_settings", "2.5"),
|
|
("valve_settings", ""),
|
|
("drainage_node_ID", "N1"),
|
|
("scheme_name", "flush_with_valve_control"),
|
|
],
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert captured["modify_valve_opening"] is None
|
|
assert captured["valve_control"] == {
|
|
"V1": {"status": "ACTIVE", "setting": "2.5"},
|
|
"V2": {"status": "CLOSED"},
|
|
}
|
|
|
|
|
|
def test_flushing_endpoint_requires_setting_for_active_valve(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/flushing-analyses",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"valves": "V1",
|
|
"valve_statuses": "ACTIVE",
|
|
"drainage_node_ID": "N1",
|
|
"scheme_name": "flush_without_active_setting",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_flushing_endpoint_rejects_mixed_valve_control_modes(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/flushing-analyses",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"valves": "V1",
|
|
"valves_k": 0.5,
|
|
"valve_statuses": "ACTIVE",
|
|
"valve_settings": "2.5",
|
|
"drainage_node_ID": "N1",
|
|
"scheme_name": "flush_with_mixed_controls",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_flushing_endpoint_rejects_settings_without_statuses(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/flushing-analyses",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"valves": "V1",
|
|
"valves_k": 0.5,
|
|
"valve_settings": "2.5",
|
|
"drainage_node_ID": "N1",
|
|
"scheme_name": "flush_with_orphan_settings",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_flushing_endpoint_requires_drainage_node(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/flushing-analyses",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"scheme_name": "flush_without_drainage_node",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_contaminant_endpoint_passes_current_username(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
captured = {}
|
|
|
|
def fake_contaminant_simulation(**kwargs):
|
|
captured.update(kwargs)
|
|
return "ok"
|
|
|
|
monkeypatch.setattr(module, "contaminant_simulation", fake_contaminant_simulation)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/contaminant-simulations",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"source": "N1",
|
|
"concentration": 10.0,
|
|
"duration": 900,
|
|
"scheme_name": "contaminant_case_01",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.text == "ok"
|
|
assert captured["username"] == "alice"
|
|
|
|
|
|
def test_contaminant_endpoint_requires_scheme_name(monkeypatch):
|
|
module = _load_simulation_module(monkeypatch)
|
|
client = _build_authenticated_client(module)
|
|
|
|
response = client.post(
|
|
"/api/v1/contaminant-simulations",
|
|
params={
|
|
"network": "demo",
|
|
"start_time": "2025-01-02T03:04:05+08:00",
|
|
"source": "N1",
|
|
"concentration": 10.0,
|
|
"duration": 900,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|