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.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.endpoints import agent_messages
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.agent_messages import AgentMessageCreate
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def test_private_message_and_exact_run_routes_are_in_contract():
|
||||
paths = app.openapi()["paths"]
|
||||
assert "post" in paths["/api/v1/agent-messages"]
|
||||
assert "get" in paths["/api/v1/agent-messages/pending"]
|
||||
assert "post" in paths["/api/v1/agent-messages/{message_id}/reply"]
|
||||
assert "post" in paths["/api/v1/agent-messages/{message_id}/delivery"]
|
||||
assert "get" in paths["/api/v1/operational-tasks/{task_id}/runs/{run_id}"]
|
||||
parameters = paths["/api/v1/agent-messages"]["get"]["parameters"]
|
||||
assert {"session_id", "cursor", "pending_only"} <= {param["name"] for param in parameters}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_message_route_preserves_structured_terminal_rejection(monkeypatch):
|
||||
service = SimpleNamespace(create=AsyncMock(side_effect=DomainError(
|
||||
"RUN_TERMINAL", "Run no longer accepts messages", status=409)))
|
||||
monkeypatch.setattr(agent_messages, "_service", lambda ctx, conn: service)
|
||||
context = ProjectContext(uuid4(), "test", uuid4(), "member")
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await agent_messages.create_agent_message(AgentMessageCreate(
|
||||
request_id=uuid4(), task_id=uuid4(), run_id=uuid4(),
|
||||
source_session_id="main", text="当前情况?"), ctx=context, conn=object())
|
||||
assert error.value.status_code == 409 and error.value.detail["code"] == "RUN_TERMINAL"
|
||||
@@ -0,0 +1,150 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import health
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def probe_app(monkeypatch):
|
||||
app = FastAPI()
|
||||
app.include_router(health.router)
|
||||
app.state.startup_complete = True
|
||||
app.state.mcp_runtime = SimpleNamespace(ready=True)
|
||||
database_check = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(health, "metadata_database_ready", database_check)
|
||||
return app, TestClient(app), database_check
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runtime", [None, SimpleNamespace(ready=True)])
|
||||
def test_ready_accepts_disabled_or_started_mcp(probe_app, runtime):
|
||||
app, client, database_check = probe_app
|
||||
app.state.mcp_runtime = runtime
|
||||
response = client.get("/ready")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "ready",
|
||||
"checks": {
|
||||
"startup": "ready", "metadata_database": "ready",
|
||||
"mcp_transport": "disabled" if runtime is None else "ready",
|
||||
},
|
||||
}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
database_check.assert_awaited_once_with()
|
||||
|
||||
|
||||
def test_startup_and_shutdown_are_not_ready_without_touching_database(probe_app):
|
||||
app, client, database_check = probe_app
|
||||
app.state.startup_complete = False
|
||||
response = client.get("/ready")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["checks"]["startup"] == "not_ready"
|
||||
assert response.json()["checks"]["metadata_database"] == "not_checked"
|
||||
database_check.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("component", ["metadata_database", "mcp_transport"])
|
||||
def test_dependency_not_ready_returns_503_without_breaking_liveness(probe_app, component):
|
||||
app, client, database_check = probe_app
|
||||
if component == "metadata_database":
|
||||
database_check.return_value = False
|
||||
else:
|
||||
app.state.mcp_runtime.ready = False
|
||||
response = client.get("/ready")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["status"] == "not_ready"
|
||||
assert response.json()["checks"][component] in {"unavailable", "not_ready"}
|
||||
database_check.reset_mock()
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
database_check.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["ready", "error", "timeout"])
|
||||
def test_metadata_probe_runs_bounded_select_and_sanitizes_errors(monkeypatch, outcome):
|
||||
queries = []
|
||||
closed = []
|
||||
|
||||
async def execute(query):
|
||||
queries.append(str(query))
|
||||
if outcome == "error":
|
||||
raise RuntimeError("postgresql://private:secret@internal/database")
|
||||
if outcome == "timeout":
|
||||
await asyncio.Event().wait()
|
||||
return SimpleNamespace(scalar_one=lambda: 1)
|
||||
|
||||
@asynccontextmanager
|
||||
async def connect():
|
||||
try:
|
||||
yield SimpleNamespace(execute=execute)
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
monkeypatch.setattr(health, "engine", SimpleNamespace(connect=connect))
|
||||
monkeypatch.setattr(health, "METADATA_TIMEOUT_SECONDS", 0.01)
|
||||
assert asyncio.run(health.metadata_database_ready()) is (outcome == "ready")
|
||||
assert queries == ["SELECT 1"]
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("startup_fails", [False, True])
|
||||
def test_application_lifecycle_marks_readiness_before_cleanup(monkeypatch, startup_fails):
|
||||
from app import main
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@asynccontextmanager
|
||||
async def mcp_lifespan():
|
||||
assert app.state.startup_complete is False
|
||||
if startup_fails:
|
||||
raise RuntimeError("MCP startup failed")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
assert app.state.startup_complete is False
|
||||
|
||||
monkeypatch.setattr(main, "mcp_runtime", SimpleNamespace(lifespan=mcp_lifespan))
|
||||
for owner, method in [
|
||||
(main.workbench_event_hubs, "close_all"),
|
||||
(main.project_connection_manager, "close_all"),
|
||||
(main, "close_metadata_engine"),
|
||||
]:
|
||||
monkeypatch.setattr(owner, method, AsyncMock())
|
||||
monkeypatch.setattr(main, "close_all_timescale_pools", Mock())
|
||||
monkeypatch.setattr(main, "close_all_project_pools", Mock())
|
||||
|
||||
async def run():
|
||||
if startup_fails:
|
||||
with pytest.raises(RuntimeError, match="MCP startup failed"):
|
||||
async with main.lifespan(app):
|
||||
pytest.fail("Failed startup must not yield")
|
||||
else:
|
||||
async with main.lifespan(app):
|
||||
assert app.state.startup_complete is True
|
||||
assert app.state.startup_complete is False
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_mcp_runtime_tracks_its_actual_lifecycle():
|
||||
from app.mcp.application import McpQueryApplication
|
||||
from app.mcp.config import McpSettings
|
||||
|
||||
runtime = McpQueryApplication(McpSettings(
|
||||
enabled=True, issuer_uri="http://localhost/realms/water",
|
||||
resource_uri="http://localhost/mcp", _env_file=None,
|
||||
))
|
||||
|
||||
async def run():
|
||||
assert runtime.ready is False
|
||||
async with runtime.lifespan():
|
||||
assert runtime.ready is True
|
||||
assert runtime.ready is False
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,34 @@
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.rest_router import api_router
|
||||
from app.api.v1.endpoints.timeseries import composite
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||
from app.domain.schemas.timeseries_history import ElementHistoryResponse
|
||||
|
||||
|
||||
def test_viewer_can_query_history_without_scada_clean_permission(monkeypatch):
|
||||
project_id = uuid4()
|
||||
context = ProjectContext(project_id=project_id, project_code="test", user_id=uuid4(), project_role="viewer")
|
||||
app = FastAPI()
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_project_context] = lambda: context
|
||||
app.dependency_overrides[composite.get_timescale_connection] = lambda: object()
|
||||
app.dependency_overrides[composite.get_postgres_connection] = lambda: object()
|
||||
query = AsyncMock(return_value=ElementHistoryResponse(series=[]))
|
||||
monkeypatch.setattr(composite.TimeseriesHistoryService, "query", query)
|
||||
with TestClient(app) as client:
|
||||
headers = {"X-Project-Id": str(project_id)}
|
||||
response = client.post("/api/v1/timeseries/views/element-history/query", headers=headers, json={
|
||||
"start_time": "2026-09-01T00:00:00Z", "end_time": "2026-09-01T01:00:00Z",
|
||||
"elements": [{"element_type": "junction", "element_id": "J1"}],
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"series": []}
|
||||
assert query.await_count == 1
|
||||
write_response = client.post("/api/v1/timeseries/scada-cleaning-runs", headers=headers, json={})
|
||||
assert write_response.status_code == 403
|
||||
assert write_response.json()["detail"]["permission"] == "scada.clean"
|
||||
@@ -71,13 +71,13 @@ def test_rest_router_has_unique_method_path_pairs() -> None:
|
||||
assert len(pairs) == len(set(pairs))
|
||||
|
||||
|
||||
def test_removed_redis_management_routes_are_not_published() -> None:
|
||||
def test_retired_management_and_web_search_routes_are_not_published() -> None:
|
||||
published_paths = {
|
||||
route.path for route in api_router.routes if isinstance(route, APIRoute)
|
||||
}
|
||||
|
||||
assert published_paths.isdisjoint(
|
||||
{"/redis-keys/detail", "/redis-keys", "/all-redis", "/redis"}
|
||||
{"/redis-keys/detail", "/redis-keys", "/all-redis", "/redis", "/web-searches"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.api.v1.endpoints import operational_tasks as endpoints
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
||||
from app.domain.schemas.operational_task import EvidenceRecord
|
||||
from app.domain.schemas.operational_task import TaskPresentationEventCommand
|
||||
from app.main import app
|
||||
|
||||
|
||||
@@ -31,10 +32,34 @@ def test_dynamic_investigation_routes_are_in_the_public_contract():
|
||||
assert "post" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch"]
|
||||
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports"]
|
||||
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports/{revision}"]
|
||||
assert "post" in paths["/api/v1/operational-tasks/{task_id}/presentation-events"]
|
||||
assert "get" in paths["/api/v1/operational-tasks/{task_id}/presentation-cursor"]
|
||||
context_params = paths["/api/v1/operational-tasks/{task_id}/agent-context"]["get"]["parameters"]
|
||||
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
|
||||
from app.domain.domain_errors import DomainError
|
||||
|
||||
service = SimpleNamespace(
|
||||
publish_presentation_event=AsyncMock(side_effect=DomainError("SESSION_MISMATCH", "stale session", status=409)),
|
||||
presentation_cursor=AsyncMock(return_value={"task_id": str(uuid4()), "execution_generation": 1,
|
||||
"active_run": None, "event_cursor": 17}),
|
||||
)
|
||||
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await endpoints.publish_operational_task_presentation_event(
|
||||
uuid4(), TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="stale",
|
||||
command={"action": "clear_highlights"}),
|
||||
ctx=_service_context(), conn=object(),
|
||||
)
|
||||
assert error.value.status_code == 409
|
||||
assert error.value.detail["code"] == "SESSION_MISMATCH"
|
||||
result = await endpoints.get_operational_task_presentation_cursor(uuid4(), ctx=_service_context(), conn=object())
|
||||
assert result["event_cursor"] == 17 and result["active_run"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_data_query_requires_task_grant_before_source_request(monkeypatch):
|
||||
service = SimpleNamespace(can_read_source=AsyncMock(return_value=False))
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import place_search
|
||||
from app.api.v1.rest_router import api_router
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchResponse
|
||||
from app.services.place_search import PlaceSearchError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
application = FastAPI()
|
||||
application.include_router(api_router, prefix="/api/v1")
|
||||
application.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "viewer")
|
||||
return application
|
||||
|
||||
|
||||
def test_viewer_can_search_with_existing_project_read_permission(app, monkeypatch):
|
||||
service = AsyncMock(return_value=PlaceSearchResponse(items=[PlaceSearchItem(
|
||||
id="place-1", name="南桥路", address="上海市奉贤区南桥路", longitude=121.44, latitude=31.22,
|
||||
)], total=1))
|
||||
monkeypatch.setattr(place_search, "search_tianditu_places", service)
|
||||
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["name"] == "南桥路"
|
||||
assert response.json()["region"] == "奉贤区"
|
||||
assert set(response.json()) == {"items", "total", "truncated", "region"}
|
||||
assert service.await_args.args[0].keyword == "南桥路"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [502, 503, 504])
|
||||
def test_endpoint_preserves_provider_failure_status(app, monkeypatch, status):
|
||||
monkeypatch.setattr(place_search, "search_tianditu_places", AsyncMock(side_effect=PlaceSearchError(status, "查询暂不可用")))
|
||||
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
|
||||
assert response.status_code == status and response.json()["detail"] == "查询暂不可用"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", [{"region": "北京市"}, {"specify": "156310104"}, {"offset": 0}, {"limit": 10}])
|
||||
def test_scope_and_result_limit_cannot_be_overridden(app, monkeypatch, extra):
|
||||
service = AsyncMock()
|
||||
monkeypatch.setattr(place_search, "search_tianditu_places", service)
|
||||
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路", **extra})
|
||||
assert response.status_code == 422 and not service.called
|
||||
|
||||
|
||||
def test_search_denies_users_without_project_read_permission(app, monkeypatch):
|
||||
app.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "none")
|
||||
service = AsyncMock()
|
||||
monkeypatch.setattr(place_search, "search_tianditu_places", service)
|
||||
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
|
||||
assert response.status_code == 403 and not service.called
|
||||
|
||||
|
||||
def test_search_contract_has_typed_results_and_required_project_header(app):
|
||||
contract = app.openapi()
|
||||
operation = contract["paths"]["/api/v1/place-search-requests"]["post"]
|
||||
assert any(param["name"] == "X-Project-Id" and param["required"] for param in operation["parameters"])
|
||||
assert operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("PlaceSearchResponse")
|
||||
assert set(contract["components"]["schemas"]["PlaceSearchRequest"]["properties"]) == {"keyword"}
|
||||
assert contract["components"]["schemas"]["PlaceSearchResponse"]["properties"]["items"]["maxItems"] == 10
|
||||
@@ -31,6 +31,7 @@ def _load_simulation_module(monkeypatch):
|
||||
{
|
||||
"get_time": lambda name: {"HYDRAULIC TIMESTEP": "0:15:00"},
|
||||
"run_simulation": lambda **kwargs: None,
|
||||
"run_online_simulation": lambda **kwargs: None,
|
||||
"query_corresponding_element_id_and_query_id": lambda name: SimpleNamespace(
|
||||
fixed_pumps={}, variable_pumps={}
|
||||
),
|
||||
@@ -145,13 +146,13 @@ def test_pressure_regulation_requires_scheme_name(monkeypatch):
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
|
||||
def test_run_simulation_manually_by_date_passes_aware_time_and_converts_minutes(monkeypatch):
|
||||
module = _load_simulation_module(monkeypatch)
|
||||
captured_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
module.simulation,
|
||||
"run_simulation",
|
||||
"run_online_simulation",
|
||||
lambda **kwargs: captured_calls.append(kwargs),
|
||||
)
|
||||
|
||||
@@ -161,13 +162,13 @@ def test_run_simulation_manually_by_date_uses_utc_aware_timestamps(monkeypatch):
|
||||
30,
|
||||
)
|
||||
|
||||
assert [call["modify_pattern_start_time"] for call in captured_calls] == [
|
||||
"2025-01-01T19:04:05+00:00",
|
||||
"2025-01-01T19:19:05+00:00",
|
||||
]
|
||||
assert captured_calls == [{
|
||||
"name": "demo", "start_time": datetime(2025, 1, 1, 19, 4, 5, tzinfo=timezone.utc),
|
||||
"duration_seconds": 1800,
|
||||
}]
|
||||
|
||||
|
||||
def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch):
|
||||
def test_run_simulation_manually_by_date_preserves_two_hour_window(monkeypatch):
|
||||
module = _load_simulation_module(monkeypatch)
|
||||
captured_calls = []
|
||||
|
||||
@@ -178,7 +179,7 @@ def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.simulation,
|
||||
"run_simulation",
|
||||
"run_online_simulation",
|
||||
lambda **kwargs: captured_calls.append(kwargs),
|
||||
)
|
||||
|
||||
@@ -188,10 +189,8 @@ def test_run_simulation_manually_by_date_uses_hydraulic_timestep(monkeypatch):
|
||||
120,
|
||||
)
|
||||
|
||||
assert [call["modify_pattern_start_time"] for call in captured_calls] == [
|
||||
"2025-01-01T16:00:00+00:00",
|
||||
"2025-01-01T17:00:00+00:00",
|
||||
]
|
||||
assert captured_calls[0]["duration_seconds"] == 7200
|
||||
assert captured_calls[0]["start_time"] == datetime(2025, 1, 1, 16, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_runsimulationmanuallybydate_endpoint_accepts_timezone_aware_start_time(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user