Prefer link endpoints for legacy accident IDs and allow explicit node selection. Read authoritative initial statuses and sources, verify source isolation, and separate direct isolation from newly disconnected supply nodes. Return validation errors as HTTP 422. The node-first lookup misrouted adjacent Fengyang pipes 19025 and 19026 into unrelated regions. Regression cases fail against the previous algorithm and pass after this change.
414 lines
13 KiB
Python
414 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi import APIRouter, FastAPI, Query
|
|
from fastapi.routing import APIRoute
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.api.v1.endpoints import simulation as simulation_endpoint
|
|
from app.api.pagination import PaginatedList
|
|
from app.api.problem_details import install_problem_details_handlers
|
|
from app.api.v1.rest_router import api_router, build_rest_router
|
|
from app.api.v1.router import api_router as source_api_router
|
|
from app.auth.project_dependencies import (
|
|
ProjectContext,
|
|
get_project_business_routing,
|
|
get_project_context,
|
|
get_project_simulation_routing,
|
|
)
|
|
from app.infra.db.project_routing import (
|
|
ActiveProjectRouting,
|
|
get_project_pgconn_string,
|
|
get_project_timescale_pgconn_string,
|
|
)
|
|
from scripts.check_openapi import current_contract_bytes, validate
|
|
|
|
|
|
def _override_project_routing(
|
|
app: FastAPI,
|
|
project_context: ProjectContext,
|
|
) -> None:
|
|
app.dependency_overrides[get_project_context] = lambda: project_context
|
|
business = ActiveProjectRouting(
|
|
project_code=project_context.project_code,
|
|
business_dsn=f"postgresql://user:password@biz/{project_context.project_code}",
|
|
)
|
|
simulation = ActiveProjectRouting(
|
|
project_code=project_context.project_code,
|
|
business_dsn=business.business_dsn,
|
|
timescale_dsn=(
|
|
f"postgresql://user:password@timescale/{project_context.project_code}"
|
|
),
|
|
)
|
|
app.dependency_overrides[get_project_business_routing] = lambda: business
|
|
app.dependency_overrides[get_project_simulation_routing] = lambda: simulation
|
|
|
|
|
|
def test_rest_router_preserves_every_distinct_source_operation() -> None:
|
|
skipped_names = {"fastapi_get_json", "fastapi_test_dict"}
|
|
source_names = {
|
|
route.name
|
|
for route in source_api_router.routes
|
|
if isinstance(route, APIRoute) and route.name not in skipped_names
|
|
}
|
|
rest_names = {
|
|
route.name for route in api_router.routes if isinstance(route, APIRoute)
|
|
}
|
|
|
|
assert rest_names == source_names
|
|
|
|
|
|
def test_rest_router_has_unique_method_path_pairs() -> None:
|
|
pairs: list[tuple[str, str]] = []
|
|
for route in api_router.routes:
|
|
if not isinstance(route, APIRoute):
|
|
continue
|
|
pairs.extend((method, route.path) for method in route.methods or set())
|
|
assert len(pairs) == len(set(pairs))
|
|
|
|
|
|
def test_removed_redis_management_routes_are_not_published() -> None:
|
|
published_paths = {
|
|
route.path for route in api_router.routes if isinstance(route, APIRoute)
|
|
}
|
|
|
|
assert published_paths.isdisjoint(
|
|
{"/redis-keys/detail", "/redis-keys", "/all-redis", "/redis"}
|
|
)
|
|
|
|
|
|
def test_rest_router_rejects_duplicate_method_path_pairs() -> None:
|
|
first = APIRoute(
|
|
"/duplicate",
|
|
lambda: None,
|
|
methods={"POST"},
|
|
name="first_endpoint",
|
|
)
|
|
second = APIRoute(
|
|
"/duplicate",
|
|
lambda: None,
|
|
methods={"POST"},
|
|
name="second_endpoint",
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="REST route collision"):
|
|
build_rest_router([first, second])
|
|
|
|
|
|
def test_handler_router_defines_only_the_public_rest_operations() -> None:
|
|
source_operations = {
|
|
(method, route.path)
|
|
for route in source_api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
for method in route.methods or set()
|
|
}
|
|
public_operations = {
|
|
(method, route.path)
|
|
for route in api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
for method in route.methods or set()
|
|
}
|
|
|
|
assert source_operations == public_operations
|
|
assert ("POST", "/burst-analysis") not in source_operations
|
|
assert ("GET", "/getpipeproperties/") not in source_operations
|
|
|
|
|
|
def test_legacy_project_user_operations_are_not_exposed() -> None:
|
|
source_paths = {
|
|
route.path
|
|
for route in source_api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
}
|
|
|
|
assert {
|
|
"/network-schemas/user",
|
|
"/users",
|
|
"/users/detail",
|
|
}.isdisjoint(source_paths)
|
|
|
|
|
|
def test_rest_openapi_satisfies_contract_invariants() -> None:
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI(redirect_slashes=False)
|
|
app.include_router(api_router, prefix="/api/v1")
|
|
errors = validate(app.openapi())
|
|
assert errors == []
|
|
|
|
|
|
def test_openapi_snapshot_matches_current_application() -> None:
|
|
contract = Path(__file__).resolve().parents[2] / "contracts/server-v1.openapi.json"
|
|
|
|
assert contract.read_bytes() == current_contract_bytes()
|
|
|
|
|
|
def test_rest_contract_uses_header_project_context() -> None:
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI(redirect_slashes=False)
|
|
app.include_router(api_router, prefix="/api/v1")
|
|
document = app.openapi()
|
|
|
|
for path_item in document["paths"].values():
|
|
for operation in path_item.values():
|
|
if not isinstance(operation, dict):
|
|
continue
|
|
query_names = {
|
|
parameter["name"]
|
|
for parameter in operation.get("parameters", [])
|
|
if parameter.get("in") == "query"
|
|
}
|
|
assert "network" not in query_names
|
|
assert "network_name" not in query_names
|
|
|
|
for name, schema in document["components"]["schemas"].items():
|
|
if name.endswith("Rest"):
|
|
assert "network" not in schema.get("properties", {})
|
|
assert "network_name" not in schema.get("properties", {})
|
|
|
|
assert "/api/v1/burst-analysis" not in document["paths"]
|
|
assert "/api/v1/getpipeproperties/" not in document["paths"]
|
|
|
|
pipes_collection = document["paths"]["/api/v1/pipes"]["get"]
|
|
query_names = {
|
|
parameter["name"]
|
|
for parameter in pipes_collection["parameters"]
|
|
if parameter["in"] == "query"
|
|
}
|
|
assert {"limit", "offset"} <= query_names
|
|
assert "204" in document["paths"]["/api/v1/pipes"]["delete"]["responses"]
|
|
|
|
|
|
def test_side_effecting_analysis_routes_are_post() -> None:
|
|
methods_by_path = {
|
|
route.path: route.methods
|
|
for route in api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
}
|
|
assert methods_by_path["/burst-analyses"] == {"POST"}
|
|
assert methods_by_path["/flushing-analyses"] == {"POST"}
|
|
assert methods_by_path["/contaminant-simulations"] == {"POST"}
|
|
|
|
|
|
def test_valve_isolation_route_uses_the_isolation_handler() -> None:
|
|
route = next(
|
|
route
|
|
for route in api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
and route.path == "/valve-isolation-analyses"
|
|
and route.methods == {"POST"}
|
|
)
|
|
|
|
assert route.name == "valve_isolation_endpoint"
|
|
|
|
|
|
def test_legacy_project_pool_lifecycle_routes_are_not_published() -> None:
|
|
operations = {
|
|
(method, route.path)
|
|
for route in api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
for method in route.methods or set()
|
|
}
|
|
|
|
assert ("POST", "/projects/current") not in operations
|
|
assert ("DELETE", "/projects/current") not in operations
|
|
assert ("GET", "/projects/current/status") not in operations
|
|
|
|
|
|
def test_legacy_server_filesystem_inp_routes_are_not_published() -> None:
|
|
operations = {
|
|
(method, route.path)
|
|
for route in source_api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
for method in route.methods or set()
|
|
}
|
|
|
|
assert operations.isdisjoint(
|
|
{
|
|
("POST", "/projects/current/imports"),
|
|
("POST", "/projects/current/exports/inp"),
|
|
("GET", "/projects/current/files/inp"),
|
|
}
|
|
)
|
|
|
|
|
|
def test_committed_write_refresh_failure_has_explicit_http_contract() -> None:
|
|
from app.native.wndb.core.database import MaterializedViewRefreshAfterCommitError
|
|
|
|
app = FastAPI()
|
|
install_problem_details_handlers(app)
|
|
|
|
@app.get("/probe")
|
|
def probe():
|
|
raise MaterializedViewRefreshAfterCommitError("project_a")
|
|
|
|
response = TestClient(app, raise_server_exceptions=False).get("/probe")
|
|
|
|
assert response.status_code == 503
|
|
assert response.headers["X-TJWater-Changes-Committed"] == "true"
|
|
assert response.json()["code"] == "materialized_view_refresh_failed_after_commit"
|
|
|
|
|
|
@pytest.mark.parametrize("element_type", ["auto", "link", "node"])
|
|
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch, element_type) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_analyze_valve_isolation(network, accident_element, disabled_valves, accident_element_type):
|
|
captured.update(
|
|
network=network,
|
|
accident_element=accident_element,
|
|
disabled_valves=disabled_valves,
|
|
accident_element_type=accident_element_type,
|
|
business_dsn=get_project_pgconn_string(network),
|
|
timescale_dsn=get_project_timescale_pgconn_string(network),
|
|
)
|
|
return {"isolatable": True, "must_close_valves": ["V-1"]}
|
|
|
|
monkeypatch.setattr(
|
|
simulation_endpoint,
|
|
"analyze_valve_isolation",
|
|
fake_analyze_valve_isolation,
|
|
)
|
|
app = FastAPI(redirect_slashes=False)
|
|
app.include_router(api_router, prefix="/api/v1")
|
|
project_context = ProjectContext(
|
|
project_id=uuid4(),
|
|
project_code="fengyang",
|
|
user_id=uuid4(),
|
|
project_role="member",
|
|
)
|
|
_override_project_routing(app, project_context)
|
|
|
|
response = TestClient(app, raise_server_exceptions=False).post(
|
|
"/api/v1/valve-isolation-analyses",
|
|
params=[
|
|
("accident_element", "P-1"),
|
|
("accident_element", "P-2"),
|
|
("disabled_valves", "V-9"),
|
|
*([] if element_type == "auto" else [("accident_element_type", element_type)]),
|
|
],
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"isolatable": True, "must_close_valves": ["V-1"]}
|
|
assert captured == {
|
|
"network": "fengyang",
|
|
"accident_element": ["P-1", "P-2"],
|
|
"disabled_valves": ["V-9"],
|
|
"accident_element_type": element_type,
|
|
"business_dsn": "postgresql://user:password@biz/fengyang",
|
|
"timescale_dsn": "postgresql://user:password@timescale/fengyang",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target,disabled,detail",
|
|
[("P", "typo", "Disabled valve"), ("missing", "V", "Accident element")],
|
|
)
|
|
def test_valve_isolation_invalid_ids_return_validation_error(monkeypatch, target, disabled, detail):
|
|
from app.services import valve_isolation as service
|
|
|
|
monkeypatch.setattr(
|
|
service, "get_valve_isolation_topology",
|
|
lambda _network: (["P:pipe:A:B", "V:valve:B:R"], ["R"], []),
|
|
)
|
|
monkeypatch.setattr(simulation_endpoint, "analyze_valve_isolation", service.analyze_valve_isolation)
|
|
app = FastAPI()
|
|
app.include_router(api_router, prefix="/api/v1")
|
|
_override_project_routing(app, ProjectContext(
|
|
project_id=uuid4(), project_code="project", user_id=uuid4(), project_role="member",
|
|
))
|
|
|
|
response = TestClient(app, raise_server_exceptions=False).post(
|
|
"/api/v1/valve-isolation-analyses",
|
|
params={"accident_element": target, "disabled_valves": disabled},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert detail in response.json()["detail"]
|
|
|
|
|
|
def test_scada_cleaning_runs_are_post() -> None:
|
|
methods_by_path = {
|
|
route.path: route.methods
|
|
for route in api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
}
|
|
assert methods_by_path["/timeseries/scada-cleaning-runs"] == {"POST"}
|
|
|
|
|
|
def test_sensor_placement_excel_export_is_post() -> None:
|
|
methods_by_path = {
|
|
route.path: route.methods
|
|
for route in api_router.routes
|
|
if isinstance(route, APIRoute)
|
|
}
|
|
assert methods_by_path[
|
|
"/sensor-placement-runs/{run_id}/exports/excel"
|
|
] == {"POST"}
|
|
|
|
|
|
def test_rest_runtime_wraps_handler_paginated_list() -> None:
|
|
source_router = APIRouter()
|
|
|
|
@source_router.get("/records", response_model=list[int])
|
|
async def list_records(
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(2, ge=1, le=10),
|
|
) -> list[int]:
|
|
records = [10, 20, 30, 40]
|
|
return PaginatedList(records[skip : skip + limit], total=len(records))
|
|
|
|
app = FastAPI(redirect_slashes=False)
|
|
app.include_router(build_rest_router(source_router.routes), prefix="/api/v1")
|
|
|
|
response = TestClient(app, raise_server_exceptions=False).get(
|
|
"/api/v1/records",
|
|
params={"skip": 1, "limit": 2},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"items": [20, 30],
|
|
"total": 4,
|
|
"limit": 2,
|
|
"offset": 1,
|
|
}
|
|
|
|
|
|
def test_rest_runtime_json_encodes_untyped_datetime_response() -> None:
|
|
source_router = APIRouter()
|
|
|
|
@source_router.get("/simulation-result")
|
|
async def simulation_result():
|
|
return {
|
|
"result": [
|
|
{
|
|
"time": datetime(2026, 7, 30, 4, tzinfo=timezone.utc),
|
|
"id": "4277",
|
|
}
|
|
]
|
|
}
|
|
|
|
app = FastAPI(redirect_slashes=False)
|
|
app.include_router(build_rest_router(source_router.routes), prefix="/api/v1")
|
|
|
|
response = TestClient(app, raise_server_exceptions=False).get(
|
|
"/api/v1/simulation-result"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"result": [
|
|
{
|
|
"time": "2026-07-30T04:00:00+00:00",
|
|
"id": "4277",
|
|
}
|
|
]
|
|
}
|