feat(sensor-placement): add editable scheme APIs

This commit is contained in:
2026-07-30 16:16:51 +08:00
parent 437eb5a19a
commit ddbb50173c
13 changed files with 1313 additions and 122 deletions
+36
View File
@@ -0,0 +1,36 @@
from io import BytesIO
from unittest.mock import AsyncMock
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
from app.infra.audit import middleware as audit_middleware
from app.infra.audit.middleware import AuditMiddleware
def test_post_streaming_response_survives_audit_body_capture(monkeypatch):
log_audit_event = AsyncMock()
monkeypatch.setattr(audit_middleware, "log_audit_event", log_audit_event)
app = FastAPI()
app.add_middleware(AuditMiddleware)
@app.post("/exports")
async def export(payload: dict[str, str]) -> StreamingResponse:
assert payload == {"format": "xlsx"}
return StreamingResponse(
BytesIO(b"xlsx"),
media_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
)
response = TestClient(app, raise_server_exceptions=False).post(
"/exports",
json={"format": "xlsx"},
)
assert response.status_code == 200
assert response.content == b"xlsx"
log_audit_event.assert_awaited_once()
@@ -0,0 +1,325 @@
from datetime import datetime, timezone
from io import BytesIO
from types import SimpleNamespace
from fastapi.testclient import TestClient
from tests.conftest import build_test_app, install_stub, load_module_from_path
class NotFoundError(LookupError):
pass
class ValidationError(ValueError):
pass
class ConflictError(RuntimeError):
pass
def _scheme(**overrides):
value = {
"id": 7,
"scheme_name": "北区测压点",
"sensor_number": 2,
"min_diameter": 300,
"username": "alice",
"create_time": datetime(2026, 7, 30, 8, 0, tzinfo=timezone.utc),
"sensor_location": ["J1", "J2"],
"sensor_points": [
{
"node_id": "J1",
"project_x": 13500000.0,
"project_y": 3600000.0,
"map_x": 13500000.0,
"map_y": 3600000.0,
"longitude": 121.0,
"latitude": 31.0,
"elevation": 4.5,
},
{
"node_id": "J2",
"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},
},
)
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):
self.project_code = project_code
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
),
"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):
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")
)
return TestClient(app)
def test_optimize_returns_created_scheme(monkeypatch):
module = _load_module(monkeypatch)
captured = {}
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-schemes/optimize",
json={
"network": "tjwater",
"scheme_name": "北区测压点",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
},
)
assert response.status_code == 200
assert response.json()["sensor_location"] == ["J1", "J2"]
assert captured["username"] == "alice"
def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
module = _load_module(monkeypatch)
response = _client(module).post(
"/api/v1/sensor-placement-schemes/optimize",
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-schemes/optimize",
json={
"network": "other_project",
"scheme_name": "越权方案",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 2,
"min_diameter": 300,
},
)
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-schemes/optimize",
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-schemes/optimize",
json={
"network": "tjwater",
"scheme_name": "超大方案",
"sensor_type": "pressure",
"method": "kmeans",
"sensor_count": 201,
"min_diameter": 300,
},
)
assert response.status_code == 422
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_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"},
},
)
assert response.status_code == 200
assert response.content == b"xlsx"
assert response.headers["content-type"].startswith(
"application/vnd.openxmlformats-officedocument"
)
assert "filename*=UTF-8" in response.headers["content-disposition"]