refactor(backend)!: separate algorithm and data layers
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.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import burst_detection, burst_location
|
||||
|
||||
|
||||
def _client(module) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(module.router, prefix="/api/v1")
|
||||
app.dependency_overrides[module.get_current_keycloak_username] = lambda: "tester"
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_burst_detection_runs_service_outside_event_loop(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_threadpool(func, **kwargs):
|
||||
captured["func"] = func
|
||||
captured["kwargs"] = kwargs
|
||||
return {"status": "completed"}
|
||||
|
||||
monkeypatch.setattr(burst_detection, "run_in_threadpool", fake_threadpool)
|
||||
response = _client(burst_detection).post(
|
||||
"/api/v1/burst-detections",
|
||||
json={"network": "demo", "observed_pressure_data": {"S1": [1.0]}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["func"] is burst_detection.run_burst_detection
|
||||
assert captured["kwargs"]["username"] == "tester"
|
||||
|
||||
|
||||
def test_burst_location_runs_service_outside_event_loop(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_threadpool(func, **kwargs):
|
||||
captured["func"] = func
|
||||
captured["kwargs"] = kwargs
|
||||
return {"status": "completed"}
|
||||
|
||||
monkeypatch.setattr(burst_location, "run_in_threadpool", fake_threadpool)
|
||||
response = _client(burst_location).post(
|
||||
"/api/v1/burst-locations",
|
||||
json={
|
||||
"network": "demo",
|
||||
"burst_leakage": 0.1,
|
||||
"burst_pressure": {"S1": 1.0},
|
||||
"normal_pressure": {"S1": 1.1},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["func"] is burst_location.run_burst_location_by_network
|
||||
assert captured["kwargs"]["username"] == "tester"
|
||||
@@ -33,3 +33,58 @@ def test_identify_leakage_success(monkeypatch):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["area_count"] == 0
|
||||
|
||||
|
||||
def test_identify_leakage_rejects_server_file_paths():
|
||||
client = _build_client()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/leakage-identifications",
|
||||
json={
|
||||
"network": "demo",
|
||||
"observed_pressure_data": "/etc/passwd",
|
||||
"output_dir": "/tmp/leakage-results",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_identify_leakage_rejects_unbounded_workload():
|
||||
client = _build_client()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/leakage-identifications",
|
||||
json={
|
||||
"network": "demo",
|
||||
"observed_pressure_data": {"S1": [1.0]},
|
||||
"pop_size": 1_000_000,
|
||||
"max_gen": 1_000_000,
|
||||
"n_workers": 10_000,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_identify_leakage_runs_service_outside_event_loop(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_threadpool(func, **kwargs):
|
||||
captured["func"] = func
|
||||
captured["kwargs"] = kwargs
|
||||
return {"rows": [], "area_count": 0}
|
||||
|
||||
monkeypatch.setattr(leakage_endpoint, "run_in_threadpool", fake_threadpool)
|
||||
client = _build_client()
|
||||
response = client.post(
|
||||
"/api/v1/leakage-identifications",
|
||||
json={
|
||||
"network": "demo",
|
||||
"observed_pressure_data": {"S1": [1.0]},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["func"] is leakage_endpoint.run_leakage_identification
|
||||
assert captured["kwargs"]["username"] == "tester"
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_optimize_returns_analysis_run(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
endpoint,
|
||||
"pressure_sensor_placement_kmeans",
|
||||
"optimize_sensor_placement_by_kmeans",
|
||||
lambda **kwargs: captured.update(kwargs) or {"run_id": RUN_ID},
|
||||
)
|
||||
monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run())
|
||||
@@ -74,7 +74,7 @@ def test_optimize_returns_analysis_run(monkeypatch):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["run_id"] == str(RUN_ID)
|
||||
assert captured["username"] == "alice"
|
||||
assert captured["created_by"] == "alice"
|
||||
|
||||
|
||||
def test_optimize_rejects_project_mismatch(monkeypatch):
|
||||
|
||||
@@ -25,15 +25,6 @@ def _load_simulation_module(monkeypatch):
|
||||
seconds = parts[2] if len(parts) == 3 else 0
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.services.time_api",
|
||||
{
|
||||
"parse_aware_time": parse_aware_time,
|
||||
"parse_clock_duration_seconds": parse_clock_duration_seconds,
|
||||
"parse_utc_time": parse_utc_time,
|
||||
},
|
||||
)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.services.simulation",
|
||||
@@ -62,42 +53,22 @@ def _load_simulation_module(monkeypatch):
|
||||
"dump_output": lambda output: f"dump::{output}",
|
||||
},
|
||||
)
|
||||
install_stub(monkeypatch, "app.algorithms", package=True)
|
||||
install_stub(monkeypatch, "app.algorithms.simulation", package=True)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.algorithms.simulation.scenarios",
|
||||
"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",
|
||||
"age_analysis": lambda *args, **kwargs: "age",
|
||||
"pressure_regulation": lambda *args, **kwargs: "pressure",
|
||||
},
|
||||
)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.algorithms.sensor",
|
||||
{
|
||||
"pressure_sensor_placement_sensitivity": lambda *args, **kwargs: [],
|
||||
"pressure_sensor_placement_kmeans": lambda *args, **kwargs: [],
|
||||
},
|
||||
)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.services.network_import",
|
||||
{"network_update": lambda *args, **kwargs: "updated"},
|
||||
)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.services.simulation_ops",
|
||||
{
|
||||
"project_management": lambda *args, **kwargs: "managed",
|
||||
"scheduling_simulation": lambda *args, **kwargs: "scheduled",
|
||||
"daily_scheduling_simulation": lambda *args, **kwargs: "daily",
|
||||
},
|
||||
)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.services.valve_isolation",
|
||||
@@ -126,78 +97,52 @@ def test_run_project_endpoint_returns_plain_text(monkeypatch):
|
||||
assert response.text == "report::demo"
|
||||
|
||||
|
||||
def test_scheduling_analysis_maps_request_body(monkeypatch):
|
||||
def test_removed_legacy_simulation_workflows_are_not_exposed(monkeypatch):
|
||||
module = _load_simulation_module(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_schedule(network, start_time, pump_control, tank_id, water_plant_output_id, time_delta):
|
||||
captured["args"] = (
|
||||
network,
|
||||
start_time,
|
||||
pump_control,
|
||||
tank_id,
|
||||
water_plant_output_id,
|
||||
time_delta,
|
||||
)
|
||||
return "scheduled"
|
||||
|
||||
monkeypatch.setattr(module, "scheduling_simulation", fake_schedule)
|
||||
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/scheduling-analyses",
|
||||
json={
|
||||
"network": "demo",
|
||||
"start_time": "2025-01-01T08:00:00+08:00",
|
||||
"pump_control": {"P1": [1, 0, 1]},
|
||||
"tank_id": "T1",
|
||||
"water_plant_output_id": "R1",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == "scheduled"
|
||||
assert captured["args"] == (
|
||||
"demo",
|
||||
"2025-01-01T08:00:00+08:00",
|
||||
{"P1": [1, 0, 1]},
|
||||
"T1",
|
||||
"R1",
|
||||
300,
|
||||
)
|
||||
|
||||
|
||||
def test_project_management_maps_named_arguments(monkeypatch):
|
||||
module = _load_simulation_module(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_project_management(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "managed"
|
||||
|
||||
monkeypatch.setattr(module, "project_management", fake_project_management)
|
||||
client = TestClient(build_test_app(module.router, "/api/v1"))
|
||||
|
||||
response = client.post(
|
||||
for path in (
|
||||
"/api/v1/project-managements",
|
||||
json={
|
||||
"/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",
|
||||
"start_time": "2025-01-01T08:00:00+08:00",
|
||||
"pump_control": {"P1": [1]},
|
||||
"tank_init_level": {"T1": 10.0},
|
||||
"region_demand": {"R1": 20.0},
|
||||
"target_node": "J1",
|
||||
"target_pressure": 50,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == "managed"
|
||||
assert captured == {
|
||||
"prj_name": "demo",
|
||||
"start_datetime": "2025-01-01T08:00:00+08:00",
|
||||
"pump_control": {"P1": [1]},
|
||||
"tank_initial_level_control": {"T1": 10.0},
|
||||
"region_demand_control": {"R1": 20.0},
|
||||
}
|
||||
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):
|
||||
@@ -353,6 +298,34 @@ def test_burst_endpoint_passes_current_username(monkeypatch):
|
||||
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 = {}
|
||||
|
||||
Reference in New Issue
Block a user