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.
151 lines
5.2 KiB
Python
151 lines
5.2 KiB
Python
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())
|