import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from pydantic import ValidationError from app.infra.db.postgresql import spatial_queries from app.mcp.models import NearbyElementsInput from app.mcp import query_service from app.mcp.catalog import TOOLS from fastapi import HTTPException def test_nearby_input_uses_geocoder_coordinate_order_and_bounded_defaults(): args = NearbyElementsInput(context_handle="a" * 43, center=[121.5, 30.9]) assert args.center == (121.5, 30.9) assert (args.element_type, args.radius_m, args.limit, args.offset) == ("pipe", 200, 5, 0) schema = NearbyElementsInput.model_json_schema() assert schema["properties"]["center"]["prefixItems"][0]["maximum"] == 180 assert schema["properties"]["center"]["prefixItems"][1]["maximum"] == 90 @pytest.mark.parametrize("overrides", [ {"center": [181, 30]}, {"center": [121, 91]}, {"center": [121]}, {"center": [121, 30, 0]}, {"center": [float("nan"), 30]}, {"center": [121, float("inf")]}, {"radius_m": 0}, {"radius_m": 10001}, {"radius_m": float("inf")}, {"limit": 101}, {"offset": 10001}, {"element_type": "pipe; DROP TABLE network.pipes"}, {"source": "source_gis"}, {"project_id": "foreign"}, {"sql": "SELECT * FROM network.pipes"}, ]) def test_nearby_rejects_invalid_or_unscoped_parameters(overrides): with pytest.raises(ValidationError): NearbyElementsInput.model_validate({ "context_handle": "a" * 43, "center": [121.5, 30.9], **overrides, }) def test_nearby_sql_limits_before_transfer_and_measures_complete_geometries(monkeypatch): rows = [{"element_id": "P1", "distance_m": 3.5, "properties": {"diameter": 300}}] read = AsyncMock(return_value=rows) monkeypatch.setattr(spatial_queries, "read_rows", read) result = asyncio.run(spatial_queries.nearby_elements( object(), "pipe", center=(121.5, 30.9), radius_m=500, limit=6, offset=10, )) assert result == rows _, statement, parameters = read.call_args.args query = statement.as_string() assert 'FROM "gis"."pipes"' in query assert "v.geom && ST_Transform" in query assert "ST_Transform(v.geom,4326)::geography" in query assert "ST_DWithin(c.shape,t.point,%s,true)" in query assert "ST_Distance(c.shape,t.point,true)" in query assert "ST_StartPoint" not in query and "ST_EndPoint" not in query assert "ORDER BY distance_m,c.id" in query assert "LIMIT %s OFFSET %s" in query assert parameters[:2] == (121.5, 30.9) and parameters[-3:] == (500, 6, 10) def test_candidate_envelopes_split_at_dateline_and_skip_mercator_poles(): boxes = spatial_queries._search_boxes(179.9999, 30, 500) assert len(boxes) == 2 assert all(-180 <= left < right <= 180 for left, _, right, _ in boxes) assert spatial_queries._search_boxes(0, 90, 500) == [] assert spatial_queries._search_boxes(0, -90, 500) == [] def test_nearby_dispatch_preserves_scope_paging_units_and_current_permissions(monkeypatch): connection = object() scope = SimpleNamespace(connection=connection, project=object()) binding = SimpleNamespace( tool_cap=["query_nearby_elements"], permission_cap=["webgis.view"], task_id=None, purpose="ordinary_chat", task_scope_cap=[], ) args = NearbyElementsInput(context_handle="a" * 43, center=[121.5, 30.9], limit=2, offset=1) rows = [{"element_id": f"P{i}", "properties": {}, "distance_m": i} for i in (1, 2, 3)] read = AsyncMock(return_value=rows) monkeypatch.setattr(query_service, "nearby_elements", read) monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.view"}) result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_nearby_elements"], args)) assert result.data["items"] == rows[:2] assert result.data["center"] == [121.5, 30.9] assert result.data["center_crs"] == "EPSG:4326" assert result.data["source"] == "model_network" assert result.page.has_more and result.page.offset == 1 and result.truncated assert result.units["distance_m"] == "m" and result.semantics == "asset" assert read.call_args.args == (connection, "pipe") assert read.call_args.kwargs == {"center": (121.5, 30.9), "radius_m": 200, "limit": 3, "offset": 1} read.reset_mock() monkeypatch.setattr(query_service, "permissions_for_context", lambda _: set()) with pytest.raises(HTTPException) as denied: asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_nearby_elements"], args)) assert denied.value.status_code == 403 read.assert_not_called()