413 lines
12 KiB
Python
413 lines
12 KiB
Python
from datetime import datetime, timezone
|
|
from io import BytesIO
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
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, 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
|
|
),
|
|
"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)
|
|
)
|
|
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-optimization-runs",
|
|
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-optimization-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": "越权方案",
|
|
"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-optimization-runs",
|
|
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,
|
|
},
|
|
)
|
|
|
|
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,
|
|
},
|
|
)
|
|
|
|
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",
|
|
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"},
|
|
},
|
|
)
|
|
|
|
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"]
|