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:
2026-09-04 17:30:55 +08:00
parent 9b095c7439
commit 5966d039de
91 changed files with 1418 additions and 4020 deletions
@@ -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"
+55
View File
@@ -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"
+2 -2
View File
@@ -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):
+67 -94
View File
@@ -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 = {}