Files
next-tjwater-server/tests/unit/test_mcp_query_limits.py
T
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00

225 lines
9.8 KiB
Python

import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.domain.schemas.timeseries_history import ElementHistoryQuery
from app.infra.db.postgresql import read_queries
from app.services import timeseries_history
START = datetime(2026, 10, 1, tzinfo=timezone.utc)
def test_history_pages_advance_across_raw_cleaned_and_model_series(monkeypatch):
monkeypatch.setattr(
timeseries_history.ScadaInfoRepository, "get_scadas_for_elements",
AsyncMock(return_value=[{
"device_id": "D1", "device_type": "pressure", "node_id": "J1",
"link_id": None, "measurement_unit": "m",
}]),
)
monkeypatch.setattr(
timeseries_history.ScadaRepository, "get_scada_by_ids_time_range",
AsyncMock(side_effect=AssertionError("must use paged SQL")),
)
monkeypatch.setattr(
timeseries_history.NetworkSettingsRepository, "get_result_units",
AsyncMock(return_value={"pressure": "m"}),
)
points = [
{"series_index": i, "time": START + timedelta(minutes=j), "value": i * 10 + j}
for i in range(3) for j in range(2)
]
async def page(conn, descriptors, *, start, end, run_id, limit, offset):
assert len(descriptors) == 3
return points[offset:offset + limit]
monkeypatch.setattr(read_queries, "history_rows", page, raising=False)
query = ElementHistoryQuery(
start_time=START, end_time=START + timedelta(hours=1),
mode="realtime_comparison", elements=[{"element_type": "junction", "element_id": "J1"}],
)
async def check():
seen = []
for offset in (0, 2, 4, 6):
result = await timeseries_history.TimeseriesHistoryService.query(
object(), object(), query, row_limit=2, row_offset=offset,
)
seen.extend(point.value for series in result.series for point in series.points)
assert result.truncated is (offset < 4)
assert seen == [0, 1, 10, 11, 20, 21]
asyncio.run(check())
class Connection:
def __init__(self, rows):
self.rows = rows
self.statements = []
@asynccontextmanager
async def transaction(self):
yield self
def cursor(self, **kwargs):
return self
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def execute(self, statement, params):
self.statements.append((statement.as_string() if hasattr(statement, "as_string") else statement, params))
async def fetchall(self):
return self.rows
def test_history_sql_applies_one_page_to_all_sources():
conn = Connection([])
asyncio.run(read_queries.history_rows(
conn, [
{"source": "scada", "element_type": "junction", "id": "D'1", "field": "monitored_value"},
{"source": "analysis", "element_type": "pipe", "id": "P1", "field": "flow"},
], start=START, end=START + timedelta(hours=1), run_id=uuid4(), limit=3, offset=2,
))
statement, params = conn.statements[-1]
assert "UNION ALL" in statement and "ORDER BY series_index, time" in statement
assert statement.endswith("LIMIT %s OFFSET %s") and params[-2:] == (3, 2)
assert "run_id=%s" in statement and "D'1" not in statement
def test_evidence_projection_is_scoped_paged_and_size_guarded_in_sql():
payload = {"value": [2], "total": 3, "value_type": "array", "snapshot_metadata": {}, "query_metadata": None}
conn = Connection([{"payload": payload, "too_large": False}])
result = asyncio.run(read_queries.evidence_page(
conn, project_id=uuid4(), task_id=uuid4(), evidence_id="E1",
data_path=["items"], limit=1, offset=1, mcp_sources=["query_scada_readings"],
))
assert result == payload
statement, params = conn.statements[-1]
assert "project_id=%s AND task_id=%s AND evidence_id=%s" in statement
assert "#> %s::text[]" in statement and "LIMIT %s OFFSET %s" in statement
assert "octet_length" in statement and "CASE WHEN" in statement
assert ["items"] in params
def test_oversized_evidence_does_not_return_a_document():
from fastapi import HTTPException
conn = Connection([{"payload": None, "too_large": True}])
with pytest.raises(HTTPException) as caught:
asyncio.run(read_queries.evidence_page(
conn, project_id=uuid4(), task_id=uuid4(), evidence_id="E1",
data_path=[], limit=1, offset=0, mcp_sources=[],
))
assert caught.value.status_code == 413
def test_demo_payload_has_a_size_gate_before_transfer(monkeypatch):
from fastapi import HTTPException
from app.mcp import peripheral
from app.mcp.models import PeripheralInput
calls = []
async def rows(conn, statement, params):
calls.append(statement)
return [{"document": None, "digest": "unused", "too_large": True}]
monkeypatch.setattr(peripheral, "read_rows", rows)
args = PeripheralInput(
context_handle="a" * 43, source="peripheral_observations", point_id="DEMO-1",
start=START, end=START + timedelta(hours=1), limit=1,
)
with pytest.raises(HTTPException) as caught:
asyncio.run(peripheral.read_peripheral_query(
SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=uuid4())), args,
))
assert caught.value.status_code == 413
assert "CASE WHEN" in calls[0] and "octet_length" in calls[0]
def test_history_tool_forwards_offset_and_keeps_last_page_partial(monkeypatch):
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, HistoryInput
conn = Connection([])
scope = SimpleNamespace(connection=conn, project=object(), timescale=conn.transaction)
binding = BoundContext(
context_handle="a"*43, project_id=uuid4(), subject="u", client_id="host",
tool_cap=["query_element_history"], permission_cap=["scada.view"],
native_session_id="s", purpose="ordinary_chat", expires_at=START,
)
monkeypatch.setattr(query_service, "permissions_for_context", lambda scope: {"scada.view"})
from app.domain.schemas.timeseries_history import ElementHistoryResponse
reader = AsyncMock(return_value=ElementHistoryResponse(series=[], truncated=False))
monkeypatch.setattr(timeseries_history.TimeseriesHistoryService, "query", reader)
args = HistoryInput(
context_handle="a"*43, start=START, end=START+timedelta(hours=1),
elements=[{"element_type": "junction", "element_id": "J1"}], limit=2, offset=4,
)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["query_element_history"], args))
assert reader.await_args.kwargs == {"row_limit": 2, "row_offset": 4}
assert result.page.offset == 4 and not result.page.has_more and result.truncated
def test_task_context_retains_selected_report_revision(monkeypatch):
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, TaskContextInput
reader = AsyncMock(return_value={"selected_report": {"revision": 3}})
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
task_id = uuid4()
binding = BoundContext(
context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
tool_cap=["get_task_context"], permission_cap=["operational_task.view"],
native_session_id="s", purpose="task_collaboration", task_id=task_id,
expires_at=START,
)
scope = SimpleNamespace(connection=Connection([]), project=object())
args = TaskContextInput(context_handle="a" * 43, report_revision=3)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], args))
reader.assert_awaited_once_with(scope.connection, project_id=binding.project_id, task_id=task_id, report_revision=3)
assert result.data["selected_report"]["revision"] == 3
with pytest.raises(ValueError):
TaskContextInput(context_handle="a" * 43, report_revision=0)
@pytest.mark.parametrize("section", ["evidence", "actions", "access_requests"])
def test_task_context_collection_pages_use_database_total_and_stable_paths(monkeypatch, section):
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, TaskContextInput
reader = AsyncMock(return_value={
"total": 2, "value_type": "object", "nested": True,
"snapshot_metadata": {"section": section, "total": 3},
"value": {"stable-id": {"collection_type": "object", "data_path": ["stable-id"], "count": 4}},
})
monkeypatch.setattr(query_service.reads, "task_context_page", reader)
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
binding = BoundContext(context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
tool_cap=["get_task_context"], permission_cap=["operational_task.view"], native_session_id="s",
purpose="task_collaboration", task_id=uuid4(), expires_at=START)
scope = SimpleNamespace(connection=Connection([]), project=object())
args = TaskContextInput(context_handle="a" * 43, section=section, limit=1, offset=1)
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"], args))
assert result.page.total == 3 and result.page.has_more and result.page.offset == 1
assert result.truncated and result.data["value"]["stable-id"]["data_path"] == ["stable-id"]
reader.assert_awaited_once_with(scope.connection, project_id=binding.project_id, task_id=binding.task_id,
section=section, report_revision=None, data_path=[], limit=1, offset=1)