refactor(db)!: adopt project-routed pooled databases

Reorganize WNDB by responsibility and remove legacy scheme endpoints.\n\nRoute analysis and time-series access through project pools, preserve transactional realtime replacement, and refresh GIS materialized views after writes.\n\nAdd database architecture documentation, live pooling coverage, API contract updates, and executable container verification.\n\nBREAKING CHANGE: legacy scheme APIs and flat app.native.wndb module imports are removed.
This commit is contained in:
2026-08-25 18:35:05 +08:00
parent fdbcc5c033
commit fa188af0b1
181 changed files with 8446 additions and 33546 deletions
+1 -98
View File
@@ -10,12 +10,10 @@ from fastapi import APIRouter, FastAPI, Query
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from app.api.v1.endpoints import schemes as schemes_endpoint
from app.api.v1.endpoints import simulation as simulation_endpoint
from app.api.pagination import PaginatedList
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.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import (
ProjectContext,
get_project_business_routing,
@@ -173,12 +171,6 @@ def test_rest_contract_uses_header_project_context() -> None:
assert "network" not in schema.get("properties", {})
assert "network_name" not in schema.get("properties", {})
placement_schema = document["components"]["schemas"][
"PressureSensorPlacementRest"
]
assert "name" not in placement_schema["properties"]
assert "username" not in placement_schema["properties"]
assert "/api/v1/burst-analysis" not in document["paths"]
assert "/api/v1/getpipeproperties/" not in document["paths"]
@@ -294,54 +286,10 @@ def test_sensor_placement_excel_export_is_post() -> None:
if isinstance(route, APIRoute)
}
assert methods_by_path[
"/sensor-placement-schemes/{scheme_id}/exports/excel"
"/sensor-placement-runs/{run_id}/exports/excel"
] == {"POST"}
def test_rest_runtime_consumes_injected_project_context(monkeypatch) -> None:
captured: dict[str, object] = {}
def fake_get_all_schemes(network, scheme_type=None, query_date=None):
captured.update(
network=network,
scheme_type=scheme_type,
query_date=query_date,
business_dsn=get_project_pgconn_string(network),
)
return [{"scheme_name": "burst_case", "scheme_type": scheme_type}]
monkeypatch.setattr(
schemes_endpoint,
"get_all_schemes",
fake_get_all_schemes,
)
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="viewer",
)
_override_project_routing(app, project_context)
response = TestClient(app, raise_server_exceptions=False).get(
"/api/v1/schemes",
params={"scheme_type": "burst_analysis"},
)
assert response.status_code == 200
assert captured == {
"network": "fengyang",
"scheme_type": "burst_analysis",
"query_date": None,
"business_dsn": "postgresql://user:password@biz/fengyang",
}
assert response.json()["items"] == [
{"scheme_name": "burst_case", "scheme_type": "burst_analysis"}
]
def test_rest_runtime_wraps_handler_paginated_list() -> None:
source_router = APIRouter()
@@ -370,51 +318,6 @@ def test_rest_runtime_wraps_handler_paginated_list() -> None:
}
def test_sensor_placement_body_uses_authenticated_project_and_user(
monkeypatch,
) -> None:
captured: dict[str, object] = {}
def fake_pressure_sensor_placement_kmeans(**kwargs):
captured.update(kwargs)
monkeypatch.setattr(
simulation_endpoint,
"pressure_sensor_placement_kmeans",
fake_pressure_sensor_placement_kmeans,
)
app = FastAPI(redirect_slashes=False)
app.include_router(api_router, prefix="/api/v1")
project_context = ProjectContext(
project_id=uuid4(),
project_code="project_a",
user_id=uuid4(),
project_role="member",
)
_override_project_routing(app, project_context)
app.dependency_overrides[get_current_metadata_user] = lambda: type(
"User", (), {"username": "alice"}
)()
response = TestClient(app, raise_server_exceptions=False).post(
"/api/v1/pressure-sensor-placement-kmeans",
json={
"scheme_name": "placement_01",
"sensor_number": 5,
"min_diameter": 100,
},
)
assert response.status_code == 200
assert captured == {
"name": "project_a",
"scheme_name": "placement_01",
"sensor_number": 5,
"min_diameter": 100,
"username": "alice",
}
def test_rest_runtime_json_encodes_untyped_datetime_response() -> None:
source_router = APIRouter()
+2 -17
View File
@@ -42,11 +42,10 @@ def _load_project_module(monkeypatch):
"read_inp": lambda network, inp: True,
"dump_inp": lambda network, inp: True,
"get_all_vertices": lambda network: [],
"get_all_scada_elements": lambda network: [],
"get_all_scada_info": lambda network: [],
"get_all_district_metering_areas": lambda network: [],
"get_all_service_areas": lambda network: [],
"get_all_virtual_districts": lambda network: [],
"get_extension_data": lambda network, key: None,
"convert_inp_v3_to_v2": lambda inp: DummyChangeSet({"inp": inp}),
},
)
@@ -55,16 +54,6 @@ def _load_project_module(monkeypatch):
"app.auth.project_dependencies",
{"get_metadata_repository": lambda: None},
)
install_stub(
monkeypatch,
"app.infra.db.postgresql.database",
{"get_database_instance": lambda network: None},
)
install_stub(
monkeypatch,
"app.infra.db.timescaledb.database",
{"get_database_instance": lambda network: None},
)
return load_module_from_path(
"tests_project_endpoints_module",
"app/api/v1/endpoints/project.py",
@@ -109,16 +98,12 @@ def test_project_info_returns_project_workspace(monkeypatch):
assert "geoserver" not in payload
def test_open_project_returns_network_even_when_db_connection_fails(monkeypatch):
def test_open_project_uses_unified_wndb_connection_path(monkeypatch):
module = _load_project_module(monkeypatch)
called = []
monkeypatch.setattr(module, "open_project", lambda network: called.append(network))
async def failing_get_pg_db(network):
raise RuntimeError("db down")
monkeypatch.setattr(module, "get_pg_db", failing_get_pg_db)
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post("/api/v1/projects/current", params={"network": "demo"})
+31 -105
View File
@@ -1,5 +1,3 @@
from typing import Any
from fastapi.testclient import TestClient
from tests.conftest import build_test_app, install_stub, load_module_from_path
@@ -7,16 +5,7 @@ from tests.conftest import build_test_app, install_stub, load_module_from_path
class DummyChangeSet:
def __init__(self, operations=None):
if operations is None:
self.operations = []
elif isinstance(operations, dict):
self.operations = [operations]
else:
self.operations = operations
def _noop(*args, **kwargs):
return None
self.operations = [operations] if isinstance(operations, dict) else operations or []
def _load_regions_module(monkeypatch):
@@ -25,41 +14,18 @@ def _load_regions_module(monkeypatch):
monkeypatch,
"app.services.tjnetwork",
{
"Any": Any,
"ChangeSet": DummyChangeSet,
"add_district_metering_area": _noop,
"add_region": _noop,
"add_service_area": _noop,
"add_virtual_district": _noop,
"calculate_district_metering_area_for_network": lambda *args, **kwargs: [],
"calculate_district_metering_area_for_nodes": lambda *args, **kwargs: [],
"calculate_district_metering_area_for_region": lambda *args, **kwargs: [],
"calculate_service_area": lambda network: [],
"calculate_virtual_district": lambda *args, **kwargs: {},
"delete_district_metering_area": _noop,
"delete_region": _noop,
"delete_service_area": _noop,
"delete_virtual_district": _noop,
"generate_district_metering_area": _noop,
"generate_service_area": _noop,
"generate_sub_district_metering_area": _noop,
"generate_virtual_district": _noop,
"get_all_district_metering_area_ids": lambda network: [],
"get_all_district_metering_areas": lambda network: [],
"get_all_service_areas": lambda network: [],
"get_all_virtual_districts": lambda network: [],
"get_district_metering_area": lambda network, area_id: {},
"get_district_metering_area_schema": lambda network: {},
"get_region": lambda network, region_id: {},
"get_region_schema": lambda network: {},
"get_service_area": lambda network, area_id: {},
"get_service_area_schema": lambda network: {},
"get_virtual_district": lambda network, area_id: {},
"get_virtual_district_schema": lambda network: {},
"set_district_metering_area": _noop,
"set_region": _noop,
"set_service_area": _noop,
"set_virtual_district": _noop,
"add_region": lambda network, cs: cs,
"delete_region": lambda network, cs: cs,
"get_nodes_in_region": lambda network, region_id: ["J1"],
"get_region": lambda network, region_id: {
"id": region_id,
"region_type": "DMA",
"boundary": [[0, 0], [1, 0], [0, 0]],
},
"get_region_schema": lambda network: {"id": {"type": "str"}},
"get_regions": lambda network: ["DMA-1"],
"set_region": lambda network, cs: cs,
},
)
return load_module_from_path(
@@ -68,87 +34,47 @@ def _load_regions_module(monkeypatch):
)
def test_removed_routes_are_absent_and_return_404(monkeypatch):
def test_regions_are_exposed_as_one_generic_resource(monkeypatch):
module = _load_regions_module(monkeypatch)
client = TestClient(build_test_app(module.router, "/api/v1"))
openapi = client.get("/openapi.json").json()
assert "/api/v1/calculateregion/" not in openapi["paths"]
assert "/api/v1/getallregions/" not in openapi["paths"]
assert "/api/v1/generateregion/" not in openapi["paths"]
assert "/api/v1/calculatedistrictmeteringarea/" not in openapi["paths"]
assert client.get("/api/v1/calculateregion/", params={"network": "demo", "time_index": 0}).status_code == 404
assert client.get("/api/v1/calculatedistrictmeteringarea/", params={"network": "demo"}).status_code == 404
def test_calculate_service_area_contract_uses_only_network(monkeypatch):
module = _load_regions_module(monkeypatch)
calls = []
monkeypatch.setattr(
module,
"calculate_service_area",
lambda network: calls.append(network) or [{"source-1": ["n1", "n2"]}],
)
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/service-area-calculations",
params={"network": "demo", "time_index": 5},
)
schema = client.get("/openapi.json").json()
response = client.get("/api/v1/regions", params={"network": "demo"})
assert response.status_code == 200
assert response.json() == [{"source-1": ["n1", "n2"]}]
assert calls == ["demo"]
parameter_names = [
item["name"]
for item in schema["paths"]["/api/v1/service-area-calculations"]["post"]["parameters"]
]
assert parameter_names == ["network"]
assert response.json()[0]["region_type"] == "DMA"
def test_add_district_metering_area_converts_boundary_to_tuples(monkeypatch):
def test_add_region_converts_boundary_to_tuples(monkeypatch):
module = _load_regions_module(monkeypatch)
captured = {}
def fake_add(network, change_set):
captured["network"] = network
captured["boundary"] = change_set.operations[0]["boundary"]
return {"ok": True}
def add(network, changeset):
captured["operation"] = changeset.operations[0]
return changeset
monkeypatch.setattr(module, "add_district_metering_area", fake_add)
monkeypatch.setattr(module, "add_region", add)
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/district-metering-areas",
"/api/v1/regions",
params={"network": "demo"},
json={"id": "dma-1", "boundary": [[1, 2], [3, 4], [1, 2]]},
json={
"id": "DMA-1",
"region_type": "DMA",
"boundary": [[0, 0], [1, 0], [0, 0]],
},
)
assert response.status_code == 200
assert captured == {
"network": "demo",
"boundary": [(1, 2), (3, 4), (1, 2)],
}
assert captured["operation"]["boundary"] == [(0, 0), (1, 0), (0, 0)]
def test_generate_virtual_district_reads_centers_from_body(monkeypatch):
def test_region_nodes_use_generic_region_id(monkeypatch):
module = _load_regions_module(monkeypatch)
captured = {}
def fake_generate(network, centers, inflate_delta):
captured["args"] = (network, centers, inflate_delta)
return {"generated": True}
monkeypatch.setattr(module, "generate_virtual_district", fake_generate)
client = TestClient(build_test_app(module.router, "/api/v1"))
response = client.post(
"/api/v1/virtual-district-generation-runs",
params={"network": "demo", "inflate_delta": 0.75},
json={"centers": ["J1", "J2"]},
response = client.get(
"/api/v1/regions/nodes", params={"network": "demo", "id": "DMA-1"}
)
assert response.status_code == 200
assert captured["args"] == ("demo", ["J1", "J2"], 0.75)
assert response.json() == ["J1"]
-102
View File
@@ -1,102 +0,0 @@
from datetime import date
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1.endpoints import schemes as schemes_endpoint
def _build_client() -> TestClient:
app = FastAPI()
app.include_router(schemes_endpoint.router, prefix="/api/v1")
return TestClient(app)
def test_get_schemes_forwards_optional_scheme_type(monkeypatch):
captured = {}
def fake_get_all_schemes(network, scheme_type=None, query_date=None):
captured["network"] = network
captured["scheme_type"] = scheme_type
captured["query_date"] = query_date
return [
{
"scheme_id": 1,
"scheme_name": "burst_case",
"scheme_type": scheme_type,
}
]
monkeypatch.setattr(schemes_endpoint, "get_all_schemes", fake_get_all_schemes)
response = _build_client().get(
"/api/v1/schemes",
params={"network": "demo", "scheme_type": "burst_analysis"},
)
assert response.status_code == 200
assert captured == {
"network": "demo",
"scheme_type": "burst_analysis",
"query_date": None,
}
assert response.json()[0]["scheme_type"] == "burst_analysis"
def test_get_schemes_forwards_query_date(monkeypatch):
captured = {}
def fake_get_all_schemes(network, scheme_type=None, query_date=None):
captured["network"] = network
captured["scheme_type"] = scheme_type
captured["query_date"] = query_date
return []
monkeypatch.setattr(schemes_endpoint, "get_all_schemes", fake_get_all_schemes)
response = _build_client().get(
"/api/v1/schemes",
params={
"network": "demo",
"scheme_type": "dma_leak_identification",
"query_date": "2026-01-02T00:00:00+08:00",
},
)
assert response.status_code == 200
assert captured == {
"network": "demo",
"scheme_type": "dma_leak_identification",
"query_date": date(2026, 1, 2),
}
def test_get_scheme_detail_forwards_scheme_type(monkeypatch):
captured = {}
def fake_query_scheme_detail(name, scheme_name, scheme_type=None):
captured["name"] = name
captured["scheme_name"] = scheme_name
captured["scheme_type"] = scheme_type
return {
"scheme_name": scheme_name,
"scheme_type": scheme_type,
"rows": [{"Area": "1", "LeakageFlow_m3_per_s": 0.1}],
}
monkeypatch.setattr(
schemes_endpoint, "query_scheme_detail", fake_query_scheme_detail
)
response = _build_client().get(
"/api/v1/schemes/dma_001",
params={"network": "demo", "scheme_type": "dma_leak_identification"},
)
assert response.status_code == 200
assert captured == {
"name": "demo",
"scheme_name": "dma_001",
"scheme_type": "dma_leak_identification",
}
assert response.json()["scheme_name"] == "dma_001"
+58 -359
View File
@@ -1,424 +1,124 @@
from datetime import datetime, timezone
from io import BytesIO
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from tests.conftest import build_test_app, install_stub, load_module_from_path
from app.api.v1.endpoints import sensor_placement as endpoint
from tests.conftest import build_test_app
class NotFoundError(LookupError):
pass
RUN_ID = uuid4()
class ValidationError(ValueError):
pass
class ConflictError(RuntimeError):
pass
def _scheme(**overrides):
def _run(**overrides):
value = {
"id": 7,
"scheme_name": "北区测压点",
"sensor_number": 2,
"run_id": RUN_ID,
"name": "北区测压点",
"sensor_count": 1,
"min_diameter": 300,
"username": "alice",
"create_time": datetime(2026, 7, 30, 8, 0, tzinfo=timezone.utc),
"sensor_location": ["J1", "J2"],
"created_by": "alice",
"created_at": datetime(2026, 8, 24, tzinfo=timezone.utc),
"status": "completed",
"sensor_locations": ["J1"],
"sensor_points": [
{
"node_id": "J1",
"max_pipe_diameter": 400.0,
"project_x": 13500000.0,
"project_y": 3600000.0,
"map_x": 13500000.0,
"map_y": 3600000.0,
"project_x": 1.0,
"project_y": 2.0,
"map_x": 3.0,
"map_y": 4.0,
"longitude": 121.0,
"latitude": 31.0,
"elevation": 4.5,
},
{
"node_id": "J2",
"max_pipe_diameter": 300.0,
"project_x": 13500100.0,
"project_y": 3600100.0,
"map_x": 13500100.0,
"map_y": 3600100.0,
"longitude": 121.001,
"latitude": 31.001,
"elevation": 5.0,
},
}
],
}
value.update(overrides)
return value
def _load_module(monkeypatch):
install_stub(monkeypatch, "app.algorithms", package=True)
install_stub(
monkeypatch,
"app.algorithms.sensor",
{
"pressure_sensor_placement_kmeans": lambda **kwargs: {"id": 7},
"pressure_sensor_placement_sensitivity": lambda **kwargs: {"id": 7},
},
def _client(project_role="member", username="alice", role="user"):
app = build_test_app(endpoint.router, "/api/v1")
app.dependency_overrides[endpoint.get_project_context] = lambda: SimpleNamespace(
project_code="tjwater", project_role=project_role
)
install_stub(monkeypatch, "app.auth", package=True)
async def current_user():
return SimpleNamespace(
username="alice",
role="user",
is_superuser=False,
)
install_stub(
monkeypatch,
"app.auth.metadata_dependencies",
{"get_current_metadata_user": current_user},
)
class ProjectContext:
def __init__(self, project_code: str, project_role: str = "member"):
self.project_code = project_code
self.project_role = project_role
async def project_context():
return ProjectContext("tjwater")
install_stub(
monkeypatch,
"app.auth.project_dependencies",
{
"ProjectContext": ProjectContext,
"get_project_context": project_context,
},
)
install_stub(monkeypatch, "app.services", package=True)
install_stub(
monkeypatch,
"app.services.sensor_placement",
{
"SensorPlacementConflictError": ConflictError,
"SensorPlacementNotFoundError": NotFoundError,
"SensorPlacementValidationError": ValidationError,
"build_sensor_placement_workbook": lambda **kwargs: BytesIO(b"xlsx"),
"can_edit_sensor_placement": (
lambda user, scheme: user.username == scheme["username"]
or user.role == "admin"
or user.is_superuser
),
"get_sensor_placement_scheme": lambda network, scheme_id: _scheme(
id=scheme_id
),
"get_sensor_placement_candidate": (
lambda network, node_id: _scheme()["sensor_points"][0]
),
"update_sensor_placement_scheme": (
lambda network, scheme_id, **kwargs: _scheme(
id=scheme_id,
sensor_location=kwargs["sensor_location"],
sensor_number=len(kwargs["sensor_location"]),
)
),
},
)
return load_module_from_path(
"tests_sensor_placement_endpoints_module",
"app/api/v1/endpoints/sensor_placement.py",
)
def _client(module, user=None, project_role="member"):
app = build_test_app(module.router, "/api/v1")
if user is None:
user = SimpleNamespace(
username="alice",
role="user",
is_superuser=False,
)
app.dependency_overrides[module.get_current_metadata_user] = lambda: user
app.dependency_overrides[module.get_project_context] = lambda: (
module.ProjectContext("tjwater", project_role)
app.dependency_overrides[endpoint.get_current_metadata_user] = lambda: SimpleNamespace(
username=username, role=role, is_superuser=False
)
return TestClient(app)
def test_optimize_returns_created_scheme(monkeypatch):
module = _load_module(monkeypatch)
def test_optimize_returns_analysis_run(monkeypatch):
captured = {}
monkeypatch.setattr(
endpoint,
"pressure_sensor_placement_kmeans",
lambda **kwargs: captured.update(kwargs) or {"run_id": RUN_ID},
)
monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run())
def optimize(**kwargs):
captured.update(kwargs)
return {"id": 7}
monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", optimize)
response = _client(module).post(
"/api/v1/sensor-placement-optimization-runs",
response = _client().post(
"/api/v1/sensor-placement-runs",
json={
"network": "tjwater",
"scheme_name": "北区测压点",
"run_name": "北区测压点",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"sensor_count": 1,
"min_diameter": 300,
},
)
assert response.status_code == 200
assert response.json()["sensor_location"] == ["J1", "J2"]
assert response.json()["run_id"] == str(RUN_ID)
assert captured["username"] == "alice"
def test_get_candidate_returns_maximum_incident_pipe_diameter(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).get(
"/api/v1/sensor-placement-candidates/J1",
)
assert response.status_code == 200
assert response.json()["node_id"] == "J1"
assert response.json()["max_pipe_diameter"] == 400.0
def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-optimization-runs",
def test_optimize_rejects_project_mismatch(monkeypatch):
response = _client().post(
"/api/v1/sensor-placement-runs",
json={
"network": "tjwater",
"scheme_name": "北区测流点",
"sensor_type": "flow",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
},
)
assert response.status_code == 422
def test_optimize_rejects_network_outside_project_context(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "other_project",
"scheme_name": "越权方案",
"network": "other",
"run_name": "越权运行",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
"sensor_count": 1,
},
)
assert response.status_code == 403
def test_optimize_rejects_network_path_traversal(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-optimization-runs",
def test_viewer_cannot_update_run(monkeypatch):
monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run())
response = _client(project_role="viewer").put(
f"/api/v1/sensor-placement-runs/{RUN_ID}",
params={"network": "tjwater"},
json={
"network": "../other_project",
"scheme_name": "非法路径",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
},
)
assert response.status_code == 422
def test_optimize_rejects_unbounded_sensor_count(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": "超大方案",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 201,
"min_diameter": 300,
},
)
assert response.status_code == 422
def test_optimize_rejects_viewer_project_role(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module, project_role="viewer").post(
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": "只读成员方案",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
"expected_sensor_locations": ["J1"],
"sensor_locations": ["J2"],
},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"project_role",
["owner", "admin", "modeler", "dispatcher", "auditor"],
)
def test_legacy_project_roles_cannot_optimize(monkeypatch, project_role):
module = _load_module(monkeypatch)
response = _client(module, project_role=project_role).post(
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": f"{project_role}方案",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
},
def test_export_returns_xlsx(monkeypatch):
monkeypatch.setattr(endpoint, "get_sensor_placement_run", lambda *_: _run())
monkeypatch.setattr(
endpoint,
"build_sensor_placement_workbook",
lambda **kwargs: BytesIO(b"xlsx"),
)
assert response.status_code == 403
def test_optimize_maps_running_project_job_to_409(monkeypatch):
module = _load_module(monkeypatch)
def conflict(**kwargs):
raise ConflictError("当前项目已有监测点优化任务正在运行,请稍后重试")
monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", conflict)
response = _client(module).post(
"/api/v1/sensor-placement-optimization-runs",
json={
"network": "tjwater",
"scheme_name": "并发方案",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
},
)
assert response.status_code == 409
def test_viewer_reads_scheme_as_non_editable(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module, project_role="viewer").get(
"/api/v1/sensor-placement-schemes/7",
response = _client().post(
f"/api/v1/sensor-placement-runs/{RUN_ID}/exports/excel",
params={"network": "tjwater"},
)
assert response.status_code == 200
assert response.json()["can_edit"] is False
def test_update_rejects_non_owner(monkeypatch):
module = _load_module(monkeypatch)
response = _client(
module,
SimpleNamespace(username="bob", role="user", is_superuser=False),
).put(
"/api/v1/sensor-placement-schemes/7",
params={"network": "tjwater"},
json={
"expected_sensor_location": ["J1", "J2"],
"sensor_location": ["J1", "J3"],
},
)
assert response.status_code == 403
def test_update_rejects_owner_with_viewer_project_role(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module, project_role="viewer").put(
"/api/v1/sensor-placement-schemes/7",
params={"network": "tjwater"},
json={
"expected_sensor_location": ["J1", "J2"],
"sensor_location": ["J1", "J3"],
},
)
assert response.status_code == 403
def test_admin_can_overwrite_scheme(monkeypatch):
module = _load_module(monkeypatch)
response = _client(
module,
SimpleNamespace(username="ops", role="admin", is_superuser=False),
).put(
"/api/v1/sensor-placement-schemes/7",
params={"network": "tjwater"},
json={
"expected_sensor_location": ["J1", "J2"],
"sensor_location": ["J1", "J3"],
},
)
assert response.status_code == 200
assert response.json()["sensor_number"] == 2
assert response.json()["sensor_location"] == ["J1", "J3"]
def test_update_maps_concurrent_change_to_409(monkeypatch):
module = _load_module(monkeypatch)
def conflict(*args, **kwargs):
raise ConflictError("方案已被其他用户修改,请重新加载")
monkeypatch.setattr(module, "update_sensor_placement_scheme", conflict)
response = _client(module).put(
"/api/v1/sensor-placement-schemes/7",
params={"network": "tjwater"},
json={
"expected_sensor_location": ["J1", "J2"],
"sensor_location": ["J1", "J3"],
},
)
assert response.status_code == 409
assert "重新加载" in response.json()["detail"]
def test_update_rejects_duplicate_nodes_before_service(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).put(
"/api/v1/sensor-placement-schemes/7",
params={"network": "tjwater"},
json={
"expected_sensor_location": ["J1", "J2"],
"sensor_location": ["J1", "J1"],
},
)
assert response.status_code == 422
def test_export_returns_xlsx_download(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-schemes/7/exports/excel",
params={"network": "tjwater"},
json={
"sensor_location": ["J1", "J2"],
"adjustment_status": {"J1": "original", "J2": "replaced"},
},
json={"sensor_locations": ["J1"], "adjustment_status": {}},
)
assert response.status_code == 200
@@ -426,4 +126,3 @@ def test_export_returns_xlsx_download(monkeypatch):
assert response.headers["content-type"].startswith(
"application/vnd.openxmlformats-officedocument"
)
assert "filename*=UTF-8" in response.headers["content-disposition"]