feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence, limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs. Default nearby searches to 200 meters and five results. Integrate investigation messaging/presentation, health/readiness and typed history; remove duplicate web search and fix online simulation and compressed baseline writes. Include project migrations 023-025; keep local credentials and demo seeding excluded. Validation: 319 focused pytest checks; real PostGIS temporary-table regression; read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
@@ -0,0 +1,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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user