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"]