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:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
+240
View File
@@ -0,0 +1,240 @@
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.auth.project_dependencies import ProjectContext
from app.domain.domain_errors import DomainError
from app.domain.schemas.agent_messages import AgentMessageCreate, AgentMessageDelivery, AgentMessageReply
from app.domain.schemas.operational_task import AgentRunCommand
from app.services.agent_messages import AgentMessageService
from app.services.operational_tasks import OperationalTaskService
@pytest.fixture
def anyio_backend():
return "asyncio"
NOW = datetime(2026, 10, 13, 0, tzinfo=timezone.utc)
class Repository:
def __init__(self):
self.project_id, self.task_id, self.run_id = uuid4(), uuid4(), uuid4()
self.run = {"run_id": str(self.run_id), "task_id": str(self.task_id),
"status": "running", "agent_session_id": "task-session",
"lease_expires_at": NOW + timedelta(minutes=1), "revision": 2,
"generation": 1, "execution_request_id": str(uuid4()),
"next_dispatch_at": NOW, "created_at": NOW, "updated_at": NOW}
self.messages = {}
@asynccontextmanager
async def transaction(self):
yield self
async def lock(self, key):
pass
async def get_run(self, run_id):
return self.run if run_id == str(self.run_id) else None
async def get_task(self, task_id):
return {"task_type": "event_investigation"} if task_id == str(self.task_id) else None
async def get_message(self, message_id):
return self.messages.get(message_id)
async def get_message_request(self, owner_id, session_id, request_id):
return next((row for row in self.messages.values()
if row["source_owner_id"] == owner_id and row["source_session_id"] == session_id
and row["request_id"] == request_id), None)
async def insert_message(self, message):
self.messages[message["message_id"]] = message
async def update_message_delivery(self, message_id, update):
self.messages[message_id].update(update)
def setup():
repo = Repository()
actor = ProjectContext(repo.project_id, "test", uuid4(), "member")
worker = ProjectContext(repo.project_id, "test", uuid4(), "member", system_role="service")
service = AgentMessageService(repo, actor, clock=lambda: NOW)
trusted = AgentMessageService(repo, worker, clock=lambda: NOW)
command = AgentMessageCreate(request_id=uuid4(), task_id=repo.task_id, run_id=repo.run_id,
source_session_id="main-session", text="核对当前压力")
return repo, actor, service, trusted, command
@pytest.mark.anyio
async def test_messages_are_bound_to_exact_run_and_idempotent_even_after_finish():
repo, actor, service, trusted, command = setup()
sent = await service.create(command)
assert sent.source_owner_id == actor.user_id == sent.conversation_owner_id
assert sent.native_message_id == f"msg_{sent.message_id}"
repo.run["status"] = "succeeded"
assert (await service.create(command)).message_id == sent.message_id
with pytest.raises(DomainError, match="request_id") as error:
await service.create(command.model_copy(update={"text": "different"}))
assert error.value.code == "IDEMPOTENCY_CONFLICT"
with pytest.raises(DomainError) as error:
await service.create(command.model_copy(update={"request_id": uuid4()}))
assert error.value.code == "RUN_TERMINAL"
assert len(repo.messages) == 1
@pytest.mark.anyio
async def test_wrong_task_or_expired_lease_cannot_accept_message():
repo, actor, service, trusted, command = setup()
with pytest.raises(DomainError) as error:
await service.create(command.model_copy(update={"task_id": uuid4()}))
assert error.value.code == "RUN_NOT_FOUND"
repo.run["lease_expires_at"] = NOW
with pytest.raises(DomainError) as error:
await service.create(command)
assert error.value.code == "LEASE_EXPIRED"
repo.run.update(status="queued", agent_session_id=None)
assert (await service.create(command)).target_session_id is None
@pytest.mark.anyio
async def test_reply_has_original_conversation_owner_and_immutable_channel():
repo, actor, service, trusted, command = setup()
incoming = await service.create(command)
reply_command = AgentMessageReply(request_id=uuid4(), source_session_id="task-session", text="压力正常")
with pytest.raises(DomainError) as error:
await service.reply(incoming.message_id, reply_command)
assert error.value.code == "SERVICE_REQUIRED"
with pytest.raises(DomainError) as error:
await trusted.reply(incoming.message_id, reply_command.model_copy(update={"source_session_id": "other"}))
assert error.value.code == "SESSION_MISMATCH"
reply = await trusted.reply(incoming.message_id, reply_command)
assert reply.source_owner_id == trusted.context.user_id
assert reply.conversation_owner_id == actor.user_id
assert reply.target_session_id == command.source_session_id
followup = await service.reply(reply.message_id, AgentMessageReply(
request_id=uuid4(), source_session_id="main-session", text="请继续核对"))
assert followup.direction == "to_task" and followup.in_reply_to == reply.message_id
assert followup.target_session_id == "task-session"
with pytest.raises(DomainError) as error:
await service.reply(reply.message_id, AgentMessageReply(
request_id=uuid4(), source_session_id="wrong-main", text="伪造来源"))
assert error.value.code == "SESSION_MISMATCH"
repo.run["status"] = "succeeded"
assert (await trusted.reply(incoming.message_id, reply_command)).message_id == reply.message_id
with pytest.raises(DomainError) as error:
await service.delivery(reply.message_id, AgentMessageDelivery(status="delivered", target_session_id="other"))
assert error.value.code == "SESSION_MISMATCH"
received = await service.delivery(reply.message_id, AgentMessageDelivery(status="enqueued"))
assert received.status == "enqueued" and received.delivered_at is None
assert (await service.delivery(reply.message_id, AgentMessageDelivery(status="delivered"))).delivered_at == NOW
@pytest.mark.anyio
async def test_inbound_delivery_requires_service_run_session_and_fixed_native_identity():
repo, actor, service, trusted, command = setup()
incoming = await service.create(command)
with pytest.raises(DomainError) as error:
await service.delivery(incoming.message_id, AgentMessageDelivery(status="delivered"))
assert error.value.code == "SERVICE_REQUIRED"
with pytest.raises(DomainError) as error:
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="enqueued", target_session_id="wrong"))
assert error.value.code == "SESSION_MISMATCH"
with pytest.raises(DomainError) as error:
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="delivered", target_session_id="task-session", native_message_id="changed"))
assert error.value.code == "MESSAGE_ID_MISMATCH"
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="enqueued", target_session_id="task-session"))
assert (await service.get(incoming.message_id)).status == "enqueued"
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="delivered", target_session_id="task-session"))
with pytest.raises(DomainError) as error:
await trusted.delivery(incoming.message_id, AgentMessageDelivery(status="failed"))
assert error.value.code == "MESSAGE_TERMINAL"
@pytest.mark.anyio
async def test_other_project_member_cannot_read_private_message_or_pending_dispatch():
repo, actor, service, trusted, command = setup()
incoming = await service.create(command)
outsider = AgentMessageService(repo, ProjectContext(repo.project_id, "test", uuid4(), "member"))
with pytest.raises(DomainError) as error:
await outsider.get(incoming.message_id)
assert error.value.code == "MESSAGE_NOT_FOUND"
with pytest.raises(DomainError) as error:
await outsider.pending(20)
assert error.value.code == "SERVICE_REQUIRED"
viewer_service = AgentMessageService(repo, ProjectContext(
repo.project_id, "test", uuid4(), "viewer", system_role="service"))
with pytest.raises(DomainError) as error:
await viewer_service.get(incoming.message_id)
assert error.value.code == "SERVICE_REQUIRED"
@pytest.mark.anyio
async def test_own_session_listing_uses_owner_and_keyset_cursor():
repo, actor, service, trusted, command = setup()
sent = await service.create(command)
other = await service.create(command.model_copy(update={"request_id": uuid4(), "text": "后续问题"}))
repo.list_session_messages = AsyncMock(return_value=[sent.model_dump(), other.model_dump()])
page = await service.list_session("main-session", 1, None, pending_only=True)
assert len(page.items) == 1 and page.next_cursor
repo.list_session_messages.assert_awaited_once_with(
str(actor.user_id), "main-session", 2,
cursor_at=None, cursor_id=None, pending_only=True)
repo.list_session_messages.reset_mock()
repo.list_session_messages.return_value = []
await service.list_session("main-session", 1, page.next_cursor)
assert repo.list_session_messages.await_args.kwargs["cursor_id"] == sent.message_id
with pytest.raises(DomainError) as error:
await service.list_session("main-session", 20, "invalid")
assert error.value.code == "INVALID_CURSOR"
@pytest.mark.anyio
async def test_pending_dispatch_page_returns_forward_keyset_cursor():
repo, actor, service, trusted, command = setup()
first = await service.create(command)
second = await service.create(command.model_copy(update={"request_id": uuid4()}))
repo.list_pending_messages = AsyncMock(return_value=[first.model_dump(), second.model_dump()])
page = await trusted.pending(1)
assert page.items[0].message_id == first.message_id and page.next_cursor
await trusted.pending(1, page.next_cursor)
assert repo.list_pending_messages.await_args.kwargs == {
"cursor_at": first.created_at, "cursor_id": first.message_id}
@pytest.mark.anyio
async def test_requeue_locks_and_rechecks_run_before_cancelling_it():
repo, actor, service, trusted, command = setup()
run = repo.run.copy()
replacement = {**run, "run_id": str(uuid4())}
repo.get_active_run = AsyncMock(side_effect=[run, replacement, replacement])
repo.lock = AsyncMock()
repo.update_run, repo.insert_run, repo.fail_run_messages = AsyncMock(), AsyncMock(), AsyncMock()
task_service = OperationalTaskService(repo, actor, [], clock=lambda: NOW)
task_service._run_event = AsyncMock()
task = {"task_id": str(repo.task_id), "execution_generation": 1, "revision": 2}
await task_service._requeue_task(task, NOW)
assert [call.args[0] for call in repo.lock.await_args_list] == [
f"run:{run['run_id']}", f"run:{replacement['run_id']}"]
assert repo.update_run.await_args.args[0]["run_id"] == replacement["run_id"]
repo.fail_run_messages.assert_awaited_once_with(replacement["run_id"], "Run superseded by requeue", NOW)
@pytest.mark.anyio
async def test_finish_refuses_messages_enqueued_but_not_entered_in_model_context():
repo, actor, service, trusted, command = setup()
repo.get_request = AsyncMock(return_value=None)
repo.pending_run_messages = AsyncMock(return_value=["pending-message"])
repo.get_latest_report = AsyncMock()
task_service = OperationalTaskService(repo, actor, [], clock=lambda: NOW)
task_service._task = AsyncMock(return_value={"task_type": "event_investigation"})
with pytest.raises(DomainError) as error:
await task_service.complete_run(repo.task_id, repo.run_id, AgentRunCommand(
request_id=uuid4(), expected_revision=2, source_session_id="task-session"))
assert error.value.code == "PENDING_AGENT_MESSAGES" and error.value.status == 409
assert error.value.refs == ["pending-message"]
repo.get_latest_report.assert_not_awaited()
+35 -2
View File
@@ -38,10 +38,10 @@ def test_valve_close_analysis_uses_normalized_scheme_type(monkeypatch):
monkeypatch.setattr(
simulation_scenarios.simulation,
"run_simulation",
lambda **kwargs: captured.update(kwargs),
lambda **kwargs: (captured.update(kwargs), {"node_results": []})[1],
)
simulation_scenarios.valve_close_analysis.__wrapped__(
saved_result = simulation_scenarios.valve_close_analysis.__wrapped__(
name="demo",
modify_pattern_start_time="2026-01-01T00:00:00+08:00",
modify_valve_opening={"V1": 0.0},
@@ -51,6 +51,13 @@ def test_valve_close_analysis_uses_normalized_scheme_type(monkeypatch):
assert captured["scheme_type"] == "valve_close_analysis"
assert captured["result_db_name"] == "demo"
assert saved_result is None # Preserve the existing REST "success" response.
preview = simulation_scenarios.valve_close_analysis.__wrapped__(
name="demo", modify_pattern_start_time="2026-01-01T00:00:00+08:00",
_temporary_project="tjw_tmp_demo", persist_results=False,
)
assert preview == {"node_results": []}
assert captured["persist_results"] is False
def test_apply_valve_control_matches_runner_semantics(monkeypatch):
@@ -208,6 +215,32 @@ def test_extended_simulation_stores_results_by_run_id(monkeypatch):
assert transaction_calls == [("begin", "demo"), ("end", "demo")]
refresh_mock.assert_called_once_with("demo")
preview = simulation.run_simulation(
name="tjw_tmp_preview_test", simulation_type="extended",
modify_pattern_start_time="2026-07-16T00:00:00+08:00",
modify_total_duration=900, persist_results=False,
scada_mappings=_empty_scada_mappings(simulation),
)
assert preview["node_results"][0]["node"] == "J1"
assert len(storage_calls) == len(create_calls) == 1
assert len(lifecycle_calls) == 1
with pytest.raises(ValueError, match="isolated temporary model"):
simulation.run_simulation(name="demo", simulation_type="extended",
modify_pattern_start_time="2026-07-16T00:00:00+08:00", persist_results=False)
realtime_storage = Mock()
monkeypatch.setattr(simulation.TimescaleInternalStorage, "store_realtime_simulation", realtime_storage)
realtime = simulation.run_simulation(
name="demo", simulation_type="realtime",
modify_pattern_start_time="2026-07-16T00:00:00+08:00",
scada_mappings=_empty_scada_mappings(simulation),
)
realtime_storage.assert_called_once_with(
realtime["node_results"], realtime["link_results"],
"2026-07-16T00:00:00+08:00", db_name="demo",
)
assert len(storage_calls) == len(create_calls) == len(lifecycle_calls) == 1
def test_extended_simulation_marks_run_failed_when_result_storage_fails(monkeypatch):
from app.services import simulation
+70
View File
@@ -0,0 +1,70 @@
import ctypes
import platform
from pathlib import Path
import pytest
from app.native.wndb.model import status
def test_epanet2_preserves_valve_setting_without_emitting_active(monkeypatch):
monkeypatch.setattr(status, "read_all", lambda *_: [
{"link": "V1", "status": "ACTIVE", "setting": None},
{"link": "V2", "status": "ACTIVE", "setting": 2.5},
{"link": "V3", "status": "CLOSED", "setting": None},
{"link": "P1", "status": "OPEN", "setting": 0},
])
assert status.inp_out_status("model") == ["V2 2.5", "V3 CLOSED", "P1 OPEN", "P1 0"]
def test_epanet3_retains_explicit_active_status(monkeypatch):
monkeypatch.setattr(status, "read_all", lambda *_: [
{"link": "V1", "status": "ACTIVE", "setting": 100},
])
assert status.inp_out_status("model", version="3") == ["V1 ACTIVE", "V1 100"]
@pytest.mark.parametrize("version", ["2", "3"])
def test_file_and_text_exporters_forward_the_selected_format(monkeypatch, tmp_path, version):
from app.native.wndb.inp import exporter
monkeypatch.setattr(exporter, "have_project", lambda _: True)
monkeypatch.setattr(exporter, "section_name", [exporter.STATUS])
monkeypatch.setattr(exporter, "section_names_for_epanetv2", [exporter.STATUS])
monkeypatch.setattr(status, "read_all", lambda *_: [
{"link": "V1", "status": "ACTIVE", "setting": 100},
])
path = tmp_path / "model.inp"
exporter.dump_inp("model", str(path), version)
text = exporter.export_inp("model", version).operations[0]["inp"]
assert "V1 100" in path.read_text() and "V1 100" in text
assert ("V1 ACTIVE" in path.read_text()) == (version == "3")
assert ("V1 ACTIVE" in text) == (version == "3")
def test_exported_active_valve_passes_bundled_epanet2_input_validation(monkeypatch, tmp_path):
library = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux/libepanet2.so"
if platform.system() != "Linux" or not library.exists():
pytest.skip("Bundled Linux EPANET library is unavailable")
monkeypatch.setattr(status, "read_all", lambda *_: [
{"link": "551711", "status": "ACTIVE", "setting": None},
])
inp = tmp_path / "valve.inp"
inp.write_text(
"[JUNCTIONS]\nJ1 0 1\nJ2 0 1\n[RESERVOIRS]\nR1 100\n"
"[PIPES]\nP1 R1 J1 100 300 100 0 OPEN\n"
"[VALVES]\n551711 J1 J2 600 TCV 100 0\n[STATUS]\n"
+ "\n".join(status.inp_out_status("model"))
+ "\n[OPTIONS]\nUNITS LPS\n[END]\n"
)
lib = ctypes.CDLL(str(library))
lib.EN_createproject.argtypes = [ctypes.POINTER(ctypes.c_void_p)]
lib.EN_open.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p]
lib.EN_deleteproject.argtypes = [ctypes.c_void_p]
handle = ctypes.c_void_p()
assert lib.EN_createproject(ctypes.byref(handle)) == 0
try:
code = lib.EN_open(handle, str(inp).encode(), str(tmp_path / "valve.rpt").encode(), b"")
assert code == 0
finally:
lib.EN_deleteproject(handle)
+5 -5
View File
@@ -30,7 +30,7 @@ class FakeClient:
def test_geocode_tianditu_gets_expected_params(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "tk-test")
monkeypatch.setattr(
geocoding.settings,
"TIANDITU_GEOCODER_URL",
@@ -71,7 +71,7 @@ def test_geocode_tianditu_gets_expected_params(monkeypatch):
def test_geocode_tianditu_accepts_key_word_alias(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "tk-test")
response = httpx.Response(
200,
json={"location": {"lon": "116", "lat": "39"}, "status": "0", "msg": "ok"},
@@ -89,7 +89,7 @@ def test_geocode_tianditu_accepts_key_word_alias(monkeypatch):
def test_geocode_tianditu_requires_token(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "")
monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "")
with pytest.raises(geocoding.TiandituGeocodingConfigError):
asyncio.run(
@@ -101,7 +101,7 @@ def test_geocode_tianditu_requires_token(monkeypatch):
def test_geocode_tianditu_surfaces_http_error(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "tk-test")
response = httpx.Response(
403,
json={"msg": "invalid tk"},
@@ -121,7 +121,7 @@ def test_geocode_tianditu_surfaces_http_error(monkeypatch):
def test_geocode_tianditu_surfaces_tianditu_error_status(monkeypatch):
monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test")
monkeypatch.setattr(geocoding.settings, "TIANDITU_TOKEN", "tk-test")
response = httpx.Response(
200,
json={"status": "100", "msg": "bad request"},
+479
View File
@@ -0,0 +1,479 @@
import asyncio
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.mcp import analysis_service, query_service
from app.mcp.catalog import TOOLS
from app.mcp.scope import QueryScope
from app.infra.db.project_routing import (
ActiveProjectRouting,
activate_project_routing,
get_active_project_routing,
get_project_pgconn_string,
get_project_timescale_pgconn_string,
)
HANDLE = "x" * 43
START = "2026-10-08T12:00:00+08:00"
END = "2026-10-08T13:00:00+08:00"
INPUTS = {
"run_simulation": {"start_time": START, "duration": 3600, "output_node_ids": ["J1"], "output_link_ids": ["L1"]},
"run_contaminant_simulation": {"start_time": START, "output_node_ids": ["J1"], "source": "J2", "concentration": 1.0},
"analyze_valve_closure": {"start_time": START, "output_node_ids": ["J1"], "valves": ["V1"]},
"analyze_flushing": {"start_time": START, "output_node_ids": ["J1"], "drainage_node_id": "J2", "flushing_flow_m3h": 10},
"analyze_pressure_regulation": {"start_time": START, "output_node_ids": ["J1"], "pump_control": {"P1": [1]}},
"optimize_sensor_placement": {"method": "kmeans", "sensor_count": 3},
"run_burst_detection": {"target_time": START, "sensor_nodes": ["1", "2", "3", "4", "5"]},
"run_burst_location": {"data_source": "simulation", "simulation_run_id": str(uuid4()), "burst_leakage": 0.02, "pressure_scada_ids": ["1"], "scada_burst_start": START, "scada_burst_end": END},
"analyze_valve_isolation": {"accident_element": ["540672"]},
"run_leakage_identification": {"scada_start": START, "scada_end": END, "sensor_nodes": ["1"], "q_sum": 0.02, "start_time": 12, "dma_count": 1},
}
def arguments(name, **extra):
return TOOLS[name].model.model_validate({"context_handle": HANDLE, **INPUTS[name], **extra})
@pytest.fixture
def routed_calculation_scope(monkeypatch):
events = []
project = SimpleNamespace(project_id=uuid4(), project_code="logical-project")
business = "host=localhost dbname=physical_business"
timescale = "host=localhost dbname=physical_iot"
async def resolve(project_id, role):
assert project_id == project.project_id
events.append(role)
return SimpleNamespace(
db_type="postgresql" if role == "biz_data" else "timescaledb",
dsn=business if role == "biz_data" else timescale,
)
async def commit():
events.append("metadata_commit")
async def release_business_read():
events.append("business_commit")
metadata = SimpleNamespace(
get_project_db_routing=AsyncMock(side_effect=resolve),
session=SimpleNamespace(commit=AsyncMock(side_effect=commit)),
)
routing = ActiveProjectRouting(project.project_code, business, project_id=project.project_id)
scope = QueryScope(
project, SimpleNamespace(username="actor"),
SimpleNamespace(commit=release_business_read), routing, metadata,
)
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
return scope, timescale, events
@pytest.mark.parametrize("name", [
"run_simulation", "run_contaminant_simulation", "analyze_valve_closure",
"analyze_flushing", "analyze_pressure_regulation", "run_burst_detection",
"run_burst_location", "run_leakage_identification",
])
def test_timescale_calculation_routes_are_ready_before_native_worker(monkeypatch, routed_calculation_scope, name):
scope, timescale, events = routed_calculation_scope
def worker(tool_name, parameters, network, username):
assert tool_name == name
assert network == scope.project.project_code
assert username == "actor"
assert "context_handle" not in parameters
assert "timeout_seconds" not in parameters
assert get_project_timescale_pgconn_string(network) == timescale
assert get_project_pgconn_string(network) == scope.routing.business_dsn
assert events == ["business_commit", "biz_data", "iot_data", "metadata_commit"]
events.append("worker")
return {"routed": True}
monkeypatch.setattr(analysis_service, "_calculate", worker)
async def execute():
result = await query_service.execute_query(scope, object(), TOOLS[name], arguments(name))
assert get_active_project_routing() is scope.routing
return result
with activate_project_routing(scope.routing):
result = asyncio.run(execute())
assert result.data["result"] == {"routed": True}
assert events[-1] == "worker"
def test_missing_timescale_route_stops_online_simulation_before_model_changes(monkeypatch, routed_calculation_scope):
scope, _, _ = routed_calculation_scope
resolve = scope.metadata.get_project_db_routing.side_effect
async def missing_iot(project_id, role):
return None if role == "iot_data" else await resolve(project_id, role)
scope.metadata.get_project_db_routing.side_effect = missing_iot
calculate = AsyncMock(return_value={})
monkeypatch.setattr(analysis_service, "calculate", calculate)
with activate_project_routing(scope.routing):
with pytest.raises(HTTPException) as denied:
asyncio.run(query_service.execute_query(scope, object(), TOOLS["run_simulation"], arguments("run_simulation")))
assert get_active_project_routing() is scope.routing
assert denied.value.status_code == 503
assert denied.value.detail == "Project TimescaleDB not configured"
calculate.assert_not_awaited()
@pytest.mark.parametrize("name", ["analyze_valve_isolation", "optimize_sensor_placement"])
def test_business_only_calculations_do_not_require_timescale(monkeypatch, routed_calculation_scope, name):
scope, _, events = routed_calculation_scope
def worker(*_):
assert get_active_project_routing() is scope.routing
assert get_project_pgconn_string(scope.project.project_code) == scope.routing.business_dsn
return {"business_only": True}
monkeypatch.setattr(analysis_service, "_calculate", worker)
with activate_project_routing(scope.routing):
result = asyncio.run(query_service.execute_query(scope, object(), TOOLS[name], arguments(name)))
assert result.data["result"] == {"business_only": True}
assert events == ["business_commit"]
scope.metadata.get_project_db_routing.assert_not_awaited()
def test_calculation_failure_restores_business_routing(monkeypatch, routed_calculation_scope):
scope, timescale, _ = routed_calculation_scope
def worker(*_):
assert get_project_timescale_pgconn_string(scope.project.project_code) == timescale
raise RuntimeError("native calculation failed")
monkeypatch.setattr(analysis_service, "_calculate", worker)
async def execute():
with pytest.raises(RuntimeError, match="native calculation failed"):
await query_service.execute_query(scope, object(), TOOLS["run_simulation"], arguments("run_simulation"))
assert get_active_project_routing() is scope.routing
with activate_project_routing(scope.routing):
asyncio.run(execute())
def test_denied_calculation_never_loads_timescale_or_starts_worker(monkeypatch, routed_calculation_scope):
scope, _, events = routed_calculation_scope
monkeypatch.setattr(query_service, "authorize_query", AsyncMock(side_effect=HTTPException(403, "denied")))
calculate = AsyncMock(return_value={})
monkeypatch.setattr(analysis_service, "calculate", calculate)
with pytest.raises(HTTPException) as denied:
asyncio.run(query_service.execute_query(scope, object(), TOOLS["run_simulation"], arguments("run_simulation")))
assert denied.value.status_code == 403
assert events == []
scope.metadata.get_project_db_routing.assert_not_awaited()
calculate.assert_not_awaited()
@pytest.mark.parametrize("name", INPUTS)
def test_catalog_and_snapshots_accept_calculations_but_reject_model_owned_authority(name):
tool = TOOLS[name]
assert tool.computation
assert arguments(name).timeout_seconds == 120
assert tool.protocol_tool().input_schema["additionalProperties"] is False
for field in ("network", "username", "scheme_name", "final_candidates_csv_path", "observed_pressure_data", "persist_results", "simulation_type"):
with pytest.raises(ValidationError):
arguments(name, **{field: "untrusted"})
snapshot = TaskDataSnapshot(source=name, source_ref="mcp:test", queried_at=datetime.now(timezone.utc), origin="system_data", data={}, truncated=False)
assert snapshot.source == name
@pytest.mark.parametrize("name", INPUTS)
def test_calculation_permission_and_reader_boundary(monkeypatch, name):
permissions = {"burst.run", "simulation.run", "simulation.view", "scada.view", "optimization.run"}
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
binding = SimpleNamespace(tool_cap=list(TOOLS), purpose="task_execution", permission_cap=list(permissions), task_id=uuid4(), task_scope_cap=["scada_readings"])
tool, args = TOOLS[name], arguments(name)
asyncio.run(query_service.authorize_query(scope, binding, tool, args))
binding.purpose = "task_reader"
with pytest.raises(HTTPException) as denied:
asyncio.run(query_service.authorize_query(scope, binding, tool, args))
assert denied.value.status_code == 403
binding.purpose = "task_execution"
binding.permission_cap.remove(tool.permission)
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(scope, binding, tool, args))
binding.permission_cap = list(permissions)
permissions.remove(tool.permission)
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(scope, binding, tool, args))
def test_time_sources_and_finite_inputs_are_explicit():
invalid = [
("run_simulation", {"start_time": "2026-10-08T12:00:00"}),
("run_simulation", {"duration": 0}),
("run_simulation", {"duration": 86401}),
("run_simulation", {"timeout_seconds": 0}),
("run_simulation", {"timeout_seconds": 86401}),
("run_simulation", {"timeout_seconds": 30.5}),
("run_simulation", {"timeout_seconds": True}),
("run_simulation", {"output_node_ids": []}),
("run_simulation", {"output_link_ids": [str(i) for i in range(21)]}),
("run_burst_detection", {"target_time": "2026-10-08T12:00:00"}),
("run_burst_detection", {"sensor_nodes": ["1"] * 5}),
("run_burst_location", {"burst_leakage": float("nan")}),
("run_burst_location", {"simulation_run_id": None}),
("run_burst_location", {"data_source": "monitoring"}),
("run_burst_location", {"scada_burst_end": "2026-10-10T12:00:00+08:00"}),
("run_leakage_identification", {"q_sum": 0}),
]
for name, changes in invalid:
with pytest.raises(ValidationError):
arguments(name, **changes)
args = arguments("run_burst_location", data_source="monitoring", simulation_run_id=None,
scada_normal_start="2026-10-07T12:00:00+08:00", scada_normal_end="2026-10-07T13:00:00+08:00")
assert args.data_source == "monitoring"
@pytest.mark.parametrize("overrides", [{}, {
"pressure_scada_ids": None, "scada_normal_start": None, "scada_normal_end": None,
}, {
"pressure_scada_ids": ["J2"],
"scada_normal_start": "2026-10-06T12:00:00+08:00",
"scada_normal_end": "2026-10-06T13:00:00+08:00",
}])
@pytest.mark.parametrize("missing_normal", [False, True])
def test_burst_location_mcp_reuses_project_sensors_and_normal_window_defaults(monkeypatch, tmp_path, overrides, missing_normal):
from app.services import burst_location
args = TOOLS["run_burst_location"].model.model_validate({
"context_handle": HANDLE, "burst_leakage": 0.0274,
"scada_burst_start": START, "scada_burst_end": END, **overrides,
})
devices = [{"device_type": "pressure", "node_id": node, "api_query_id": f"q-{node}"}
for node in ("J1", "J2")]
monkeypatch.setattr(burst_location, "get_all_scada_info", lambda network: devices)
queries = []
def read_scada(**kwargs):
queries.append(kwargs)
if missing_normal and len(queries) == 2:
return {}
return {device: [{"value": 20.0}] for device in kwargs["device_ids"]}
monkeypatch.setattr(burst_location.InternalQueries, "query_scada_by_ids_timerange", read_scada)
@contextmanager
def inp(network, *, purpose):
yield tmp_path / "model.inp"
monkeypatch.setattr(burst_location, "temporary_project_inp", inp)
algorithm = []
def locate(**kwargs):
algorithm.append(kwargs)
return {"located_pipe": "candidate"}
monkeypatch.setattr(burst_location, "run_burst_location", locate)
if missing_normal:
with pytest.raises(ValueError, match="正常压力数据.*无有效数据"):
asyncio.run(analysis_service.calculate(
"run_burst_location", args, network="trusted-project", username="trusted-user",
))
assert algorithm == []
return
result = asyncio.run(analysis_service.calculate(
"run_burst_location", args, network="trusted-project", username="trusted-user",
))
expected_ids = overrides.get("pressure_scada_ids") or ["J1", "J2"]
assert result["pressure_scada_ids"] == expected_ids
assert [q["device_ids"] for q in queries] == [[f"q-{node}" for node in expected_ids]] * 2
normal_start = overrides.get("scada_normal_start") or "2026-10-07T12:00:00+08:00"
normal_end = overrides.get("scada_normal_end") or "2026-10-07T13:00:00+08:00"
assert datetime.fromisoformat(result["scada_window"]["normal_start"]) == datetime.fromisoformat(normal_start)
assert datetime.fromisoformat(result["scada_window"]["normal_end"]) == datetime.fromisoformat(normal_end)
assert algorithm[0]["pressure_scada_ids"] == expected_ids
assert algorithm[0]["final_candidates_csv_path"] is None
@pytest.mark.parametrize("overrides", [
{"scada_normal_start": "2026-10-07T12:00:00+08:00"},
{"scada_normal_end": "2026-10-07T13:00:00+08:00"},
{"scada_normal_start": "2026-10-07T12:00:00", "scada_normal_end": "2026-10-07T13:00:00"},
{"pressure_scada_ids": []}, {"pressure_scada_ids": ["J1", "J1"]},
{"pressure_scada_ids": [str(i) for i in range(21)]},
])
def test_burst_location_optional_inputs_still_reject_partial_or_invalid_values(overrides):
with pytest.raises(ValidationError):
TOOLS["run_burst_location"].model.model_validate({
"context_handle": HANDLE, "burst_leakage": 0.0274,
"scada_burst_start": START, "scada_burst_end": END, **overrides,
})
@pytest.mark.parametrize("name", INPUTS)
def test_calculations_accept_agent_execution_budget_separately_from_duration(name):
args = arguments(name, timeout_seconds=900)
assert args.timeout_seconds == 900
if hasattr(args, "duration"):
assert args.duration == INPUTS[name].get("duration", 900)
@pytest.mark.parametrize("name,module,function", [
("run_burst_detection", "app.services.burst_detection", "run_burst_detection"),
("run_burst_location", "app.services.burst_location", "run_burst_location_by_network"),
("analyze_valve_isolation", "app.services.valve_isolation", "analyze_valve_isolation"),
("run_leakage_identification", "app.services.dma_leakage_estimation", "run_leakage_identification"),
])
def test_adapter_uses_existing_service_without_scheme_or_shared_output(monkeypatch, name, module, function):
calls = []
def calculate(**kwargs):
calls.append(kwargs)
return {"ok": True}
monkeypatch.setattr(f"{module}.{function}", calculate)
result = asyncio.run(analysis_service.calculate(name, arguments(name), network="trusted-project", username="trusted-user"))
assert result == {"ok": True}
call = calls[0]
assert call["network"] == "trusted-project"
assert "context_handle" not in call
if name != "analyze_valve_isolation":
assert call["username"] == "trusted-user"
assert call["scheme_name"] is None
if name == "run_burst_location":
assert call["final_candidates_csv_path"] is None
if name == "run_leakage_identification":
assert call["duration"] == 1
assert call["n_workers"] == 1
@pytest.mark.parametrize("name", ["run_burst_location", "run_simulation"])
def test_dispatch_ends_read_transaction_and_preserves_analysis_provenance(monkeypatch, name):
order = []
async def commit():
order.append("commit")
async def calculate(*args, **kwargs):
order.append("calculate")
assert kwargs == {"network": "trusted-project", "username": "trusted-user"}
return {"located_pipe": "candidate"}
@asynccontextmanager
async def simulation_routing():
yield
monkeypatch.setattr(query_service, "authorize_query", AsyncMock())
monkeypatch.setattr(analysis_service, "calculate", calculate)
scope = SimpleNamespace(connection=SimpleNamespace(commit=commit), project=SimpleNamespace(project_code="trusted-project"), user=SimpleNamespace(username="trusted-user"), simulation_routing=simulation_routing)
result = asyncio.run(query_service.execute_query(scope, object(), TOOLS[name], arguments(name)))
assert order == ["commit", "calculate"]
assert result.semantics == "analysis"
if name == "run_burst_location":
assert result.data["parameters"]["burst_leakage"] == 0.02
else:
assert result.data["parameters"]["duration"] == 3600
assert result.source == "run_simulation"
assert "context_handle" not in result.data["parameters"]
assert result.data["result"]["located_pipe"] == "candidate"
assert "not observed SCADA" in result.data_gaps[-1]
def test_busy_calculation_does_not_start_another_worker(monkeypatch):
from threading import BoundedSemaphore
slots = BoundedSemaphore(1)
slots.acquire()
monkeypatch.setattr(analysis_service, "_slots", slots)
with pytest.raises(HTTPException) as denied:
analysis_service._calculate("analyze_valve_isolation", {}, "project", "user")
assert denied.value.status_code == 503
@pytest.mark.parametrize("name,function", [
("run_contaminant_simulation", "contaminant_simulation"),
("analyze_valve_closure", "valve_close_analysis"),
("analyze_flushing", "flushing_analysis"),
("analyze_pressure_regulation", "pressure_regulation"),
])
def test_scenario_adapters_are_nonpersistent_and_return_only_requested_elements(monkeypatch, name, function):
from app.mcp.scenario_service import calculate_scenario
calls = []
def scenario(**kwargs):
calls.append(kwargs)
return {"node_results": [{"node": "J1", "result": []}, {"node": "J2", "result": []}], "link_results": [{"link": "L1", "result": []}]}
monkeypatch.setattr(f"app.services.simulation_scenarios.{function}", scenario)
monkeypatch.setattr("app.native.wndb.model.options.get_option", lambda _: {"UNITS": "CMH"})
monkeypatch.setattr("app.services.simulation.query_corresponding_element_id_and_query_id", lambda _: SimpleNamespace(fixed_pumps={"P1": "S1"}, variable_pumps={}))
result = calculate_scenario(name, arguments(name).model_dump(exclude={"context_handle"}, exclude_none=True), network="trusted", username="actor")
assert calls[0]["name"] == "trusted"
assert calls[0]["persist_results"] is False
assert calls[0]["scheme_name"] is None
assert result["node_results"] == [{"node": "J1", "result": []}]
assert result["link_results"] == []
assert result["coverage"]["missing_nodes"] == []
if name == "analyze_flushing":
assert calls[0]["flushing_flow"] == 10
def test_sensor_preview_validates_but_does_not_store(monkeypatch):
from app.services import sensor_placement as service
from unittest.mock import Mock
validate = Mock()
save = Mock()
monkeypatch.setattr(service, "validate_sensor_placement_nodes", validate)
monkeypatch.setattr(service, "_sensor_points", lambda *_: [{"node_id": "J1"}])
monkeypatch.setattr(service.sensor_placement_repository, "create_sensor_placement", save)
result = service._create_validated_placement("project", run_name="preview", min_diameter=0,
created_by="actor", sensor_locations=["J1"], persist_results=False)
validate.assert_called_once_with("project", ["J1"])
save.assert_not_called()
assert result["sensor_locations"] == ["J1"]
@pytest.mark.parametrize("name", ["run_contaminant_simulation", "run_simulation"])
def test_scenario_also_requires_scada_permission_even_with_task_read_scope(monkeypatch, name):
permissions = {"simulation.run"}
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
binding = SimpleNamespace(tool_cap=list(TOOLS), purpose="task_execution", permission_cap=list(permissions), task_id=uuid4(), task_scope_cap=["scada_readings"])
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), binding, TOOLS[name], arguments(name)))
@pytest.mark.parametrize("fails", [False, True])
def test_standard_simulation_uses_online_service_and_propagates_failure(monkeypatch, fails):
from app.mcp.scenario_service import calculate_scenario
events = []
def temporary_project(*_):
pytest.fail("Online calculation must not copy a database")
def simulate(network, start, duration, **kwargs):
assert network == "trusted-project"
assert start.isoformat() == START
assert duration == 3600
assert kwargs == {"output_node_ids": ["J1"], "output_link_ids": ["L1"]}
events.append("calculate")
if fails:
raise RuntimeError("calculation failed")
return {"node_results": [{"node": "J1"}, {"node": "J2"}],
"link_results": [{"link": "L1"}, {"link": "L2"}],
"times": {"report_step": 900}, "units": {"pressure": "m", "flow": "LPS"}}
monkeypatch.setattr("app.native.wndb.core.projects.temporary_project_database", temporary_project)
monkeypatch.setattr("app.services.simulation.run_online_simulation", simulate)
def execute():
return calculate_scenario("run_simulation", arguments("run_simulation").model_dump(
exclude={"context_handle"}), network="trusted-project", username="actor")
if fails:
with pytest.raises(RuntimeError, match="calculation failed"):
execute()
else:
result = execute()
assert result["node_results"] == [{"node": "J1"}]
assert result["link_results"] == [{"link": "L1"}]
assert result["duration_seconds"] == 3600
assert result["persisted_scheme"] is False
assert result["persisted_baseline"] is True
assert result["units"]["pressure"] == "m"
assert events == ["calculate"]
+53
View File
@@ -0,0 +1,53 @@
import pytest
from pydantic import ValidationError
from app.mcp.config import McpSettings
ISSUER = "http://192.168.1.114:8088/realms/tjwater"
def local_settings(**overrides):
return McpSettings(**{
"enabled": True,
"environment": "development",
"issuer_uri": ISSUER,
"dev_http_issuer_uri": ISSUER,
"resource_uri": "http://localhost/mcp",
"_env_file": None,
**overrides,
})
def test_explicit_private_development_issuer():
assert local_settings().issuer_uri == ISSUER
def test_calculation_limit_covers_default_without_extending_ordinary_queries():
config = local_settings(max_calculation_timeout_seconds=120)
assert config.query_timeout_seconds == 30
with pytest.raises(ValidationError):
local_settings(max_calculation_timeout_seconds=119)
@pytest.mark.parametrize("overrides", [
{"environment": "production"},
{"environment": "production", "enabled": False},
{"dev_http_issuer_uri": ""},
{"issuer_uri": ISSUER + "/other"},
{"resource_uri": "http://192.168.1.114:8088/mcp"},
{"issuer_uri": "http://example.com/realm", "dev_http_issuer_uri": "http://example.com/realm"},
{"issuer_uri": "http://8.8.8.8/realm", "dev_http_issuer_uri": "http://8.8.8.8/realm"},
{"issuer_uri": ISSUER + "?query=1", "dev_http_issuer_uri": ISSUER + "?query=1"},
])
def test_rejects_unapproved_http_issuer_or_resource(overrides):
with pytest.raises(ValidationError):
local_settings(**overrides)
def test_development_mode_can_be_injected_from_environment(monkeypatch):
monkeypatch.setenv("ENVIRONMENT", "development")
monkeypatch.setenv("TJWATER_MCP_DEV_HTTP_ISSUER_URI", ISSUER)
monkeypatch.setenv("TJWATER_MCP_ISSUER_URI", ISSUER)
config = McpSettings(enabled=True, resource_uri="http://localhost/mcp", _env_file=None)
assert config.environment == "development"
+900
View File
@@ -0,0 +1,900 @@
import asyncio
import base64
import time
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from uuid import uuid4
from unittest.mock import AsyncMock
import httpx
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from fastapi import HTTPException
from jose import jwt
from mcp import types
from mcp.server.auth.provider import AccessToken
from starlette.applications import Starlette
from starlette.testclient import TestClient
from app.mcp.application import McpQueryApplication
from app.mcp.auth import McpTokenVerifier
from app.mcp.config import McpSettings
from app.mcp.catalog import TOOLS
from app.mcp.models import BindInput, BoundContext, QueryResult, ScadaInput
from app.mcp import application, contexts, query_service
PROJECT = uuid4()
SUBJECT = str(uuid4())
PERMISSIONS = {
"webgis.view",
"simulation.view",
"scada.view",
"operational_task.view",
"operational_task.execute",
"optimization.run",
}
def test_calculation_budget_outlives_query_default_and_still_times_out(runtime, monkeypatch):
app, client, _, _ = runtime
app.settings.query_timeout_seconds = 1
monkeypatch.setattr(application, "authorize_query", AsyncMock())
async def execute(scope, bound, tool, args):
await asyncio.sleep(1.05)
return QueryResult(
source=tool.name, source_ref="test:calculation", semantics="analysis",
queried_at=datetime.now(timezone.utc), data={"completed": True},
)
app.executor = execute
handle = binding().context_handle
arguments = {
"context_handle": handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"], "timeout_seconds": 2,
}
response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json()
assert response["result"].get("isError") is not True
assert response["result"]["structuredContent"]["data"] == {"completed": True}
arguments["timeout_seconds"] = 1
response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json()
assert response["result"]["isError"] is True
assert response["result"]["structuredContent"]["error"]["code"] == "QUERY_TIMEOUT"
def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime):
app, client, calls, _ = runtime
app.settings.max_calculation_timeout_seconds = 120
tools = request(client).json()["result"]["tools"]
schema = next(tool["inputSchema"] for tool in tools if tool["name"] == "run_simulation")
assert schema["properties"]["timeout_seconds"]["maximum"] == 120
assert schema["properties"]["timeout_seconds"]["default"] == 120
arguments = {
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"], "timeout_seconds": 121,
}
response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json()
assert "error" in response or response["result"]["isError"] is True
assert calls == []
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch):
app, client, calls, _ = runtime
monkeypatch.setattr(application, "authorize_query", AsyncMock())
schema = next(tool["inputSchema"] for tool in request(client).json()["result"]["tools"]
if tool["name"] == "run_burst_location")
assert "pressure_scada_ids" not in schema["required"]
response = request(client, "tools/call", {"name": "run_burst_location", "arguments": {
"context_handle": binding().context_handle, "burst_leakage": 0.0274,
"scada_burst_start": "2026-10-08T12:00:00+08:00",
"scada_burst_end": "2026-10-08T13:00:00+08:00",
}}).json()
assert response["result"].get("isError") is not True
assert calls == [("session-1", "run_burst_location")]
def settings():
return McpSettings(
enabled=True,
issuer_uri="http://localhost/realms/water",
resource_uri="http://localhost/mcp",
allowed_hosts=["testserver", "localhost:*"],
_env_file=None,
)
def principal(subject=SUBJECT, scopes=None, client="tjwater-agent-mcp"):
return AccessToken(
token="fake",
subject=subject,
client_id=client,
scopes=scopes
if scopes is not None
else ["mcp.catalog.read", "mcp.query", "mcp.context.bind"],
resource="http://localhost/mcp",
claims={"sub": subject},
)
class Verifier:
def __init__(self):
self.identities = {
"user": principal(),
"other": principal(str(uuid4())),
"catalog": principal(scopes=["mcp.catalog.read"]),
}
async def verify_token(self, token):
return self.identities.get(token)
class Connection:
@asynccontextmanager
async def transaction(self):
yield
class Repository:
bindings = {}
receipts = {}
def __init__(self, conn, project):
self.project = project
async def create(self, binding):
self.bindings[binding.context_handle] = binding.model_copy(deep=True)
async def get(self, handle):
return self.bindings.get(handle)
async def revoke(self, handle, subject, client):
binding = self.bindings.get(handle)
if (
binding
and binding.subject == subject
and binding.client_id == client
and binding.project_id == self.project
):
binding.revoked_at = datetime.now(timezone.utc)
async def receipt(self, key, call):
return self.receipts.get((self.project, key, call))
async def save_receipt(self, key, call, parameters, result):
self.receipts[(self.project, key, call)] = {
"parameters": parameters,
"result": result,
}
async def lock_receipt(self, key, call):
pass
@pytest.fixture
def runtime(monkeypatch):
Repository.bindings = {}
Repository.receipts = {}
monkeypatch.setattr(application, "McpQueryRepository", Repository)
monkeypatch.setattr(contexts, "McpQueryRepository", Repository)
monkeypatch.setattr(contexts, "permissions_for_context", lambda ctx: PERMISSIONS)
monkeypatch.setattr(
query_service, "permissions_for_context", lambda ctx: PERMISSIONS
)
@asynccontextmanager
async def scope_factory(identity, project):
try:
project_id = __import__("uuid").UUID(project)
except ValueError:
raise HTTPException(400, "Invalid project")
yield SimpleNamespace(
project=SimpleNamespace(project_id=project_id), connection=Connection()
)
calls = []
async def execute(scope, binding, tool, args):
calls.append((binding.native_session_id, tool.name))
return QueryResult(
source=tool.name,
source_ref="source:1",
queried_at=datetime.now(timezone.utc),
semantics=tool.semantics,
data=[{"id": "P-1"}],
)
verifier = Verifier()
runtime = McpQueryApplication(
settings(), verifier=verifier, scope_factory=scope_factory, executor=execute
)
@asynccontextmanager
async def lifespan(app):
async with runtime.lifespan():
yield
host = Starlette(lifespan=lifespan)
# Lifespan belongs to host; HTTP is delegated to SDK without mounting path prefixes.
class App:
async def __call__(self, scope, receive, send):
if scope["type"] == "lifespan":
await host(scope, receive, send)
else:
await runtime.asgi(scope, receive, send)
with TestClient(App()) as client:
yield runtime, client, calls, verifier
def request(
client,
method="tools/list",
params=None,
token="user",
version="2026-07-28",
extra=None,
):
meta = {
types.PROTOCOL_VERSION_META_KEY: version,
types.CLIENT_CAPABILITIES_META_KEY: {},
types.CLIENT_INFO_META_KEY: {"name": "test", "version": "1"},
"io.tjwater/callId": "call-1",
}
body = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": {"_meta": meta, **(params or {})},
}
headers = {
"Authorization": "Bearer " + token,
"MCP-Protocol-Version": version,
"MCP-Method": method,
"Accept": "application/json, text/event-stream",
"X-Project-Id": str(PROJECT),
}
if method == "tools/call":
headers["MCP-Name"] = params["name"]
headers.update(extra or {})
return client.post("/mcp", json=body, headers=headers)
def binding(task=False):
value = BoundContext(
context_handle="a" * 43,
project_id=PROJECT,
subject=SUBJECT,
client_id="tjwater-agent-mcp",
native_session_id="session-1",
purpose="task_execution" if task else "ordinary_chat",
tool_cap=sorted(TOOLS),
permission_cap=sorted(PERMISSIONS),
task_id=uuid4() if task else None,
run_id=uuid4() if task else None,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
)
Repository.bindings[value.context_handle] = value
return value
def test_modern_discovery_catalog_and_metadata(runtime):
_, client, _, _ = runtime
result = request(client, "server/discover").json()
assert result["result"]["resultType"] == "complete"
assert "2026-07-28" in result["result"]["supportedVersions"]
listed = request(client).json()["result"]
assert len(listed["tools"]) == 35
for tool in listed["tools"]:
timeout = tool["inputSchema"]["properties"].get("timeout_seconds")
assert (timeout is not None) == TOOLS[tool["name"]].computation
if timeout:
assert timeout["default"] == 120
assert timeout["maximum"] == 3600
assert listed["ttlMs"] == 300000 and listed["cacheScope"] == "public"
assert all(
tool["inputSchema"]["additionalProperties"] is False for tool in listed["tools"]
)
assert (
client.get("/.well-known/oauth-protected-resource/mcp").json()["resource"]
== "http://localhost/mcp"
)
assert "mcp-session-id" not in request(client).headers
@pytest.mark.parametrize("token,status", [("bad", 401), ("catalog", 403)])
def test_oauth_challenges(runtime, token, status):
_, client, _, _ = runtime
result = request(
client, "tools/call", {"name": "list_elements", "arguments": {}}, token=token
)
assert result.status_code == status
assert "resource_metadata=" in result.headers["www-authenticate"]
assert "mcp.query" in result.headers["www-authenticate"]
def test_rejects_legacy_and_mirror_mismatch(runtime):
_, client, _, _ = runtime
assert request(client, version="2025-03-26").json()["error"]["code"] == -32022
assert client.get("/mcp").status_code == 405
assert client.delete("/mcp").status_code == 405
mismatch = request(client, extra={"MCP-Method": "resources/list"})
assert mismatch.status_code == 400 and mismatch.json()["error"]["code"] == -32020
unknown = request(client, "tools/call", {"name": "missing", "arguments": {}})
assert unknown.json()["error"]["code"] == -32602
@pytest.mark.parametrize("header,body", [
("2025-11-25", "2026-07-28"),
("1900-01-01", "2026-07-28"),
("2026-07-28", "2025-11-25"),
])
def test_version_header_mismatch_precedes_version_support(runtime, header, body):
_, client, calls, _ = runtime
response = request(client, version=body, extra={"MCP-Protocol-Version": header})
assert response.status_code == 400
assert response.json()["error"]["code"] == -32020
assert calls == []
def test_query_identity_project_expiry_and_permission_boundaries(runtime):
_, client, calls, _ = runtime
value = binding()
params = {
"name": "list_elements",
"arguments": {"context_handle": value.context_handle, "element_type": "pipe"},
}
assert request(client, "tools/call", params).json()["result"]["structuredContent"][
"data"
] == [{"id": "P-1"}]
assert len(calls) == 1
for token, extra in [("other", None), ("user", {"X-Project-Id": str(uuid4())})]:
assert request(client, "tools/call", params, token=token, extra=extra).json()[
"result"
]["isError"]
value.permission_cap = []
assert (
request(client, "tools/call", params).json()["result"]["structuredContent"][
"error"
]["code"]
== "QUERY_CONTEXT_DENIED"
)
value.permission_cap = sorted(PERMISSIONS)
value.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
assert request(client, "tools/call", params).json()["result"]["isError"]
assert len(calls) == 1
def test_host_only_binding_and_no_identity_arguments():
with pytest.raises(Exception):
BindInput(native_session_id="s", purpose="ordinary_chat", task_id=uuid4())
with pytest.raises(Exception):
BindInput(native_session_id="s", purpose="task_reader", task_id=uuid4())
with pytest.raises(Exception):
ScadaInput(
context_handle="x" * 43,
device_ids=["d"],
start="2026-10-01T00:00:00",
end="2026-10-02T00:00:00",
)
with pytest.raises(Exception):
TOOLS["list_elements"].model.model_validate(
{
"context_handle": "x" * 43,
"element_type": "pipe",
"project_id": str(PROJECT),
}
)
def test_small_page_defaults_and_invalid_deep_offset(runtime):
_, client, calls, _ = runtime
value = binding()
for name in ("list_elements", "list_analysis_runs", "query_scada_readings", "query_model_baseline_field"):
assert TOOLS[name].model.model_fields["limit"].default == 20
result = request(client, "tools/call", {
"name": "list_elements", "arguments": {
"context_handle": value.context_handle, "element_type": "pipe", "offset": 10001,
},
}).json()["result"]
assert result["isError"] and calls == []
def test_receipt_retry_checks_authorization_before_returning_cache(
runtime, monkeypatch
):
_, client, calls, _ = runtime
value = binding(task=True)
async def verify(scope, binding):
pass
monkeypatch.setattr(contexts, "verify_task", verify)
# Existing persisted receipt, not a second execution/evidence append.
import json
key = json.dumps(
[
value.subject,
value.client_id,
value.native_session_id,
str(value.task_id),
str(value.run_id),
str(value.execution_request_id),
],
separators=(",", ":"),
)
params = {
"tool": "list_elements",
"arguments": {"element_type": "pipe", "limit": 20, "offset": 0},
}
result = QueryResult(
source="list_elements",
source_ref="mcp:saved",
queried_at=datetime.now(timezone.utc),
semantics="asset",
data=[],
evidence_id="evidence:1",
).model_dump(mode="json")
Repository.receipts[(PROJECT, key, "call-1")] = {
"parameters": params,
"result": result,
}
query = {
"name": "list_elements",
"arguments": {"context_handle": value.context_handle, "element_type": "pipe"},
}
assert (
request(client, "tools/call", query).json()["result"]["structuredContent"][
"evidence_id"
]
== "evidence:1"
)
value.permission_cap = []
assert request(client, "tools/call", query).json()["result"]["isError"]
assert calls == []
def test_evidence_is_saved_before_return_and_call_conflicts_fail(runtime, monkeypatch):
_, client, calls, _ = runtime
value = binding(task=True)
saved = []
async def verify(scope, binding):
pass
monkeypatch.setattr(contexts, "verify_task", verify)
monkeypatch.setattr(application, "verify_task", verify)
class TaskService:
repo = SimpleNamespace(lock=lambda key: asyncio.sleep(0))
async def record_data_snapshot(self, task_id, snapshot):
saved.append(snapshot)
return SimpleNamespace(evidence_id=snapshot.source_ref), snapshot
monkeypatch.setattr(application, "task_service", lambda scope: TaskService())
query = {
"name": "list_elements",
"arguments": {"context_handle": value.context_handle, "element_type": "pipe"},
}
first = request(client, "tools/call", query).json()["result"]["structuredContent"]
second = request(client, "tools/call", query).json()["result"]["structuredContent"]
assert (
first == second
and first["evidence_id"] == saved[0].source_ref
and len(saved) == len(calls) == 1
)
query["arguments"]["element_type"] = "junction"
assert request(client, "tools/call", query).json()["result"]["isError"]
assert len(saved) == 1
def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch):
app, client, _, _ = runtime
value = binding(task=True)
monkeypatch.setattr(contexts, "verify_task", AsyncMock())
reader = AsyncMock(return_value={"task": {"workflow": "water_quality_complaint"}})
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
event_service = AsyncMock()
monkeypatch.setattr(application, "task_service", event_service)
app.executor = query_service.execute_query
response = request(client, "tools/call", {
"name": "get_task_context",
"arguments": {"context_handle": value.context_handle, "section": "skill"},
}).json()["result"]
assert response.get("isError") is not True
result = response["structuredContent"]
assert result["data"]["skill"]["name"] == "water-quality-complaint"
assert result["data"]["skill"]["instructions"] and not result["truncated"]
assert result["evidence_id"] is None
event_service.assert_not_called()
assert not Repository.receipts
def test_old_task_run_and_reader_parent_are_rejected(monkeypatch):
monkeypatch.setattr(contexts, "permissions_for_context", lambda ctx: PERMISSIONS)
value = binding(task=True)
value.parent_session_id = "parent"
run = {
"status": "running",
"agent_session_id": "wrong",
"run_id": str(value.run_id),
"execution_request_id": str(uuid4()),
"lease_expires_at": datetime.now(timezone.utc) + timedelta(minutes=1),
}
class Tasks:
async def get_task(self, id):
return {"task_id": id}
async def get_active_run(self, id):
return run
monkeypatch.setattr(contexts, "task_repository", lambda scope: Tasks())
scope = SimpleNamespace(project=object(), connection=Connection())
with pytest.raises(HTTPException) as failure:
asyncio.run(contexts.verify_task(scope, value))
assert failure.value.status_code == 409
run["agent_session_id"] = "parent"
asyncio.run(contexts.verify_task(scope, value))
run["run_id"] = str(uuid4())
with pytest.raises(HTTPException):
asyncio.run(contexts.verify_task(scope, value))
def test_jwt_signature_issuer_audience_lifetime_and_key_cache():
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
public = key.public_key().public_numbers()
def encoded(number):
return (
base64.urlsafe_b64encode(
number.to_bytes((number.bit_length() + 7) // 8, "big")
)
.rstrip(b"=")
.decode()
)
jwks = {
"keys": [
{
"kid": "one",
"kty": "RSA",
"alg": "RS256",
"n": encoded(public.n),
"e": encoded(public.e),
}
]
}
calls = []
def respond(request):
calls.append(request)
return httpx.Response(200, json=jwks)
async def check():
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http:
verifier = McpTokenVerifier(settings(), http)
claims = {
"sub": SUBJECT,
"iss": settings().issuer_uri,
"aud": "tjwater-mcp",
"azp": "tjwater-agent-mcp",
"iat": time.time(),
"exp": time.time() + 300,
"scope": "mcp.query mcp.catalog.read",
}
def sign(overrides=None):
return jwt.encode(
{**claims, **(overrides or {})},
pem,
algorithm="RS256",
headers={"kid": "one"},
)
assert (await verifier.verify_token(sign())).subject == SUBJECT
for overrides in [
{"aud": "tjwater-api"},
{"iss": "http://other"},
{"exp": time.time() - 1},
{"iat": time.time() + 120},
{"sub": "not-a-user"},
]:
assert await verifier.verify_token(sign(overrides)) is None
assert len(calls) == 1
assert await verifier.verify_token("broken") is None
asyncio.run(check())
def test_context_control_routes_are_host_only_and_support_revocation(
runtime, monkeypatch
):
runtime, _, _, verifier = runtime
from fastapi import FastAPI
from app.api.v1.endpoints import mcp_queries
monkeypatch.setattr(mcp_queries, "McpQueryRepository", Repository)
host = FastAPI()
host.state.mcp_runtime = runtime
host.include_router(runtime.router, prefix="/api/v1")
command = {"native_session_id": "host-native", "purpose": "ordinary_chat"}
headers = {"Authorization": "Bearer user", "X-Project-Id": str(PROJECT)}
with TestClient(host) as client:
result = client.post(
"/api/v1/mcp-query-contexts", json=command, headers=headers
)
assert result.status_code == 200
handle = result.json()["context_handle"]
assert Repository.bindings[handle].permission_cap == sorted(PERMISSIONS)
verifier.identities["outsider"] = principal(client="browser")
denied = client.post(
"/api/v1/mcp-query-contexts",
json=command,
headers={**headers, "Authorization": "Bearer outsider"},
)
assert denied.status_code == 403
assert (
client.post(
"/api/v1/mcp-query-contexts",
json={**command, "subject": SUBJECT},
headers=headers,
).status_code
== 422
)
assert (
client.delete(
"/api/v1/mcp-query-contexts/" + handle,
headers={**headers, "Authorization": "Bearer other"},
).status_code
== 204
)
assert Repository.bindings[handle].revoked_at is None
assert (
client.delete(
"/api/v1/mcp-query-contexts/" + handle, headers=headers
).status_code
== 204
)
assert Repository.bindings[handle].revoked_at is not None
host.state.mcp_runtime = None
assert (
client.post(
"/api/v1/mcp-query-contexts", json=command, headers=headers
).status_code
== 503
)
def test_large_task_context_uses_bounded_summary_without_loading_full_context(runtime, monkeypatch):
from unittest.mock import AsyncMock
from app.infra.db.postgresql import read_queries
runtime, client, _, _ = runtime
value = binding(task=True)
monkeypatch.setattr(contexts, "verify_task", AsyncMock())
full = SimpleNamespace(model_dump=lambda **kwargs: {"task": {"latest_progress": {"summary": "x" * 13000}}})
service = SimpleNamespace(agent_context=AsyncMock(return_value=full))
monkeypatch.setattr(query_service, "task_service", lambda scope: service)
summary = AsyncMock(return_value={"task": {"task_id": str(value.task_id)}, "selected_report": {"revision": 3}})
monkeypatch.setattr(read_queries, "task_context_summary", summary, raising=False)
runtime.executor = query_service.execute_query
response = request(client, "tools/call", {"name": "get_task_context", "arguments": {"context_handle": value.context_handle, "report_revision": 3}})
result = response.json()["result"]
assert response.status_code == 200 and not result.get("isError", False), result
assert result["structuredContent"]["data"]["selected_report"]["revision"] == 3
service.agent_context.assert_not_awaited()
assert summary.await_args.kwargs["report_revision"] == 3
def test_task_context_sections_support_projection_and_bounded_pages():
from app.mcp.models import TaskContextInput
args = TaskContextInput(context_handle="a" * 43, section="report", report_revision=3,
data_path=["report", "current_conclusion"], limit=100, offset=100)
assert args.section == "report" and args.offset == 100
with pytest.raises(ValueError):
TaskContextInput(context_handle="a" * 43, section="evidence", offset=10001)
def test_concurrency_bound_is_not_a_successful_empty_query(runtime):
runtime, client, _, _ = runtime
runtime.active[(SUBJECT, str(PROJECT))] = 4
query = {
"name": "list_elements",
"arguments": {"context_handle": "a" * 43, "element_type": "pipe"},
}
assert (
request(client, "tools/call", query).json()["result"]["structuredContent"][
"error"
]["code"]
== "RATE_LIMITED"
)
assert runtime.active[(SUBJECT, str(PROJECT))] == 4
def test_time_series_sql_is_bounded_and_field_names_cannot_inject_sql():
from app.infra.db.postgresql import read_queries
statements = []
class Cursor:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def execute(self, sql, params):
statements.append(
(sql.as_string() if hasattr(sql, "as_string") else sql, params)
)
async def fetchall(self):
return []
class Db(Connection):
def cursor(self, **options):
return Cursor()
start = datetime.now(timezone.utc)
async def check():
await read_queries.series_rows(
Db(),
source="scada",
element_type="junction",
ids=["D'1"],
start=start,
end=start + timedelta(hours=1),
field="monitored_value",
limit=101,
offset=200,
)
assert statements[-1][1][-2:] == (101, 200)
assert "LIMIT %s OFFSET %s" in statements[-1][0]
assert "D'1" not in statements[-1][0]
assert "statement_timeout" in statements[0][0]
with pytest.raises(ValueError):
await read_queries.series_rows(
Db(),
source="realtime",
element_type="pipe",
ids=["P1"],
time=start,
field="pressure; DROP TABLE network.nodes",
)
asyncio.run(check())
def test_analysis_preserves_run_units_and_reports_inference(monkeypatch):
from app.mcp.models import AnalysisValuesInput
from unittest.mock import AsyncMock
monkeypatch.setattr(
query_service, "permissions_for_context", lambda ctx: PERMISSIONS
)
monkeypatch.setattr(
query_service.reads, "series_rows", AsyncMock(return_value=[{"value": 3.5}])
)
monkeypatch.setattr(
query_service.NetworkSettingsRepository,
"get_result_units",
AsyncMock(return_value={"pressure": "m"}),
)
run = {"parameters": {"result_units": {"pressure": "bar"}}}
monkeypatch.setattr(
query_service.AnalysisRepository,
"get_run",
AsyncMock(side_effect=lambda *args: run),
)
class Scope:
project = object()
connection = Connection()
@asynccontextmanager
async def timescale(self):
yield self.connection
args = AnalysisValuesInput(
context_handle="a" * 43,
run_id=uuid4(),
element_type="junction",
field="pressure",
time=datetime.now(timezone.utc),
)
async def check():
result = await query_service.execute_query(
Scope(), binding(), TOOLS["query_analysis_values"], args
)
assert result.units == {"pressure": "bar"} and not result.data_gaps
run["parameters"] = {}
result = await query_service.execute_query(
Scope(), binding(), TOOLS["query_analysis_values"], args
)
assert result.units == {"pressure": "m"} and "inferred" in result.data_gaps[0]
assert query_service.finite_data([float("nan"), 2.0, float("inf")]) == (
[None, 2.0, None],
2,
)
asyncio.run(check())
def test_saved_evidence_pages_are_immutable_and_keep_synthetic_origin(monkeypatch):
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.mcp.models import EvidenceInput
monkeypatch.setattr(
query_service, "permissions_for_context", lambda ctx: PERMISSIONS
)
snapshot = TaskDataSnapshot(
source="query_scada_readings",
source_ref="evidence:stored",
queried_at=datetime.now(timezone.utc),
origin="synthetic",
truncated=False,
data={
"semantics": "observed",
"data": {
"items": [{"value": 1}, {"value": 2}],
"series": [{"points": [{"value": 3}, {"value": 4}]}],
},
},
)
async def project(conn, *, full_snapshot=False, **options):
document = snapshot.model_dump(mode="json")
if full_snapshot:
return document
selected = document["data"]["data"]
for part in options["data_path"]:
selected = selected[int(part)] if isinstance(selected, list) else selected[part]
return {
"snapshot_metadata": {k: v for k, v in document.items() if k != "data"},
"query_metadata": {"semantics": "observed"},
"value_type": "array", "total": len(selected),
"value": selected[options["offset"]:options["offset"] + options["limit"]],
}
monkeypatch.setattr(query_service.reads, "evidence_page", project)
scope = SimpleNamespace(project=SimpleNamespace(project_id=PROJECT), connection=Connection())
value = binding(task=True)
async def query(path, **options):
args = EvidenceInput(
context_handle=value.context_handle,
evidence_id=snapshot.source_ref,
data_path=path,
limit=1,
offset=1,
)
return await query_service.execute_query(
scope, value, TOOLS["get_task_evidence"], args, **options
)
async def check():
page = await query(["items"])
assert page.data["value"] == [{"value": 2}] and page.page.total == 2
assert (
page.origin == "synthetic"
and page.evidence_id == page.source_ref == "evidence:stored"
and page.truncated
)
assert (await query(["series", "0", "points"])).data["value"] == [{"value": 4}]
full = await query([], full_snapshot=True)
assert full.data == snapshot.model_dump(mode="json")
assert snapshot.data["data"]["items"] == [{"value": 1}, {"value": 2}]
asyncio.run(check())
+224
View File
@@ -0,0 +1,224 @@
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)
+94
View File
@@ -0,0 +1,94 @@
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()
+73
View File
@@ -0,0 +1,73 @@
import asyncio
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from app.mcp import query_service
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, TaskContextInput
from app.mcp.work_order_skills import read_work_order_skill
@pytest.mark.parametrize("workflow,name", [
("burst_report", "burst-report"),
("water_quality_complaint", "water-quality-complaint"),
("general", "appointment-follow-up"),
])
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
descriptor = read_work_order_skill(workflow)
body = read_work_order_skill(workflow, include_content=True)
assert descriptor == {key: value for key, value in body.items() if key != "instructions"}
assert body["name"] == name and body["version"]
assert len(body["content_sha256"]) == 64 and body["instructions"]
assert "instructions" not in descriptor
# MCP task context has no evidence resource fallback: the complete instructions
# and result envelope must fit its inline response budget.
assert len(json.dumps(body, ensure_ascii=False).encode()) < 10000
@pytest.mark.parametrize("workflow", [None, "unknown", "../../secrets", "burst-report"])
def test_unknown_workflow_never_falls_back_or_reads_an_arbitrary_path(workflow):
assert read_work_order_skill(workflow)["available"] is False
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch):
task_id, project_id = uuid4(), uuid4()
reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}})
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
scope = SimpleNamespace(connection=object(), project=object())
binding = BoundContext(
context_handle="a" * 43, project_id=project_id, subject="u", client_id="host",
native_session_id="s", purpose="task_execution", task_id=task_id,
tool_cap=["get_task_context"], permission_cap=["operational_task.view"],
expires_at=datetime.now(timezone.utc),
)
def query(section):
return asyncio.run(query_service.execute_query(scope, binding, TOOLS["get_task_context"],
TaskContextInput(context_handle=binding.context_handle, section=section)))
summary = query("summary")
body = query("skill")
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
assert summary.data["display_time_zone"] == "Asia/Shanghai"
assert body.data["skill"]["workflow"] == "burst_report"
assert body.data["skill"]["instructions"] and not body.truncated
assert body.evidence_id is None and body.semantics == "task"
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
binding.permission_cap = []
with pytest.raises(HTTPException) as caught:
query("skill")
assert caught.value.status_code == 403 and reader.await_count == 2
@pytest.mark.parametrize("extra", [{"workflow": "general"}, {"data_path": ["instructions"]}, {"offset": 1}])
def test_skill_input_cannot_select_another_workflow_or_partial_instructions(extra):
with pytest.raises(ValueError):
TaskContextInput(context_handle="a" * 43, section="skill", **extra)
+65
View File
@@ -0,0 +1,65 @@
from datetime import datetime, timedelta
from unittest.mock import Mock
import pytest
from app.services import simulation
START = datetime.fromisoformat("2026-10-08T12:00:00+08:00")
def test_online_simulation_uses_realtime_steps_and_returns_requested_samples(monkeypatch):
calls = []
mappings = object()
monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:15:00"})
monkeypatch.setattr(simulation, "query_corresponding_element_id_and_query_id", lambda _: mappings)
def run(**kwargs):
calls.append(kwargs)
return {
"units": {"pressure": "MTR", "flow": "LPS"},
"node_results": [{"node": "123422", "result": [{"pressure": len(calls)}]}, {"node": "other", "result": [{}]}],
"link_results": [{"link": "540672", "result": [{"flow": len(calls)}]}],
}
monkeypatch.setattr(simulation, "run_simulation", run)
result = simulation.run_online_simulation("online", START, 3600, output_node_ids=["123422"], output_link_ids=["540672"])
assert calls == [{
"name": "online", "simulation_type": "realtime",
"modify_pattern_start_time": (START + timedelta(minutes=15 * index)).isoformat(timespec="seconds"),
"scada_mappings": mappings,
} for index in range(4)]
assert result["node_results"] == [{"node": "123422", "result": [{"pressure": index} for index in range(1, 5)]}]
assert result["link_results"] == [{"link": "540672", "result": [{"flow": index} for index in range(1, 5)]}]
assert result["times"]["num_periods"] == 4
assert result["times"]["report_step"] == 900
assert result["times"]["sample_times"][-1] == "2026-10-08T12:45:00+08:00"
def test_online_simulation_rejects_invalid_step_before_calculating(monkeypatch):
monkeypatch.setattr(simulation, "get_time", lambda _: {"HYDRAULIC TIMESTEP": "0:00:00"})
run = Mock()
monkeypatch.setattr(simulation, "run_simulation", run)
with pytest.raises(ValueError, match="HYDRAULIC TIMESTEP"):
simulation.run_online_simulation("online", START, 3600)
run.assert_not_called()
def test_mcp_online_simulation_does_not_copy_a_database(monkeypatch):
from app.mcp.scenario_service import calculate_scenario
def reject_copy(*_):
pytest.fail("Online simulation must not clone the project database")
run = Mock(return_value={"node_results": [], "link_results": [], "times": {}, "units": {}})
monkeypatch.setattr("app.native.wndb.core.projects.temporary_project_database", reject_copy)
monkeypatch.setattr(simulation, "run_online_simulation", run, raising=False)
result = calculate_scenario("run_simulation", {
"start_time": START, "duration": 3600,
"output_node_ids": ["123422", "123425"], "output_link_ids": ["540672"],
}, network="online", username="actor")
run.assert_called_once_with("online", START, 3600, output_node_ids=["123422", "123425"], output_link_ids=["540672"])
assert result["input_source"] == "online_model_simulation"
assert result["persisted_baseline"] is True
assert result["persisted_scheme"] is False
+33 -3
View File
@@ -6,7 +6,7 @@ from pydantic import ValidationError
from app.auth.project_dependencies import ProjectContext
from app.api.v1.endpoints.operational_tasks import _task_permissions
from app.auth.permissions import SCADA_VIEW, SIMULATION_VIEW
from app.auth.permissions import SCADA_VIEW, SIMULATION_VIEW, permissions_for_context
from app.domain.schemas.operational_data import TaskDataQuery
from app.infra.integrations.operational_data import TaskDataError, TaskDataSettings, read_task_data
@@ -133,12 +133,42 @@ def test_query_is_a_finite_read_scope_with_bounded_time_and_device_count():
TaskDataQuery(source="peripheral_observations")
@pytest.mark.anyio
@pytest.mark.parametrize("source", ["scada_devices", "peripheral_work_orders"])
async def test_task_service_reads_with_existing_project_permissions(source):
ctx = ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test",
project_role="member", system_role="service")
permissions = _task_permissions(ctx)
assert permissions == set(permissions_for_context(ctx))
assert {SCADA_VIEW, SIMULATION_VIEW} <= permissions
def handler(request):
assert request.method == "GET"
assert request.headers["Authorization"] == "Bearer AGENT"
assert request.headers["X-Project-Id"] == str(ctx.project_id)
return httpx.Response(200, json={"items": [], "project_id": str(ctx.project_id)})
snapshot = await read_task_data(
context=ctx, credential="Bearer AGENT", query=TaskDataQuery(source=source),
settings=settings(), transport=httpx.MockTransport(handler),
)
assert snapshot.source == source
@pytest.mark.anyio
async def test_service_without_project_read_permission_does_not_gain_access():
ctx = ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test",
project_role="", system_role="service")
assert not {SCADA_VIEW, SIMULATION_VIEW} & _task_permissions(ctx)
with pytest.raises(TaskDataError, match="没有该数据源的读取权限"):
await read_task_data(context=ctx, credential="Bearer AGENT",
query=TaskDataQuery(source="scada_devices"), settings=settings())
@pytest.mark.anyio
async def test_task_service_uses_approved_delegation_only_during_source_read():
ctx = ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test",
project_role="member", system_role="service")
assert SCADA_VIEW not in _task_permissions(ctx)
assert SIMULATION_VIEW not in _task_permissions(ctx)
query = TaskDataQuery(source="scada_devices")
with pytest.raises(TaskDataError, match="委托身份尚未配置"):
await read_task_data(context=ctx, credential="Bearer AGENT", query=query,
@@ -0,0 +1,56 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.schemas.operational_task import TaskPresentationEventCommand
from app.services.operational_presentation import resolve_overlay
def _snapshot(data):
return TaskDataSnapshot(source="workbench_runtime", source_ref="stored-spatial-evidence",
queried_at=datetime(2026, 10, 10, tzinfo=timezone.utc),
origin="system_data", data=data, truncated=False)
def test_overlay_preserves_recorded_polygon_and_never_converts_it_to_heatmap_points():
geometry = {"type": "Polygon", "coordinates": [[[121, 31], [122, 31], [122, 32], [121, 31]]]}
collection = {"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": geometry,
"properties": {"source": "actual-analysis"}}]}
snapshot = _snapshot({"latest_analysis": {"map_payload": collection}})
assert resolve_overlay(snapshot, "geometry")["features"][0]["geometry"] == geometry
with pytest.raises(DomainError) as error:
resolve_overlay(snapshot, "heatmap")
assert error.value.code == "OVERLAY_HEATMAP_POINTS_REQUIRED"
@pytest.mark.parametrize("data", [
{"items": [{"location": "某小区,无法仅凭地址确定坐标"}]},
{"items": [{"longitude": True, "latitude": 31}]},
{"items": [{"longitude": 121, "latitude": 91}]},
{"items": [{"longitude": 121, "latitude": 31, "weight": -1}]},
{"type": "FeatureCollection", "features": []},
])
def test_overlay_rejects_missing_or_invalid_recorded_spatial_data(data):
with pytest.raises(DomainError):
resolve_overlay(_snapshot(data), "heatmap")
@pytest.mark.parametrize("command", [
{"action": "run_script", "code": "arbitrary"},
{"action": "highlight_features", "layer": "arbitrary-layer", "feature_ids": ["X"]},
{"action": "set_overlay", "overlay_id": "X", "title": "X", "mode": "geometry", "evidence_id": "E",
"data": {"type": "FeatureCollection", "features": []}},
{"action": "open_chart", "chart_id": "X", "evidence_refs": ["E"], "chart": {
"title": "X", "chart_type": "line", "x_data": ["t1", "t2"], "series": [{"name": "s", "data": [1]}],
}},
{"action": "open_chart", "chart_id": "X", "evidence_refs": ["E"], "chart": {
"title": "X", "chart_type": "line", "x_data": ["t1"], "series": [{"name": "s", "data": [True]}],
}},
])
def test_presentation_input_rejects_free_form_or_invalid_commands(command):
with pytest.raises(ValidationError):
TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
@@ -0,0 +1,58 @@
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import AgentRunCommand
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.services.operational_tasks import OperationalTaskService
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
@pytest.mark.parametrize("available", [False, True])
async def test_execution_requires_message_storage(available):
cursor = SimpleNamespace(fetchone=AsyncMock(return_value={"available": available}))
connection = SimpleNamespace(execute=AsyncMock(return_value=cursor))
repository = OperationalTaskRepository(connection, uuid4())
if available:
await repository.require_execution_storage()
else:
with pytest.raises(DomainError) as failure:
await repository.require_execution_storage()
assert failure.value.status == 503
assert failure.value.code == "DEPENDENCY_UNAVAILABLE"
assert "024_agent_messages.sql" in failure.value.message
assert "to_regclass('workbench.agent_messages')" in connection.execute.call_args.args[0]
@pytest.mark.anyio
async def test_missing_message_storage_rejects_claim_before_state_changes():
@asynccontextmanager
async def transaction():
yield
repository = SimpleNamespace(
transaction=transaction,
require_execution_storage=AsyncMock(side_effect=DomainError(
"DEPENDENCY_UNAVAILABLE", "Apply 024_agent_messages.sql", status=503,
)),
get_request=AsyncMock(return_value=None),
lock=AsyncMock(), update_run=AsyncMock(), update_task=AsyncMock(),
)
service = OperationalTaskService(repository, SimpleNamespace(), set())
with pytest.raises(DomainError, match="024_agent_messages.sql"):
await service.claim_run(uuid4(), uuid4(), AgentRunCommand(
request_id=uuid4(), expected_revision=1, source_session_id="test-session",
))
repository.get_request.assert_not_awaited()
repository.lock.assert_not_awaited()
repository.update_run.assert_not_awaited()
repository.update_task.assert_not_awaited()
+320
View File
@@ -35,6 +35,8 @@ from app.domain.schemas.operational_task import (
TaskAccessRequest,
WorkOrderDraftUpdateCommand,
WorkOrderConfirmDispatchCommand,
TaskPresentationEventCommand,
StoredEvidenceRecord,
)
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
@@ -265,6 +267,7 @@ class TaskRepository:
self.feedback = {}
self.plans = []
self.reports = []
self.progress = {}
self.notifications = []
self.locks = []
self.access_requests = {}
@@ -297,6 +300,12 @@ class TaskRepository:
async def get_latest_report(self, _task_id):
return self.reports[-1] if self.reports else None
async def get_latest_progress(self, task_id, generation):
return self.progress.get((task_id, generation))
async def save_progress(self, task_id, snapshot):
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
async def get_report(self, _task_id, revision):
return next((item for item in self.reports if item["revision"] == revision), None)
@@ -404,6 +413,9 @@ class RunTaskRepository(TaskRepository):
self.events.append(event)
return len(self.events)
async def current_event_cursor(self):
return len(self.events)
def _active_run(task, now):
return OperationalTaskRun(
@@ -415,6 +427,258 @@ def _active_run(task, now):
).model_dump(mode="json")
@pytest.mark.anyio
async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
first = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
"progress": {"summary": "正在核对输入,尚未形成结论。", "diagram": {
"title": "执行进展", "mermaid": "flowchart LR\nfirst[开始查询]",
"node_refs": [{"node_id": "first", "label": "开始查询", "status": "running",
"map_targets": [{"source_id": "pipes", "feature_id": "P-1"}]}],
}},
})
initial = await service.publish_artifacts(task["task_id"], first)
assert initial.progress_revision == 1
assert initial.task.latest_report is None and initial.task.latest_plan is None
assert initial.task.active_run.status == "running"
assert (await service.publish_artifacts(task["task_id"], first)).progress_revision == 1
assert len([event for event in repository.events if event["topic"] == "operational.progress.changed"]) == 1
replacement = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
"progress": {"summary": "重新核查另一条路径。", "diagram": {
"title": "新脉络", "mermaid": "flowchart TD\nother[核查新路径]",
"node_refs": [{"node_id": "other", "label": "核查新路径", "status": "running"}],
}},
})
updated = await service.publish_artifacts(task["task_id"], replacement)
assert updated.progress_revision == 2
assert [node.node_id for node in updated.task.latest_progress.progress.diagram.node_refs] == ["other"]
cleared = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
progress={"summary": "当前不需要图示。", "diagram": None},
))
assert cleared.progress_revision == 3
assert cleared.task.latest_progress.progress.diagram is None
assert not repository.notifications
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["progress", "presentation"])
@pytest.mark.parametrize("invalid", ["run", "session", "lease", "finished"])
async def test_reading_publications_reject_stale_execution_ownership(kind, invalid):
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
run = _active_run(task, now)
if invalid == "lease":
run["lease_expires_at"] = now.isoformat()
elif invalid == "finished":
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"],
"source_session_id": "other-session" if invalid == "session" else "session-1"}
with pytest.raises(DomainError):
if kind == "progress":
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
**identity, progress={"summary": "执行中"},
))
else:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
**identity, command={"action": "clear_highlights"},
))
assert repository.progress == {} and repository.events == []
@pytest.mark.anyio
async def test_progress_unknown_evidence_does_not_publish_or_authorize_actions():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError):
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
progress={"summary": "执行中", "diagram": {"title": "核查", "mermaid": "graph LR\nA[核查]",
"node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["unknown"]}]}},
))
assert not repository.progress
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
progress={"summary": "查询完成,正在判断"},
))
with pytest.raises(DomainError) as error:
await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="不能用进展授权动作",
payload={"title": "现场核查", "description": "核查", "work_order_type": "sampling"},
))
assert error.value.code == "ACTION_CONCLUSION_REQUIRED"
@pytest.mark.anyio
async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
baseline = await service.presentation_cursor(task["task_id"])
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "highlight_features", "layer": "pipes", "feature_ids": ["P-1"], "effect": "pulse"},
)
result = await service.publish_presentation_event(task["task_id"], command)
assert (await service.publish_presentation_event(task["task_id"], command)) == result
assert (await service.presentation_cursor(task["task_id"])).event_cursor == result.event_seq
assert repository.task["revision"] == task["revision"]
assert len(repository.events) == 1
assert repository.events[0]["metadata"]["command"]["effect"] == "pulse"
with pytest.raises(DomainError):
await service.presentation_cursor(uuid4())
@pytest.mark.anyio
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
origin="system_data", truncated=False,
data={"items": [{"device_id": "D-1", "longitude": 121.4, "latitude": 31.1}]})
evidence = StoredEvidenceRecord(evidence_id="verified-points", kind="system_data", summary="实际坐标",
source_label="SCADA", observed_at=now, recorded_at=now, recorded_by="server",
has_snapshot=True, data_snapshot=snapshot)
repository.evidence[evidence.evidence_id] = evidence.model_dump(mode="json")
identity = {"run_id": repository.run["run_id"], "source_session_id": "session-1"}
overlay = {"action": "set_overlay", "overlay_id": "points", "title": "监测点分布", "mode": "heatmap"}
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
))
emitted = repository.events[-1]["metadata"]["command"]["data"]
assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1]
assert emitted["features"][0]["properties"]["weight"] == 1
with pytest.raises(DomainError) as missing:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"},
))
assert missing.value.code == "EVIDENCE_NOT_FOUND"
repository.evidence[evidence.evidence_id]["data_snapshot"]["data"] = {
"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [121, 31]},
"properties": {"label": "x" * (512 * 1024)}}],
}
with pytest.raises(DomainError) as oversized:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
))
assert oversized.value.code == "PRESENTATION_TOO_LARGE"
assert len(repository.events) == 1
@pytest.mark.anyio
async def test_completed_investigation_resumes_once_for_linked_new_field_feedback():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
_seed_concluded_report(repository, now)
action = await service.propose_action(task["task_id"], ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="现场检测",
payload={"title": "现场检测", "description": "回填复测结果", "work_order_type": "sampling"},
))
child_id = str(uuid4())
repository.action = action.model_copy(update={
"approval_status": "approved", "execution_status": "succeeded",
}).model_dump(mode="json")
repository.action["receipt"] = {
"adapter": "peripheral_sample", "receipt_id": child_id, "submitted_at": now.isoformat(),
"completed_at": now.isoformat(), "external_status": "open", "detail": "工单已创建,现场未完成",
}
repository.run["status"] = "succeeded"
repository.task.update(status="completed", lifecycle_status="completed", agent_session_id=None,
agent_lease_expires_at=None)
identity = {"source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2,
"status": "completed", "occurred_at": now, "detail": "现场复测通过"}
report_before = repository.reports[-1].copy()
command = ExternalFeedbackCommand(request_id=uuid4(), **identity)
accepted = await service.external_feedback(task["task_id"], command)
assert accepted.task.execution_generation == 2 and accepted.task.active_run.status == "queued"
assert repository.reports[-1] == report_before
assert (await service.external_feedback(task["task_id"], command)).task.execution_generation == 2
# Source-version deduplication also survives a caller changing the request ID.
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
assert again.task.execution_generation == 2
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
repository.task.update(status="cancelled", lifecycle_status="cancelled")
with pytest.raises(DomainError) as cancelled:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), **{**identity, "source_revision": 3},
))
assert cancelled.value.code == "TASK_NOT_ACTIONABLE"
assert repository.task["execution_generation"] == 2
@pytest.mark.anyio
async def test_completed_investigation_rejects_unlinked_or_initial_child_feedback():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=None)
task.update(status="completed", lifecycle_status="completed")
run = _active_run(task, now)
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as unlinked:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}", source_revision=2,
status="completed", occurred_at=now, detail="无关联的工单",
))
assert unlinked.value.code == "EXTERNAL_SOURCE_MISMATCH"
assert repository.task["execution_generation"] == 1 and repository.feedback == {}
child_id = str(uuid4())
repository.action = {"kind": "create_work_order", "receipt": {
"adapter": "peripheral_sample", "receipt_id": child_id,
}}
with pytest.raises(DomainError) as initial:
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
request_id=uuid4(), source_event_id=f"peripheral_sample:{child_id}", source_revision=1,
status="assigned", occurred_at=now, detail="工单创建,尚未回填",
))
assert initial.value.code == "INITIAL_CHILD_FEEDBACK"
assert repository.feedback == {}
@pytest.mark.anyio
async def test_original_complaint_feedback_advances_its_source_revision():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
source_id = str(uuid4())
task = _task_document(context, now, lease=None)
task.update(status="completed", lifecycle_status="completed",
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
run = _active_run(task, now)
run["status"] = "succeeded"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
accepted = await service.external_feedback(task["task_id"], command)
assert accepted.task.source.external_revision == 2
assert accepted.task.execution_generation == 2
with pytest.raises(DomainError) as stale:
await service.external_feedback(task["task_id"], command.model_copy(update={"request_id": uuid4(), "source_revision": 1}))
assert stale.value.code == "STALE_EXTERNAL_REVISION"
@pytest.mark.anyio
async def test_run_heartbeat_does_not_change_task_business_revision():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
@@ -804,6 +1068,62 @@ async def test_report_history_and_notifications_keep_selected_and_current_refere
assert other_task.value.status == 404
@pytest.mark.anyio
async def test_final_report_archives_its_progress_diagram_across_new_rounds():
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
progress={"summary": "正在整理判断", "diagram": diagram},
))
command = AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**_current_report(), "diagram": None},
)
await service.publish_artifacts(task["task_id"], command)
assert repository.reports[-1]["report"]["diagram"]["title"] == "调查过程"
assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "running"
assert command.report.diagram is None # Do not change the idempotency request.
repository.task["execution_generation"] = 2
detail = await service.detail(task["task_id"])
assert detail.latest_progress is None
assert detail.latest_report.report.diagram.title == "调查过程"
@pytest.mark.anyio
@pytest.mark.parametrize("mismatch", [None, "session", "future", "evidence", "cleared"])
async def test_legacy_report_diagram_reads_only_matching_prepublication_progress(mismatch):
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
repository.reports.append({
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
"published_at": now.isoformat(), "published_by": "agent", "report": _current_report(),
})
repository.progress[(task["task_id"], 1)] = {
"revision": 1, "execution_generation": 1, "run_id": repository.run["run_id"],
"source_session_id": "another-session" if mismatch == "session" else "session-1",
"published_at": (now + timedelta(seconds=1) if mismatch == "future" else now - timedelta(seconds=1)).isoformat(),
"progress": {"summary": "报告发布前的核查", "diagram": None if mismatch == "cleared" else {
"title": "已保存的过程图", "mermaid": "flowchart LR\nA[核查]",
"node_refs": [{"node_id": "A", "label": "核查", "evidence_refs": ["not-in-report"] if mismatch == "evidence" else []}],
}},
}
repository.task["execution_generation"] = 2
detail = await service.detail(task["task_id"])
selected = await service.report(task["task_id"], 1)
history, _ = await service.list_reports(task["task_id"], 20, 0)
for report in (detail.latest_report, selected, history[0]):
assert (report.report.diagram is not None) is (mismatch is None)
assert repository.reports[0]["report"].get("diagram") is None # Read compatibility never rewrites history.
@pytest.mark.anyio
@pytest.mark.parametrize("node_ref,code", [
({"node_id": "A", "label": "依据", "evidence_refs": ["forged"]}, "EVIDENCE_NOT_FOUND"),
+135
View File
@@ -1,4 +1,5 @@
import json
import os
from uuid import uuid4
import httpx
@@ -12,6 +13,114 @@ def anyio_backend():
return "asyncio"
@pytest.mark.parametrize("environment,use_dev_url", [
("development", True), ("production", False), ("staging", False), (None, False),
])
def test_worker_dev_token_url_is_only_used_in_development(monkeypatch, environment, use_dev_url):
monkeypatch.setattr("scripts.run_operational_worker.load_dotenv", lambda *args, **kwargs: None)
for key in list(os.environ):
if key.startswith("OPERATIONAL_WORKER_") or key == "ENVIRONMENT":
monkeypatch.delenv(key)
if environment:
monkeypatch.setenv("ENVIRONMENT", environment)
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_ID", str(uuid4()))
monkeypatch.setenv("OPERATIONAL_WORKER_CLIENT_ID", "test-service")
monkeypatch.setenv("OPERATIONAL_WORKER_CLIENT_SECRET", "TEST_ONLY_SECRET")
standard_url = "https://identity.test/token"
dev_url = "http://192.168.1.10:8080/token"
monkeypatch.setenv("OPERATIONAL_WORKER_TOKEN_URL", standard_url)
monkeypatch.setenv("OPERATIONAL_WORKER_DEV_TOKEN_URL", dev_url)
assert WorkerSettings.from_environment().token_url == (dev_url if use_dev_url else standard_url)
monkeypatch.delenv("OPERATIONAL_WORKER_DEV_TOKEN_URL")
assert WorkerSettings.from_environment().token_url == standard_url
@pytest.mark.anyio
async def test_all_worker_operations_share_investigation_creation_scope():
boundary = "2026-10-10T10:29:47.864313Z"
requests = []
def handler(request):
requests.append(request)
if request.url.path.endswith("/agent-messages/pending"):
return httpx.Response(200, json={"items": []})
if request.method == "GET":
assert request.url.params["task_type"] == "event_investigation"
assert request.url.params["task_created_from"] == boundary
return httpx.Response(200, json={"items": []})
assert request.url.path.endswith("leases/recover")
assert json.loads(request.content) == {
"limit": 100, "task_type": "event_investigation", "task_created_from": boundary,
}
return httpx.Response(200, json={"task_ids": []})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()),
bearer="TEST_ONLY", task_created_from=boundary)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert len(requests) == 5
@pytest.mark.anyio
async def test_worker_reconciles_messages_at_capacity_and_preserves_transport_retries():
pending = [{"message_id": str(uuid4())} for _ in range(3)]
failures, submitted = [], []
def handler(request):
path = request.url.path
if path.endswith("leases/recover"):
return httpx.Response(200, json={"items": []})
if path.endswith("agent-messages/pending"):
return httpx.Response(200, json={"items": pending})
if path.endswith("operational-task-runs"):
return httpx.Response(200, json={"items": [{"run_id": str(uuid4())}]
if request.url.params["status"] == "running" else []})
body = json.loads(request.content)
if path.endswith("task-messages/deliver"):
assert str(request.url) == "http://agent.test/api/v1/task-messages/deliver"
submitted.append(body["message_id"])
status = {pending[0]["message_id"]: 503, pending[1]["message_id"]: 404}.get(body["message_id"], 202)
return httpx.Response(status, json={})
assert path.endswith("/delivery") and request.url.host == "server.test"
failures.append((path, body))
return httpx.Response(200, json={})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()),
bearer="TEST_ONLY", max_concurrency=1)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert submitted == [row["message_id"] for row in pending]
assert len(failures) == 1 and pending[1]["message_id"] in failures[0][0]
assert failures[0][1]["status"] == "failed"
@pytest.mark.anyio
async def test_message_sweep_advances_past_idle_chats_across_bounded_ticks():
messages = [str(uuid4()) for _ in range(5)]
scans, delivered = [], []
def handler(request):
if request.method == "GET":
cursor = request.url.params.get("cursor")
page = int(cursor) if cursor else 0
scans.append(cursor)
return httpx.Response(200, json={
"items": [{"message_id": messages[page]}],
"next_cursor": str(page + 1) if page < 4 else None,
})
delivered.append(json.loads(request.content)["message_id"])
return httpx.Response(202, json={})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
worker = OperationalWorker(settings, client)
await worker._reconcile_messages({})
assert scans == [None, "1", "2", "3"] and worker._message_cursor == "4"
await worker._reconcile_messages({})
assert scans[-1] == "4" and worker._message_cursor is None
assert delivered == messages
@pytest.mark.anyio
async def test_persisted_queue_retries_with_backend_execution_identity():
task_id, run_id, execution_id = str(uuid4()), str(uuid4()), str(uuid4())
@@ -29,6 +138,7 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
"status": "queued", "revision": 1,
}]})
body = json.loads(request.content)
assert str(request.url) == "http://agent.test/api/v1/task-executions"
submitted.append(body)
return httpx.Response(202, json={"task_id": task_id, "run_id": run_id, "session_id": "execution-session"})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
@@ -139,3 +249,28 @@ async def test_long_lived_oauth_token_is_refreshed_before_product_token_age_limi
clock[0] = 301.0
await provider.headers(client)
assert len(requests) == 2
@pytest.mark.anyio
async def test_worker_refreshes_running_agent_credentials_after_restart():
run_id, project_id = str(uuid4()), str(uuid4())
renewed = []
def handler(request):
if request.url.path.endswith("leases/recover"):
return httpx.Response(200, json={"items": []})
if request.method == "GET":
items = [{"run_id": run_id, "agent_session_id": "existing-session"}] if request.url.params.get("status") == "running" else []
return httpx.Response(200, json={"items": items})
assert request.url.host == "agent.test"
assert str(request.url) == "http://agent.test/api/v1/session/existing-session/credentials"
assert request.headers["Authorization"] == "Bearer TEST_ONLY"
assert request.headers["X-Project-Id"] == project_id
assert json.loads(request.content) == {}
renewed.append(request)
return httpx.Response(202, json={"accepted": True})
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert len(renewed) == 1
+70 -5
View File
@@ -241,12 +241,14 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
@pytest.mark.anyio
@pytest.mark.parametrize("targeted", [False, True])
@pytest.mark.parametrize("order_revision", [1, 2])
@pytest.mark.parametrize(
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
[
("waiting_human", 2, True),
("queued", 2, True),
("completed", 1, False),
("completed", 2, True),
("cancelled", 1, False),
],
)
async def test_created_work_order_receipt_preserves_projection_and_routes_active_feedback(
@@ -254,6 +256,7 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
expected_feedback_count,
routes_to_investigation,
targeted,
order_revision,
):
project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4()))
received = []
@@ -262,10 +265,14 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
order = {
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
"created_by": str(uuid4()),
"revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z",
"revision": order_revision, "summary": "采样任务", "status": "completed" if order_revision > 1 else "open",
"updated_at": "2026-09-23T09:00:00Z",
"title": "合成采样工单", "priority": "normal",
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
"scheduled_at": None,
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T09:00:00Z", "actor": "现场人员",
"kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"}]
if order_revision > 1 else []),
}
if targeted:
assert request.url.path.endswith(f"/work-orders/{order_id}")
@@ -277,6 +284,8 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
payload = json.loads(request.content)
assert payload["source_event_id"] == f"peripheral_sample:{order_id}"
assert ("work_order_snapshot" in payload) == request.url.path.endswith(f"{projection_id}/external-feedback")
if order_revision > 1:
assert "复测余氯0.2mg/L" in payload["evidence"]["summary"]
return httpx.Response(200, json={})
if request.url.path.endswith(investigation_id):
return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": {
@@ -287,7 +296,7 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
return httpx.Response(200, json={"items": [
{"task_id": investigation_id, "task_type": "event_investigation", "status": investigation_status},
{"task_id": projection_id, "task_type": "external_work_order", "source": {
"external_id": f"peripheral_sample:{order_id}", "external_revision": 1,
"external_id": f"peripheral_sample:{order_id}", "external_revision": 1 if order_revision > 1 else None,
}},
], "next_offset": None})
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
@@ -295,9 +304,10 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
assert await sync_once(
client, settings, "http://sample.test", CredentialProvider(settings),
**({"work_order_ids": {order_id}} if targeted else {}),
) == expected_feedback_count
assert any(investigation_id in path for path in received) is routes_to_investigation
) == (expected_feedback_count if order_revision > 1 else 1)
assert any(investigation_id in path for path in received) is (routes_to_investigation and order_revision > 1)
assert any(projection_id in path for path in received)
assert received[-1].endswith(f"{projection_id}/external-feedback")
@pytest.mark.anyio
@@ -331,6 +341,61 @@ async def test_external_events_start_investigations_without_browser(event_kind):
assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z"
@pytest.mark.anyio
@pytest.mark.parametrize("failure_target", ["parent", "projection"])
async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_deliveries(failure_target):
project_id, order_id, parent_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4()))
parent = {"task_id": parent_id, "task_type": "event_investigation", "status": "completed", "source": {}}
projection = {"task_id": projection_id, "task_type": "external_work_order", "source": {
"external_id": f"peripheral_sample:{order_id}", "external_revision": 1,
}}
order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
"revision": 2, "title": "采样", "summary": "复测通过", "status": "completed", "priority": "normal",
"created_at": "2026-10-10T00:00:00Z", "updated_at": "2026-10-10T01:00:00Z", "scheduled_at": None,
"work_order_type": "general"}
failed = False
accepted_parent_requests = set()
detail_reads = []
def handler(request):
nonlocal failed
if request.url.host == "sample.test":
return httpx.Response(200, json={"items": [order], "next_cursor": None})
if request.method == "GET":
if request.url.path.endswith(parent_id):
detail_reads.append(parent_id)
return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": {
"adapter": "peripheral_sample", "receipt_id": order_id,
}}]})
return httpx.Response(200, json={"items": [parent, projection], "next_offset": None})
payload = json.loads(request.content)
is_parent = request.url.path.endswith(f"{parent_id}/external-feedback")
if not failed and is_parent == (failure_target == "parent"):
failed = True
return httpx.Response(503, json={})
if is_parent:
accepted_parent_requests.add(payload["request_id"])
parent["status"] = "queued"
else:
assert accepted_parent_requests, "Projection must not advance before its associated investigation"
projection["source"]["external_revision"] = payload["source_revision"]
return httpx.Response(200, json={})
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
credentials = CredentialProvider(settings)
with pytest.raises(httpx.HTTPStatusError):
await sync_once(client, settings, "http://sample.test", credentials)
assert projection["source"]["external_revision"] == 1
assert await sync_once(client, settings, "http://sample.test", credentials) == 2
assert len(accepted_parent_requests) == 1
assert projection["source"]["external_revision"] == 2
parent["status"] = "completed"
detail_reads.clear()
assert await sync_once(client, settings, "http://sample.test", credentials) == 0
assert detail_reads == [], "Unchanged source versions must not scan completed parent details forever"
def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
order = {
"work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步",
@@ -1,5 +1,7 @@
from uuid import uuid4
import pytest
from samples.peripheral.auth import Identity
from samples.peripheral.config import SampleSettings
from samples.peripheral.schemas import CreateWorkOrder, UpdateWorkOrder, WorkOrder
@@ -41,6 +43,25 @@ def test_create_and_update_record_flat_order_activity(monkeypatch):
assert "progress_note" not in updated
@pytest.mark.parametrize(("work_order_type", "detail"), [
("general", "预约工单已创建。"),
("water_quality_complaint", "投诉工单已创建。"),
("burst_report", "爆管上报工单已创建。"),
])
def test_created_activity_distinguishes_work_order_type(monkeypatch, work_order_type, detail):
store = PostgresStore(SampleSettings())
actor = Identity(str(uuid4()), str(uuid4()), True)
monkeypatch.setattr(store, "_mutate", lambda who, key, command, operation: operation(None, "normal"))
monkeypatch.setattr(store, "_save_work_order", lambda _conn, _who, document: document)
created = store.create_work_order(actor, CreateWorkOrder(
request_id=uuid4(), title="测试工单", summary="验证创建活动语义",
work_order_type=work_order_type,
))
assert created["activity"][0]["detail"] == detail
def test_legacy_order_reads_flat_without_claiming_unrecorded_progress():
legacy = {"work_order_id": str(uuid4()), "subtasks": [{"stages": [{"status": "done"}]}]}
projected = PostgresStore._active_managed_document(legacy)
+140
View File
@@ -0,0 +1,140 @@
import asyncio
import json
import logging
import httpx
import pytest
from pydantic import ValidationError
from app.core.config import Settings, settings
from app.domain.schemas.place_search import PlaceSearchRequest
from app.services.place_search import PlaceSearchError, search_tianditu_places
def poi(**overrides):
return {
"hotPointID": "place-1", "name": "南桥路", "address": "上海市奉贤区南桥路",
"lonlat": "121.441891,31.220188", "county": "奉贤区", "countyCode": "156310120",
"provinceCode": "156310000", "typeName": "道路名", **overrides,
}
def payload(**overrides):
return {"status": {"infocode": 1000}, "resultType": 1, "count": 21, "pois": [poi()], **overrides}
class FakeClient:
def __init__(self, data=None, status=200, error=None):
self.data = data if data is not None else payload()
self.status = status
self.error = error
self.calls = []
async def get(self, url, *, params):
self.calls.append((url, params))
if self.error:
raise self.error
return httpx.Response(self.status, json=self.data, request=httpx.Request("GET", url))
@pytest.fixture(autouse=True)
def shared_token(monkeypatch):
monkeypatch.setattr(settings, "TIANDITU_TOKEN", "shared-token")
def test_search_fixes_fengxian_scope_and_normalizes_candidates():
client = FakeClient()
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), client=client))
url, params = client.calls[0]
assert url == "https://api.tianditu.gov.cn/v2/search"
assert params["tk"] == "shared-token"
assert json.loads(params["postStr"]) == {
"keyWord": "南桥路", "queryType": 12, "specify": "156310120", "show": 2, "start": 0, "count": 10,
}
assert result.items[0].name == "南桥路"
assert result.items[0].longitude == 121.441891
assert result.items[0].district == "奉贤区"
assert result.items[0].category == "道路名"
assert result.truncated and result.total == 1 and result.region == "奉贤区"
@pytest.mark.parametrize("data", [payload(count=0, pois=[]), {"status": {"infocode": 3001}}])
def test_no_matches_is_a_successful_empty_result(data):
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="未知地名"), client=FakeClient(data)))
assert result.items == [] and result.total == 0 and not result.truncated
@pytest.mark.parametrize("matched_total, size, truncated", [(0, 0, False), (3, 3, False), (10, 10, False), (28, 10, True), (2000, 10, True)])
def test_search_only_returns_first_ten_results(matched_total, size, truncated):
client = FakeClient(payload(count=matched_total, pois=[poi(hotPointID=f"place-{index}") for index in range(size)]))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client))
assert len(result.items) == result.total == size and result.truncated == truncated
params = json.loads(client.calls[0][1]["postStr"])
assert params["count"] == 10 and params["start"] == 0 and params["specify"] == "156310120"
assert "has_more" not in result.model_dump() and "offset" not in result.model_dump()
@pytest.mark.parametrize("data", [
payload(pois=[poi(lonlat="bad")]),
payload(pois=[poi(lonlat="181,31")]),
payload(pois=[poi(lonlat="121,NaN")]),
payload(pois=[poi(name=None)]),
payload(pois=[poi(provinceCode="156320000")]),
payload(pois=[poi(countyCode="156310104", county="徐汇区")]),
payload(pois=[poi(countyCode="156310120", county="静安区")]),
payload(pois=[poi(countyCode=None, county=None)]),
payload(count=-1),
payload(pois=[poi() for _ in range(11)]),
payload(resultType=4),
{"status": {"infocode": 2001}},
])
def test_invalid_or_unlocatable_provider_response_is_rejected(data):
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=FakeClient(data)))
assert exc.value.status_code == 502
def test_missing_shared_token_is_configuration_error(monkeypatch):
monkeypatch.setattr(settings, "TIANDITU_TOKEN", "")
client = FakeClient()
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client))
assert exc.value.status_code == 503 and client.calls == []
def test_http_request_logs_redact_the_shared_token(caplog):
caplog.set_level(logging.INFO, logger="httpx")
async def run():
transport = httpx.MockTransport(lambda _request: httpx.Response(200, json=payload()))
async with httpx.AsyncClient(transport=transport) as client:
await search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client)
asyncio.run(run())
messages = [record.getMessage() for record in caplog.records if record.name == "httpx"]
assert messages and any("tk=[redacted]" in message for message in messages)
assert all("shared-token" not in message for message in messages)
@pytest.mark.parametrize("failure, status", [
(httpx.ReadTimeout("secret-token-in-url"), 504),
(httpx.ConnectError("secret-token-in-url"), 502),
])
def test_transport_errors_are_safe_and_actionable(failure, status):
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=FakeClient(error=failure)))
assert exc.value.status_code == status
assert "secret-token" not in exc.value.detail
@pytest.mark.parametrize("body", [{"keyword": " "}, {"keyword": "南桥路", "specify": "北京"}, {"keyword": "南桥路", "offset": 0}, {"keyword": "南桥路", "limit": 10}])
def test_request_rejects_blank_keywords_scope_overrides_and_paging_options(body):
with pytest.raises(ValidationError):
PlaceSearchRequest(**body)
@pytest.mark.parametrize("key", ["TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"])
def test_shared_token_accepts_existing_configuration_names(monkeypatch, key):
for name in ("TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"):
monkeypatch.delenv(name, raising=False)
assert Settings(_env_file=None, **{key: "one-token"}).TIANDITU_TOKEN == "one-token"
+38
View File
@@ -2,9 +2,16 @@ import asyncio
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from app.core.config import Settings, settings
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
SNAPSHOT_BUDGET_SQL = "SELECT set_config('timescaledb.max_tuples_decompressed_per_dml_transaction', %s, true)"
class _FakeCursor:
def __init__(self):
self.calls: list[tuple[str, tuple]] = []
@@ -113,10 +120,12 @@ def test_realtime_node_and_link_replacement_share_outer_transaction(monkeypatch)
assert conn.transactions == 1
assert calls == ["nodes", "links"]
assert [query for query, _params in conn.calls] == [
SNAPSHOT_BUDGET_SQL,
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
"DELETE FROM realtime.node_results WHERE time = %s",
"DELETE FROM realtime.link_results WHERE time = %s",
]
assert conn.calls[0][1] == (str(settings.TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES),)
def test_realtime_batch_rejects_multiple_timestamps_before_writing():
@@ -170,7 +179,36 @@ def test_empty_realtime_side_is_deleted_as_part_of_snapshot_replacement():
)
assert [query for query, _params in conn.calls] == [
SNAPSHOT_BUDGET_SQL,
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
"DELETE FROM realtime.node_results WHERE time = %s",
"DELETE FROM realtime.link_results WHERE time = %s",
]
@pytest.mark.parametrize("side", ["nodes", "links"])
@pytest.mark.parametrize("asynchronous", [False, True])
def test_all_snapshot_batch_writers_use_configured_local_budget(monkeypatch, side, asynchronous):
monkeypatch.setattr(settings, "TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES", 1_200_000)
conn = _AsyncEmptySnapshotConnection() if asynchronous else _SyncTransactionConnection()
if asynchronous:
async def copy(*_):
pass
monkeypatch.setattr(RealtimeRepository, f"_copy_{side}", copy)
else:
monkeypatch.setattr(RealtimeRepository, f"_copy_{side}_sync", lambda *_: None)
method = getattr(RealtimeRepository, f"insert_{side}_batch" + ("" if asynchronous else "_sync"))
rows = [{"time": "2026-10-08T04:00:00Z", "id": "fixture"}]
if asynchronous:
asyncio.run(method(conn, rows))
else:
method(conn, rows)
assert conn.calls[0] == (SNAPSHOT_BUDGET_SQL, ("1200000",))
assert "pg_advisory_xact_lock" in conn.calls[1][0]
assert conn.calls[2][0].startswith("DELETE FROM realtime.")
@pytest.mark.parametrize("value", [0, -1, 2_147_483_648])
def test_snapshot_budget_must_remain_a_finite_positive_postgres_limit(value):
with pytest.raises(ValidationError):
Settings(TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES=value, _env_file=None)
-115
View File
@@ -1,115 +0,0 @@
import asyncio
import importlib.util
from pathlib import Path
import httpx
import pytest
def _load_web_search_module():
module_path = (
Path(__file__).resolve().parents[2] / "app" / "services" / "web_search.py"
)
spec = importlib.util.spec_from_file_location("tests_web_search_under_test", module_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
web_search = _load_web_search_module()
class FakeClient:
def __init__(self, response):
self.response = response
self.calls = []
async def post(self, url, *, headers, json):
self.calls.append({"url": url, "headers": headers, "json": json})
return self.response
def test_search_bocha_web_posts_expected_payload(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
monkeypatch.setattr(
web_search.settings,
"BOCHA_WEB_SEARCH_URL",
"https://api.bochaai.com/v1/web-search",
)
response = httpx.Response(
200,
json={"data": {"webPages": {"value": []}}},
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
)
client = FakeClient(response)
result = asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(
query="天津水务",
freshness="oneWeek",
summary=True,
count=5,
include=["example.com", "news.example.com"],
exclude=["spam.example.com"],
),
client=client,
)
)
assert result == {"data": {"webPages": {"value": []}}}
assert client.calls == [
{
"url": "https://api.bochaai.com/v1/web-search",
"headers": {
"Authorization": "Bearer sk-test",
"Content-Type": "application/json",
},
"json": {
"query": "天津水务",
"freshness": "oneWeek",
"summary": True,
"count": 5,
"include": "example.com,news.example.com",
"exclude": "spam.example.com",
},
}
]
def test_search_bocha_web_requires_api_key(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "")
with pytest.raises(web_search.BochaSearchConfigError):
asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(query="天津水务"),
client=FakeClient(httpx.Response(200, json={})),
)
)
def test_search_bocha_web_surfaces_upstream_error(monkeypatch):
monkeypatch.setattr(web_search.settings, "BOCHA_API_KEY", "sk-test")
response = httpx.Response(
401,
json={"error": "invalid api key"},
request=httpx.Request("POST", "https://api.bochaai.com/v1/web-search"),
)
with pytest.raises(web_search.BochaSearchAPIError) as exc_info:
asyncio.run(
web_search.search_bocha_web(
web_search.WebSearchRequest(query="天津水务"),
client=FakeClient(response),
)
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == {"error": "invalid api key"}
def test_web_search_request_validates_count_range():
with pytest.raises(ValueError):
web_search.WebSearchRequest(query="天津水务", count=51)