feat: add project-scoped operations
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent d320972d57
commit 1d061f6099
73 changed files with 4384 additions and 228 deletions
@@ -603,3 +603,16 @@ async def test_remove_project_member_rejects_self_membership_change(monkeypatch)
assert exc.value.status_code == 403
repo.remove_project_member.assert_not_called()
admin_metadata.log_audit_event.assert_not_called()
def test_project_map_config_accepts_search_and_preserves_extra_options():
from pydantic import ValidationError
from app.domain.schemas.admin_metadata import AdminProjectUpdateRequest
config = {'legend': 'original', 'place_search': {
'enabled': True, 'provider': 'tianditu', 'admin_code': '341126', 'region': '凤阳县'}}
request = AdminProjectUpdateRequest(map_config=config)
assert request.model_dump(exclude_unset=True)['map_config'] == config
with pytest.raises(ValidationError):
AdminProjectUpdateRequest(map_config=None)
with pytest.raises(ValidationError):
AdminProjectUpdateRequest(map_config={'place_search': {'enabled': True, 'admin_code': 'wrong', 'region': '凤阳县'}})
@@ -96,3 +96,30 @@ async def test_preconfigured_service_access_avoids_server_delegation(monkeypatch
assert source.await_args.kwargs["delegated"] is False
assert result.evidence.evidence_id == snapshot.source_ref
service.record_data_snapshot.assert_awaited_once()
@pytest.mark.anyio
async def test_report_endpoints_preserve_interactive_blocks_and_version_context(monkeypatch):
from app.domain.schemas.operational_task import AgentArtifactsCommand, InvestigationReportRevision
from tests.unit.test_report_ui import rich_report
report = rich_report()
snapshot = InvestigationReportRevision(
revision=2, execution_generation=1, source_session_id="session-1",
published_at="2026-10-20T08:00:00Z", published_by="agent", report=report,
)
service = SimpleNamespace(report=AsyncMock(return_value=snapshot),
list_reports=AsyncMock(return_value=([snapshot], None)),
publish_artifacts=AsyncMock(return_value={"report_revision": 2}))
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
task_id = uuid4()
command = AgentArtifactsCommand(request_id=uuid4(), source_session_id="session-1", report=report)
await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object())
assert service.publish_artifacts.await_args.args[1].report.ui_blocks[0].block.block_id == "pressure"
result = await endpoints.get_investigation_report(task_id, 2, ctx=_service_context(), conn=object())
restored = InvestigationReportRevision.model_validate_json(result.model_dump_json())
assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
assert restored.revision == 2
schemas = app.openapi()["components"]["schemas"]
assert "ui_blocks" in schemas["InvestigationReportInput"]["properties"]
assert "ui_blocks" in schemas["InvestigationReport"]["properties"]
+79 -3
View File
@@ -1,3 +1,4 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
@@ -7,7 +8,7 @@ from fastapi.testclient import TestClient
from app.api.v1.endpoints import place_search
from app.api.v1.rest_router import api_router
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.auth.project_dependencies import ProjectContext, get_project_context, get_metadata_repository
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchResponse
from app.services.place_search import PlaceSearchError
@@ -17,13 +18,18 @@ def app():
application = FastAPI()
application.include_router(api_router, prefix="/api/v1")
application.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "viewer")
application.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace(
get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config={
"place_search": {"enabled": True, "provider": "tianditu", "admin_code": "310120", "region": "奉贤区"}
}))
)
return application
def test_viewer_can_search_with_existing_project_read_permission(app, monkeypatch):
service = AsyncMock(return_value=PlaceSearchResponse(items=[PlaceSearchItem(
id="place-1", name="南桥路", address="上海市奉贤区南桥路", longitude=121.44, latitude=31.22,
)], total=1))
id="place-1", name="南桥路", address="上海市奉贤区南桥路", longitude=121.44, latitude=31.22, district="奉贤区",
)], total=1, region="奉贤区"))
monkeypatch.setattr(place_search, "search_tianditu_places", service)
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
assert response.status_code == 200
@@ -63,3 +69,73 @@ def test_search_contract_has_typed_results_and_required_project_header(app):
assert operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("PlaceSearchResponse")
assert set(contract["components"]["schemas"]["PlaceSearchRequest"]["properties"]) == {"keyword"}
assert contract["components"]["schemas"]["PlaceSearchResponse"]["properties"]["items"]["maxItems"] == 10
reverse = contract["paths"]["/api/v1/reverse-geocoding-requests"]["post"]
assert any(param["name"] == "X-Project-Id" and param["required"] for param in reverse["parameters"])
assert reverse["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith("PlaceSearchItem")
coordinates = contract["components"]["schemas"]["ReverseGeocodingRequest"]
assert set(coordinates["properties"]) == {"longitude", "latitude"}
assert coordinates["additionalProperties"] is False
@pytest.mark.parametrize('map_config', [{}, {'place_search': None},
{'place_search': {'enabled': False, 'admin_code': '341126', 'region': '凤阳县'}},
{'place_search': {'enabled': True, 'admin_code': 'invalid', 'region': '凤阳县'}}])
def test_unconfigured_or_invalid_project_does_not_search(app, monkeypatch, map_config):
service = AsyncMock()
monkeypatch.setattr(place_search, 'search_tianditu_places', service)
app.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace(
get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config=map_config)))
response = TestClient(app).post('/api/v1/place-search-requests', json={'keyword': '地名'})
assert response.status_code == 503
service.assert_not_awaited()
def test_search_uses_configuration_of_authenticated_project(app, monkeypatch):
project_id = uuid4()
app.dependency_overrides[get_project_context] = lambda: ProjectContext(project_id, 'fengyang_v2', uuid4(), 'viewer')
repository = SimpleNamespace(get_project_by_id=AsyncMock(return_value=SimpleNamespace(
map_config={'place_search': {'enabled': True, 'admin_code': '341126', 'region': '凤阳县'}})))
app.dependency_overrides[get_metadata_repository] = lambda: repository
service = AsyncMock(return_value=PlaceSearchResponse(items=[], total=0, region='凤阳县'))
monkeypatch.setattr(place_search, 'search_tianditu_places', service)
response = TestClient(app).post('/api/v1/place-search-requests', json={'keyword': '地名'})
assert response.status_code == 200 and response.json()['region'] == '凤阳县'
repository.get_project_by_id.assert_awaited_once_with(project_id)
assert service.await_args.kwargs['config'].admin_code == '341126'
def test_viewer_reverse_lookup_uses_project_configuration_and_typed_point(app, monkeypatch):
service = AsyncMock(return_value=PlaceSearchItem(id='map-point', name='南桥路', address='奉贤区南桥路',
longitude=121.45, latitude=30.92, district='奉贤区'))
monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service)
response = TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121.45, 'latitude': 30.92})
assert response.status_code == 200 and response.json()['name'] == '南桥路'
assert service.await_args.args[0].longitude == 121.45
assert service.await_args.kwargs['config'].admin_code == '310120'
@pytest.mark.parametrize('body', [{'longitude': 181, 'latitude': 0},
{'longitude': 121, 'latitude': 91}, {'longitude': 121, 'latitude': 31, 'region': '全国'}])
def test_reverse_lookup_rejects_invalid_requests_before_provider_call(app, monkeypatch, body):
service = AsyncMock()
monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service)
assert TestClient(app).post('/api/v1/reverse-geocoding-requests', json=body).status_code == 422
service.assert_not_awaited()
@pytest.mark.parametrize('role,status', [('none', 403), ('viewer', 503)])
def test_reverse_lookup_requires_project_permission_and_configuration(app, monkeypatch, role, status):
app.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), 'demo', uuid4(), role)
app.dependency_overrides[get_metadata_repository] = lambda: SimpleNamespace(
get_project_by_id=AsyncMock(return_value=SimpleNamespace(map_config={})))
service = AsyncMock()
monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', service)
assert TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121, 'latitude': 31}).status_code == status
service.assert_not_awaited()
@pytest.mark.parametrize('status', [404, 422, 502, 503, 504])
def test_reverse_lookup_preserves_actionable_errors(app, monkeypatch, status):
monkeypatch.setattr(place_search, 'reverse_geocode_tianditu', AsyncMock(side_effect=PlaceSearchError(status, '请重新取点')))
response = TestClient(app).post('/api/v1/reverse-geocoding-requests', json={'longitude': 121, 'latitude': 31})
assert response.status_code == status and response.json()['detail'] == '请重新取点'
@@ -57,3 +57,92 @@ def test_scada_batch_writes_only_after_bizdb_validation(monkeypatch):
assert result == {"message": "Inserted 1 records"}
assert insert.await_args.args[1][0]["device_id"] == "known"
def test_latest_scada_readings_join_device_metadata_with_latest_rows(monkeypatch):
observed_at = datetime(2026, 10, 22, 1, 2, tzinfo=timezone.utc)
monkeypatch.setattr(
scada_endpoint.ScadaInfoRepository,
"get_scadas",
AsyncMock(return_value=[
{
"device_id": "PRESSURE-1",
"device_type": "pressure",
"measurement_unit": "MPa",
"transmission_mode": "realtime",
"transmission_frequency": "15min",
},
{
"device_id": "FLOW-2",
"device_type": "pipe_flow",
"measurement_unit": "m3/h",
"transmission_mode": "realtime",
"transmission_frequency": "15min",
},
]),
)
latest = AsyncMock(return_value={
"PRESSURE-1": {
"time": observed_at,
"device_id": "PRESSURE-1",
"monitored_value": 0.31,
"cleaned_value": 0.3,
}
})
monkeypatch.setattr(
scada_endpoint.ScadaRepository,
"get_latest_scada_readings",
latest,
)
result = asyncio.run(
scada_endpoint.get_latest_scada_readings(
limit=100,
offset=0,
conn=object(),
postgres_conn=object(),
)
)
assert result.total == 2
assert result.observed_count == 1
assert result.latest_observed_at == observed_at
assert result.items[0].monitored_value == pytest.approx(0.31)
assert result.items[1].observed_at is None
assert latest.await_args.args[1] == ["PRESSURE-1", "FLOW-2"]
def test_latest_scada_readings_applies_device_page_before_timeseries_query(monkeypatch):
monkeypatch.setattr(
scada_endpoint.ScadaInfoRepository,
"get_scadas",
AsyncMock(return_value=[
{
"device_id": f"D-{index}",
"device_type": "pressure",
"measurement_unit": "MPa",
"transmission_mode": "realtime",
"transmission_frequency": "15min",
}
for index in range(4)
]),
)
latest = AsyncMock(return_value={})
monkeypatch.setattr(
scada_endpoint.ScadaRepository,
"get_latest_scada_readings",
latest,
)
result = asyncio.run(
scada_endpoint.get_latest_scada_readings(
limit=2,
offset=1,
conn=object(),
postgres_conn=object(),
)
)
assert result.total == 4
assert [item.device_id for item in result.items] == ["D-1", "D-2"]
assert latest.await_args.args[1] == ["D-1", "D-2"]
+41
View File
@@ -0,0 +1,41 @@
import ctypes
import os
from pathlib import Path
import platform
import subprocess
from types import SimpleNamespace
import pytest
from app.infra.epanet.epanet import Output
@pytest.mark.parametrize("pressure_code,expected", [(0, "PSI"), (1, "KPA"), (2, "MTR")])
def test_output_pressure_units_follow_native_enum(pressure_code, expected):
def units(handle, kind, value):
value._obj.value = {1: 5, 2: pressure_code, 3: 0}[kind.value]
return 0
output = Output.__new__(Output)
output._lib = SimpleNamespace(ENR_getUnits=units, ENR_close=lambda *args: 0)
output._handle = ctypes.c_void_p()
output._check = lambda code: None
assert output.units() == {"flow": "LPS", "pressure": expected, "quality": "NONE"}
def test_bundled_epanet_metric_pressure_matches_head_minus_elevation(tmp_path):
binary_dir = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux"
exe = binary_dir / "runepanet"
if platform.system() != "Linux" or not exe.exists():
pytest.skip("Bundled Linux EPANET runner unavailable")
inp, rpt, opt = (tmp_path / name for name in ("model.inp", "model.rpt", "model.opt"))
inp.write_text("[JUNCTIONS]\nJ1 10 1\n[RESERVOIRS]\nR1 100\n"
"[PIPES]\nP1 R1 J1 100 200 100 0 OPEN\n"
"[OPTIONS]\nUNITS LPS\n[END]\n")
env = {**os.environ, "LD_LIBRARY_PATH": str(binary_dir)}
result = subprocess.run([str(exe), str(inp), str(rpt), str(opt)], env=env,
capture_output=True, text=True, timeout=10)
assert result.returncode == 0, result.stdout + result.stderr
output = Output(str(opt))
assert output.units()["pressure"] == "MTR"
row = next(r for r in output.node_results() if r["node"] == "J1")["result"][0]
assert row["pressure"] == pytest.approx(row["head"] - 10, abs=1e-4)
+377
View File
@@ -0,0 +1,377 @@
import asyncio
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timedelta, 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.mcp.catalog import TOOLS
from app.mcp.models import BoundContext, QueryResult
from app.mcp import extended_service, mutation_service, query_service, scenario_service, analysis_service
HANDLE = "x" * 43
START = "2026-10-21T08:00:00+08:00"
def args(tool_name, **values):
return TOOLS[tool_name].model.model_validate({"context_handle": HANDLE, **values})
def binding(**values):
return BoundContext(context_handle=HANDLE, project_id=uuid4(), subject="actor", client_id="host",
native_session_id="session", purpose="ordinary_chat", tool_cap=list(TOOLS),
permission_cap=["webgis.edit", "webgis.view", "scada.clean", "scada.view", "simulation.run"],
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), **values)
@pytest.mark.parametrize("name,values", [
("query_network_extent", {"bounds": [120, 30, 122, 32]}),
("query_network_extent", {"bounds": [121, 31, 120, 30]}),
("query_network_extent", {"bounds": [float("nan"), 31, 122, 32]}),
("calculate_demand_allocation", {"target": "network", "demand": 10, "node_ids": ["J1"]}),
("edit_model_element", {"request_id": str(uuid4()), "operation": "delete", "element_type": "pipe", "element_id": "P1", "dry_run": False}),
("edit_model_element", {"request_id": str(uuid4()), "operation": "update", "element_type": "pipe", "element_id": "P1", "properties": {"id": "other"}}),
("clean_scada_readings", {"request_id": str(uuid4()), "start": START, "end": "2026-10-23T08:00:00+08:00", "device_ids": ["D1"]}),
("run_burst_simulation", {"start_time": START, "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1, 2]}),
])
def test_bounded_schemas_reject_unsafe_inputs(name, values):
with pytest.raises(ValidationError):
args(name, **values)
def test_saved_scenario_has_no_nested_identity_and_validates_timezone():
values = dict(request_id=uuid4(), name="test", scenario="burst", parameters={
"start_time": START, "output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1],
})
valid = args("save_simulation_scenario", **values)
assert "context_handle" not in valid.parameters.model_dump()
values["parameters"]["start_time"] = "2026-10-21T08:00:00"
with pytest.raises(ValidationError):
args("save_simulation_scenario", **values)
values["parameters"]["start_time"] = START
values["parameters"]["network"] = "other-project"
with pytest.raises(ValidationError):
args("save_simulation_scenario", **values)
@pytest.mark.parametrize("purpose,role", [("task_reader", "member"), ("task_execution", "member"), ("task_collaboration", "member"), ("ordinary_chat", "viewer")])
def test_writes_never_enter_background_or_viewer_sessions(monkeypatch, purpose, role):
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.edit"})
bound = binding()
bound.purpose = purpose
scope = SimpleNamespace(project=SimpleNamespace(project_role=role))
with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
assert error.value.status_code == 403
def test_write_requires_current_and_captured_permissions(monkeypatch):
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
bound = binding()
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: set())
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"webgis.edit"})
bound.permission_cap = []
with pytest.raises(HTTPException):
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["edit_model_element"], object()))
def test_extent_runs_real_parameterized_project_query_and_pages(monkeypatch):
read = AsyncMock(return_value=[{"id": "A"}, {"id": "B"}])
monkeypatch.setattr(extended_service, "read_rows", read)
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own"))
result = asyncio.run(extended_service.execute_extended(scope, object(), TOOLS["query_network_extent"],
args("query_network_extent", bounds=[121.0, 31.0, 121.1, 31.1], limit=1)))
assert result.page.has_more and result.truncated
assert result.data["items"] == [{"id": "A"}]
call = read.call_args.args
assert "ST_Intersects" in str(call[1])
assert call[2] == (121.0, 31.0, 121.1, 31.1, 2, 0)
assert call[0] is scope.connection
def test_missing_gis_propagates_unavailable_without_cross_project_retry(monkeypatch):
read = AsyncMock(side_effect=ValueError("GIS unavailable"))
monkeypatch.setattr(extended_service, "read_rows", read)
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="no-gis"))
with pytest.raises(ValueError):
asyncio.run(extended_service.execute_extended(scope, object(), TOOLS["query_network_extent"],
args("query_network_extent", bounds=[121.0, 31.0, 121.1, 31.1])))
assert read.await_count == 1
def test_demand_allocation_is_paged_and_never_sets_demands(monkeypatch):
from app.services import demand_allocation
monkeypatch.setattr(demand_allocation, "calculate_demand_to_nodes", lambda *a: {"B": 6, "A": 4})
values = args("calculate_demand_allocation", target="nodes", node_ids=["A", "B"], demand=10, limit=1)
result = asyncio.run(analysis_service.calculate("calculate_demand_allocation", values, network="own", username="actor"))
assert result == {"items": [{"node_id": "A", "demand": 4}], "total": 2, "limit": 1, "offset": 0, "has_more": True, "persisted": False}
def test_burst_preview_uses_isolated_nonpersistent_adapter(monkeypatch):
from app.services import simulation_scenarios
calls = []
monkeypatch.setattr(simulation_scenarios, "burst_analysis", lambda **kw: calls.append(kw) or {"node_results": [{"node": "J1"}], "link_results": []})
values = args("run_burst_simulation", start_time=START, output_node_ids=["J1"], burst_pipe_ids=["P1"], orifice_areas_cm2=[2])
result = scenario_service.calculate_scenario("run_burst_simulation", values.model_dump(exclude={"context_handle", "timeout_seconds"}), network="own", username="actor")
assert calls[0]["persist_results"] is False and calls[0]["scheme_name"] is None
assert calls[0]["burst_size"] == [2]
assert result["persisted_baseline"] is False
class Connection:
@asynccontextmanager
async def transaction(self):
yield
async def commit(self):
pass
class Journal:
records = {}
def __init__(self, *args):
pass
async def lock_receipt(self, *args):
pass
async def receipt(self, scope, key):
return self.records.get((scope, key))
async def save_receipt(self, scope, key, parameters, result):
self.records[(scope, key)] = {"parameters": parameters, "result": result}
async def finish_receipt(self, scope, key, result):
self.records[(scope, key)]["result"] = result
@pytest.fixture
def mutation(monkeypatch):
Journal.records = {}
monkeypatch.setattr(mutation_service, "McpQueryRepository", Journal)
preview = AsyncMock(return_value={"before": {"diameter": 100}, "properties": {"diameter": 200}})
apply = AsyncMock(return_value={"changed": True})
monkeypatch.setattr(mutation_service, "preview_change", preview)
monkeypatch.setattr(mutation_service, "apply_change", apply)
scope = SimpleNamespace(connection=Connection(), project=SimpleNamespace(project_id=uuid4()))
values = dict(request_id=uuid4(), operation="update", element_type="pipe", element_id="P1", properties={"diameter": 200})
return scope, binding(), values, preview, apply
def test_preview_execution_stale_state_and_idempotent_replay(mutation):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
assert result.data["dry_run"] and not apply.called
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
preview.return_value = {"before": {"diameter": 150}}
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409 and not apply.called
assert Journal.records == {}
preview.return_value = {"before": {"diameter": 100}, "properties": {"diameter": 200}}
result = await mutation_service.execute_mutation(scope, bound, tool, execution)
replay = await mutation_service.execute_mutation(scope, bound, tool, execution)
assert replay == result
assert apply.await_count == 1
asyncio.run(run())
def test_failed_or_timed_out_write_cannot_be_automatically_reexecuted(mutation):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
apply.side_effect = TimeoutError()
with pytest.raises(TimeoutError):
await mutation_service.execute_mutation(scope, bound, tool, execution)
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409
assert apply.await_count == 1
asyncio.run(run())
def test_capacity_rejection_is_retryable_under_the_same_request_after_slots_free(mutation, monkeypatch):
from threading import BoundedSemaphore
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
slots = BoundedSemaphore(2)
monkeypatch.setattr(analysis_service, "_slots", slots)
writes = []
async def bounded_write(*unused):
return await analysis_service.bounded_calculation(lambda: writes.append("written") or {"changed": True})
apply.side_effect = bounded_write
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
assert slots.acquire(blocking=False) and slots.acquire(blocking=False)
try:
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 503
record = next(iter(Journal.records.values()))
assert record["result"]["status"] == "rejected"
assert record["result"]["retryable"] is True
assert record["result"]["error"]["code"] == "CALCULATION_CAPACITY_EXCEEDED"
assert writes == []
finally:
slots.release()
slots.release()
saved = await mutation_service.execute_mutation(scope, bound, tool, execution)
replay = await mutation_service.execute_mutation(scope, bound, tool, execution)
assert replay == saved and writes == ["written"]
assert next(iter(Journal.records.values()))["result"]["status"] == "completed"
asyncio.run(run())
@pytest.mark.parametrize("failure", [TimeoutError(), asyncio.CancelledError(), RuntimeError("native failure"), HTTPException(503, "unrelated source failure")])
def test_ambiguous_write_failure_remains_started_and_cannot_be_retried(mutation, failure):
scope, bound, values, preview, apply = mutation
tool = TOOLS["edit_model_element"]
async def run():
result = await mutation_service.execute_mutation(scope, bound, tool, args(tool.name, **values))
execution = args(tool.name, **values, dry_run=False, expected_digest=result.data["expected_digest"])
apply.side_effect = failure
with pytest.raises(type(failure)):
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert next(iter(Journal.records.values()))["result"]["status"] == "started"
with pytest.raises(HTTPException) as error:
await mutation_service.execute_mutation(scope, bound, tool, execution)
assert error.value.status_code == 409 and apply.await_count == 1
asyncio.run(run())
def test_native_model_lock_revalidates_preview_and_only_writes_allowed_fields(monkeypatch):
from app.services import tjnetwork
from app.native.wndb.core import database
state = {"id": "P1", "diameter": 100}
monkeypatch.setattr(tjnetwork, "get_pipe", lambda *a: dict(state))
monkeypatch.setattr(tjnetwork, "get_pipe_schema", lambda *a: {"id": {"readonly": True}, "diameter": {"type": "float", "readonly": False}})
@contextmanager
def lock(*a):
yield
monkeypatch.setattr(database, "model_mutation_transaction", lock)
writes = []
monkeypatch.setattr(tjnetwork, "set_pipe", lambda network, changes: writes.append(changes.operations) or changes)
values = dict(request_id=uuid4(), operation="update", element_type="pipe", element_id="P1", properties={"diameter": 200})
preview = mutation_service.model_change("own", "edit_model_element", args("edit_model_element", **values), execute=False)
execution = args("edit_model_element", **values, dry_run=False, expected_digest=mutation_service.digest(preview))
state["diameter"] = 110
with pytest.raises(HTTPException):
mutation_service.model_change("own", "edit_model_element", execution, execute=True)
assert not writes
state["diameter"] = 100
mutation_service.model_change("own", "edit_model_element", execution, execute=True)
assert writes == [[{"id": "P1", "diameter": 200}]]
def test_artifacts_are_integrity_checked_bounded_binary_envelopes():
result = extended_service.artifact("model.zip", b"zip-data", "application/zip")
assert result["file"]["size"] == 8
assert len(result["file"]["sha256"]) == 64
with pytest.raises(HTTPException) as error:
extended_service.artifact("model.zip", bytes(5 * 1024 * 1024 + 1), "application/zip")
assert error.value.status_code == 413
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
assert not any("health" in name for name in TOOLS)
assert len(TOOLS) == 67
assert sum(t.mutating for t in TOOLS.values()) == 13
for tool in TOOLS.values():
if tool.mutating:
assert not tool.protocol_tool().annotations.read_only_hint
assert tool.protocol_tool().annotations.destructive_hint
@pytest.mark.parametrize("scenario,parameters,function", [
("burst", {"burst_pipe_ids": ["P1"], "orifice_areas_cm2": [1]}, "burst_analysis"),
("contaminant", {"source": "J2", "concentration": 1}, "contaminant_simulation"),
("valve_closure", {"valves": ["V1"]}, "valve_close_analysis"),
])
def test_formal_scenarios_bind_actor_and_persist_only_through_existing_services(monkeypatch, scenario, parameters, function):
from app.services import simulation_scenarios
calls = []
monkeypatch.setattr(simulation_scenarios, function, lambda **kw: calls.append(kw))
payload = {"start_time": START, "duration": 900, "output_node_ids": ["J1"], **parameters}
values = args("save_simulation_scenario", request_id=uuid4(), name="formal-test", scenario=scenario, parameters=payload)
mutation_service.saved_scenario("owned-project", "verified-user", values)
assert calls[0]["name"] == "owned-project"
assert calls[0]["username"] == "verified-user"
assert calls[0]["persist_results"] is True
assert calls[0]["scheme_name"] == "formal-test"
def test_mutation_status_is_scoped_to_bound_identity_session_and_project(monkeypatch):
calls = []
class Repository:
def __init__(self, conn, project):
calls.append(project)
async def receipt(self, scope_key, request_id):
calls.extend([scope_key, request_id])
return {"parameters": {"tool": "edit_model_element"}, "result": {"status": "started"}}
import app.infra.db.postgresql.mcp_queries as storage
monkeypatch.setattr(storage, "McpQueryRepository", Repository)
bound = binding()
request_id = uuid4()
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own", project_id=bound.project_id))
result = asyncio.run(extended_service.execute_extended(scope, bound, TOOLS["get_mutation_status"], args("get_mutation_status", request_id=request_id)))
assert calls == [bound.project_id, "mutation:actor:session", str(request_id)]
assert result.data["status"] == "started"
def test_mutation_status_exposes_rejected_capacity_and_retryability(monkeypatch):
class Repository:
def __init__(self, *unused):
pass
async def receipt(self, *unused):
return {"parameters": {"tool": "save_simulation_scenario"}, "result": {
"status": "rejected", "retryable": True,
"error": {"code": "CALCULATION_CAPACITY_EXCEEDED", "message": "calculation not started"},
}}
import app.infra.db.postgresql.mcp_queries as storage
monkeypatch.setattr(storage, "McpQueryRepository", Repository)
bound = binding()
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_code="own", project_id=bound.project_id))
result = asyncio.run(extended_service.execute_extended(scope, bound, TOOLS["get_mutation_status"],
args("get_mutation_status", request_id=uuid4())))
assert result.data["status"] == "rejected" and result.data["retryable"] is True
assert result.data["error"]["code"] == "CALCULATION_CAPACITY_EXCEEDED"
def test_scada_preview_and_journal_distinguish_omitted_fields_from_explicit_null():
values = dict(request_id=uuid4(), operation="update", provenance="operator correction",
records=[{"device_id": "D1", "time": START, "monitored_value": 1}])
original = args("write_scada_readings", **values)
values["records"][0]["cleaned_value"] = None
clearing = args("write_scada_readings", **values)
for preview in [True, False]:
left = mutation_service.mutation_arguments(original, preview=preview)
right = mutation_service.mutation_arguments(clearing, preview=preview)
assert "cleaned_value" not in left["records"][0]
assert right["records"][0]["cleaned_value"] is None
assert mutation_service.digest(left) != mutation_service.digest(right)
def test_nested_model_properties_cannot_hide_nonfinite_or_unknown_fields():
schema = {"demands": {"type": "list", "readonly": False, "element": {"demand": {"type": "float", "readonly": False}}}}
for value in [{"demand": float("nan")}, {"demand": 1, "url": "https://foreign"}]:
with pytest.raises(HTTPException) as error:
mutation_service.check_properties(schema, {"demands": [value]})
assert error.value.status_code == 422
+28 -2
View File
@@ -80,6 +80,32 @@ def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime):
assert calls == []
def test_calculation_capacity_has_an_explicit_mcp_error(runtime, monkeypatch):
from threading import BoundedSemaphore
from app.mcp import analysis_service
app, client, _, _ = runtime
monkeypatch.setattr(application, "authorize_query", AsyncMock())
slots = BoundedSemaphore(2)
monkeypatch.setattr(analysis_service, "_slots", slots)
async def execute(*unused):
return await analysis_service.bounded_calculation(lambda: pytest.fail("occupied calculation entered"))
app.executor = execute
assert slots.acquire(blocking=False) and slots.acquire(blocking=False)
try:
response = request(client, "tools/call", {"name": "run_simulation", "arguments": {
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"],
}}).json()
finally:
slots.release()
slots.release()
error = response["result"]["structuredContent"]["error"]
assert response["result"]["isError"] is True
assert error["code"] == "CALCULATION_CAPACITY_EXCEEDED"
assert "not started" in error["message"]
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch):
app, client, calls, _ = runtime
monkeypatch.setattr(application, "authorize_query", AsyncMock())
@@ -286,10 +312,10 @@ def test_modern_discovery_catalog_and_metadata(runtime):
assert result["result"]["resultType"] == "complete"
assert "2026-07-28" in result["result"]["supportedVersions"]
listed = request(client).json()["result"]
assert len(listed["tools"]) == 35
assert len(listed["tools"]) == len(TOOLS)
for tool in listed["tools"]:
timeout = tool["inputSchema"]["properties"].get("timeout_seconds")
assert (timeout is not None) == TOOLS[tool["name"]].computation
assert (timeout is not None) == (TOOLS[tool["name"]].computation or TOOLS[tool["name"]].mutating)
if timeout:
assert timeout["default"] == 120
assert timeout["maximum"] == 3600
@@ -85,3 +85,17 @@ async def test_create_provisioned_project_commits_project_routes_and_membership_
assert added[3].user_id == user_id
session.commit.assert_awaited_once()
session.refresh.assert_awaited_once_with(project)
@pytest.mark.anyio
async def test_update_map_config_preserves_legacy_settings_and_other_projects():
project = SimpleNamespace(map_config={'legend': 'original', 'coordinate_reference': {'source_srid': 990001}})
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
repository = MetadataRepository(session)
repository.get_project_by_id = AsyncMock(return_value=project)
project_id = uuid4()
search = {'enabled': True, 'provider': 'tianditu', 'admin_code': '341126', 'region': '凤阳县'}
await repository.update_project(project_id, updates={'map_config': {'place_search': search}})
repository.get_project_by_id.assert_awaited_once_with(project_id)
assert project.map_config == {'legend': 'original', 'coordinate_reference': {'source_srid': 990001}, 'place_search': search}
session.commit.assert_awaited_once()
+39
View File
@@ -1803,3 +1803,42 @@ async def test_repository_task_update_rejects_a_stale_revision():
await repository.update_task(task)
assert exc.value.code == "VERSION_CONFLICT"
@pytest.mark.anyio
async def test_interactive_report_publication_is_idempotent_and_versioned_with_evidence():
from tests.unit.test_report_ui import rich_report
now = datetime(2026, 10, 20, 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)
report = {**rich_report(), "stage": "final"}
command = AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
evidence=[{"evidence_id": "demo-1", "kind": "synthetic", "summary": "演练压力记录",
"source_label": "演练监测", "observed_at": now.isoformat()}],
)
first = await service.publish_artifacts(task["task_id"], command)
replay = await service.publish_artifacts(task["task_id"], command)
assert first.report_revision == replay.report_revision == 1
report["ui_blocks"][0]["block"]["spec"]["elements"]["chart"]["props"]["series"][0]["data"] = [0.3, 0.29]
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
))
old = await service.report(task["task_id"], 1)
latest = await service.detail(task["task_id"])
history, _ = await service.list_reports(task["task_id"], 10, 0)
assert old.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
assert latest.latest_report.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.29]
assert [item.revision for item in history] == [2, 1]
assert latest.latest_report.report.evidence_refs == ["demo-1"]
assert len(repository.notifications) == 2
report["evidence_refs"] = ["foreign"]
report["ui_blocks"][0]["evidence_refs"] = ["foreign"]
with pytest.raises(DomainError) as error:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
))
assert error.value.code == "EVIDENCE_NOT_FOUND"
@@ -0,0 +1,212 @@
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import UUID, uuid4
import pytest
from fastapi.testclient import TestClient
from psycopg.conninfo import conninfo_to_dict
from app.auth.project_dependencies import get_metadata_repository
from app.infra.db.project_routing import ActiveProjectRouting
from samples.peripheral import auth
from samples.peripheral.api import create_app
from samples.peripheral.config import SampleSettings
from samples.peripheral.errors import SampleFailure
from samples.peripheral.store import PostgresStore
@pytest.fixture
def project_api(monkeypatch):
projects = {str(uuid4()): "tjwater_v2", str(uuid4()): "fengyang_v2"}
connections = []
permissions = ["simulation.view"]
missing = set()
route_overrides = {}
class Repository:
async def get_project_db_routing(self, project_id, role):
code = projects[str(project_id)]
if (code, role) in missing:
return None
database = code + ("_history" if role == "iot_data" else "_business")
return SimpleNamespace(**({
"dsn": f"postgresql://test@localhost/{database}",
"db_type": "timescaledb" if role == "iot_data" else "postgresql",
} | route_overrides.get((code, role), {})))
class Connection:
def __init__(self, database):
self.database = database
self.info = SimpleNamespace(dbname=database)
self.rows = []
self.readonly = False
self.statements = []
def execute(self, statement, parameters=None):
self.statements.append((statement, parameters))
self.rows = []
if statement == "SET TRANSACTION READ ONLY":
self.readonly = True
if "current_database()" in statement:
self.rows = [{"name": self.database}]
elif "peripheral_sample.scenarios" in statement:
self.rows = []
elif "to_regclass('asset.scada_devices')" in statement:
self.rows = [{"table_name": "asset.scada_devices"}]
elif "FROM asset.scada_devices" in statement:
self.rows = [{"device_id": self.database, "device_type": "pressure",
"measurement_unit": "MPa", "api_query_id": "point-1"}]
elif "FROM scada.measurements" in statement:
self.rows = [{"time": datetime(2026, 10, 21, tzinfo=timezone.utc), "monitored_value": 0.3}]
elif "to_regclass('workbench.domain_event_log')" in statement:
self.rows = [{"event_log": "workbench.domain_event_log"}]
elif "RETURNING event_seq" in statement:
self.rows = [{"event_seq": 42}]
return self
def fetchone(self):
return self.rows[0] if self.rows else None
def fetchall(self):
return self.rows
@contextmanager
def connect(*, conninfo, **kwargs):
connection = Connection(conninfo_to_dict(conninfo)["dbname"])
connections.append(connection)
yield connection
monkeypatch.setattr("samples.peripheral.store.psycopg.connect", connect)
monkeypatch.setattr(auth, "next_get", lambda request, path: {
"project_id": request.headers["x-project-id"], "user_id": str(uuid4()),
"network": projects[request.headers["x-project-id"]], "permissions": permissions,
})
app = create_app(SampleSettings())
app.dependency_overrides[get_metadata_repository] = lambda: Repository()
with TestClient(app) as client:
yield SimpleNamespace(client=client, projects=projects, connections=connections,
permissions=permissions, missing=missing, route_overrides=route_overrides)
def headers(project):
return {"Authorization": "Bearer TEST_ONLY", "X-Project-Id": project}
def test_concurrent_project_reads_use_separate_metadata_databases(project_api):
api = project_api
def read(project):
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
assert response.status_code == 200, response.text
return project, response.json()
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(read, list(api.projects) * 3))
for project, payload in results:
database = api.projects[project] + "_business"
assert payload["source_id"] == database + ":asset.scada_devices"
assert payload["items"][0]["point_id"] == database
assert all(connection.readonly for connection in api.connections)
def test_history_uses_separate_project_timescale_route(project_api):
api = project_api
project, code = list(api.projects.items())[1]
response = api.client.get("/api/sample/v1/monitoring/observations", headers=headers(project), params={
"point_id": "point-1", "start": "2026-10-21T00:00:00Z", "end": "2026-10-22T00:00:00Z",
})
assert response.status_code == 200, response.text
assert response.json()["items"][0]["value"] == 0.3
assert response.json()["source_id"] == code + "_history:scada.measurements"
assert [connection.database for connection in api.connections] == [code + "_business", code + "_history"]
assert all(connection.readonly for connection in api.connections)
def test_missing_routing_never_falls_back_to_default_database(project_api):
api = project_api
project, code = list(api.projects.items())[1]
api.missing.add((code, "biz_data"))
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
assert response.status_code == 503
assert response.json()["code"] == "PROJECT_STORAGE_NOT_CONFIGURED"
assert not api.connections
def test_work_order_writes_and_events_use_the_selected_project(project_api):
api = project_api
api.permissions.append("simulation.run")
project, code = list(api.projects.items())[1]
response = api.client.post("/api/sample/v1/work-orders", headers=headers(project), json={
"request_id": str(uuid4()), "title": "routing test", "summary": "test only",
})
assert response.status_code == 201, response.text
assert response.json()["project_id"] == project
assert len(api.connections) == 1
connection = api.connections[0]
assert connection.database == code + "_business"
assert not connection.readonly
event = next(params for sql, params in connection.statements if "INSERT INTO workbench.domain_event_log" in sql)
assert event[0] == project
assert event[2] == response.json()["work_order_id"]
@pytest.mark.parametrize("override", [
{"dsn": "postgresql://test@localhost"}, {"dsn": "not-a-valid-dsn"}, {"db_type": "timescaledb"},
])
def test_invalid_project_routes_fail_without_opening_storage(project_api, override):
api = project_api
project, code = list(api.projects.items())[1]
api.route_overrides[(code, "biz_data")] = override
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
assert response.status_code == 503
assert response.json()["code"] == "PROJECT_STORAGE_NOT_CONFIGURED"
assert not api.connections
def test_crud_reads_do_not_require_timescale_configuration(project_api):
api = project_api
project, code = list(api.projects.items())[1]
api.missing.add((code, "iot_data"))
response = api.client.get("/api/sample/v1/monitoring/points", headers=headers(project))
assert response.status_code == 200
unavailable = api.client.get("/api/sample/v1/monitoring/observations", headers=headers(project), params={
"point_id": "point-1", "start": "2026-10-21T00:00:00Z", "end": "2026-10-22T00:00:00Z",
})
assert unavailable.status_code == 503
@pytest.mark.parametrize("method,permissions", [("GET", []), ("POST", ["simulation.view"])])
def test_project_routing_preserves_read_and_write_permissions(project_api, method, permissions):
api = project_api
api.permissions[:] = permissions
response = api.client.request(method, "/api/sample/v1/scenario", headers=headers(next(iter(api.projects))))
assert response.status_code == 403
assert response.json()["code"] == "PERMISSION_DENIED"
assert not api.connections
def test_mismatched_identity_is_rejected_before_storage(project_api, monkeypatch):
monkeypatch.setattr(auth, "next_get", lambda request, path: {
"project_id": str(uuid4()), "user_id": str(uuid4()), "network": "fengyang_v2",
"permissions": ["simulation.view", "simulation.run"],
})
response = project_api.client.get("/api/sample/v1/context", headers=headers(next(iter(project_api.projects))))
assert response.status_code == 403
assert response.json()["code"] == "PROJECT_MISMATCH"
assert not project_api.connections
def test_routed_connection_does_not_use_legacy_settings_or_missing_timescale(project_api):
project = next(iter(project_api.projects))
routing = ActiveProjectRouting("logical-project", "dbname=physical-business", project_id=UUID(project))
store = PostgresStore(SampleSettings(db_name="unrelated"), routing=routing)
assert store.settings.db_name == "physical-business"
with store.connection(readonly=True):
pass
with pytest.raises(SampleFailure) as failure:
with store.connection(readonly=True, timeseries=True):
pass
assert failure.value.code == "PROJECT_STORAGE_NOT_CONFIGURED"
assert [connection.database for connection in project_api.connections] == ["physical-business"]
@@ -10,27 +10,29 @@ from samples.peripheral.store import PostgresStore
class EventConnection:
def __init__(self):
self.statements = []
self.event_log = True
def execute(self, statement, parameters):
def execute(self, statement, parameters=None):
self.statements.append((statement, parameters))
return self
def fetchone(self):
return {"event_seq": 42}
return {"event_seq": 42, "event_log": self.event_log}
@pytest.mark.parametrize(("revision", "deleted", "change"), [
(1, False, "created"), (2, False, "updated"), (3, True, "deleted"),
])
def test_work_order_signals_use_same_connection_and_contain_only_identity(revision, deleted, change):
@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "physical-project-db"])
def test_work_order_signals_use_same_connection_and_contain_only_identity(revision, deleted, change, database):
conn = EventConnection()
who = Identity(str(uuid4()), str(uuid4()), True)
order = {"work_order_id": str(uuid4()), "revision": revision,
"updated_at": "2026-10-08T00:00:00Z", "title": "private title"}
PostgresStore(SampleSettings())._work_order_event(conn, who, order, deleted=deleted)
PostgresStore(SampleSettings(db_name=database))._work_order_event(conn, who, order, deleted=deleted)
statement, parameters = conn.statements[0]
statement, parameters = conn.statements[1]
assert "workbench.domain_event_log" in statement
assert "'workbench.work_order.changed','peripheral_work_order'" in statement
assert parameters[0] == who.project_id
@@ -38,13 +40,14 @@ def test_work_order_signals_use_same_connection_and_contain_only_identity(revisi
assert parameters[5].obj == {
"source": "peripheral_sample", "work_order_id": order["work_order_id"], "change": change,
}
assert conn.statements[1] == (
assert conn.statements[2] == (
"SELECT pg_notify('workbench_events', %s)", (f"{who.project_id}:42",),
)
def test_event_rollout_does_not_write_to_other_databases():
def test_event_publication_does_not_create_missing_storage():
conn = EventConnection()
conn.event_log = False
store = PostgresStore(SampleSettings(db_name="another_project"))
store._work_order_event(conn, Identity(str(uuid4()), str(uuid4()), True), {})
assert conn.statements == []
assert conn.statements == [("SELECT to_regclass('workbench.domain_event_log') AS event_log", None)]
+39 -7
View File
@@ -8,6 +8,9 @@ from pydantic import ValidationError
from app.core.config import Settings, settings
from app.domain.schemas.place_search import PlaceSearchRequest
from app.domain.schemas.project_map_config import PlaceSearchConfig
CONFIG = PlaceSearchConfig(enabled=True, admin_code="310120", region="奉贤区")
from app.services.place_search import PlaceSearchError, search_tianditu_places
@@ -44,7 +47,7 @@ def shared_token(monkeypatch):
def test_search_fixes_fengxian_scope_and_normalizes_candidates():
client = FakeClient()
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), client=client))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), config=CONFIG, client=client))
url, params = client.calls[0]
assert url == "https://api.tianditu.gov.cn/v2/search"
assert params["tk"] == "shared-token"
@@ -60,14 +63,14 @@ def test_search_fixes_fengxian_scope_and_normalizes_candidates():
@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)))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="未知地名"), config=CONFIG, 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))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, 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"
@@ -90,7 +93,7 @@ def test_search_only_returns_first_ten_results(matched_total, size, truncated):
])
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)))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=FakeClient(data)))
assert exc.value.status_code == 502
@@ -98,7 +101,7 @@ 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))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client))
assert exc.value.status_code == 503 and client.calls == []
@@ -108,7 +111,7 @@ def test_http_request_logs_redact_the_shared_token(caplog):
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)
await search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client)
asyncio.run(run())
messages = [record.getMessage() for record in caplog.records if record.name == "httpx"]
@@ -122,7 +125,7 @@ def test_http_request_logs_redact_the_shared_token(caplog):
])
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)))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=FakeClient(error=failure)))
assert exc.value.status_code == status
assert "secret-token" not in exc.value.detail
@@ -138,3 +141,32 @@ 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"
@pytest.mark.parametrize('admin_code,region,county_code,county,province', [
('341126', '凤阳县', '156341126', '凤阳县', '156340000'),
('320506', '吴中区', '320506', '吴中区', '320000'),
('440800', '湛江市', '156440803', '霞山区', '156440000'),
('310000', '上海市', '156310118', '青浦区', '156310000'),
])
def test_search_uses_project_scope_for_districts_and_cities(admin_code, region, county_code, county, province):
config = PlaceSearchConfig(enabled=True, admin_code=admin_code, region=region)
client = FakeClient(payload(pois=[poi(countyCode=county_code, county=county, provinceCode=province)]))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), config=config, client=client))
assert json.loads(client.calls[0][1]['postStr'])['specify'] == '156' + admin_code
assert result.region == region and result.items[0].district == county
def test_configured_city_rejects_other_city_in_same_province():
config = PlaceSearchConfig(enabled=True, admin_code='440800', region='湛江市')
with pytest.raises(PlaceSearchError, match='无效'):
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), config=config,
client=FakeClient(payload(pois=[poi(countyCode='156440104', county='越秀区', provinceCode='156440000')]))))
def test_disabled_project_does_not_call_provider():
client = FakeClient()
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'),
config=CONFIG.model_copy(update={'enabled': False}), client=client))
assert exc.value.status_code == 503 and client.calls == []
+74
View File
@@ -0,0 +1,74 @@
from copy import deepcopy
import json
import pytest
from pydantic import ValidationError
from app.domain.schemas.operational_task import InvestigationReportInput, InvestigationReportRevision
def rich_report():
return {
"title": "演练压力核验", "current_conclusion": "待核验", "conclusion_boundary": "演练记录",
"stage": "interim", "outcome": "concluded", "data_gaps": [], "evidence_refs": ["demo-1"],
"ui_blocks": [{
"evidence_refs": ["demo-1"],
"block": {
"version": "answer-ui@1", "block_id": "pressure", "fallback_text": "演练压力 0.27 MPa",
"spec": {"root": "chart", "elements": {
"chart": {"type": "Chart", "children": [], "props": {
"title": "演练压力", "chart_type": "line", "x_data": ["08:00", "09:00"],
"series": [{"name": "压力 MPa", "data": [0.3, 0.27]}], "source": "演练记录",
}},
}},
},
}],
}
def test_report_ui_round_trips_with_a_version_and_keeps_legacy_reports_readable():
report = InvestigationReportInput.model_validate(rich_report())
snapshot = InvestigationReportRevision(
revision=3, execution_generation=2, source_session_id="test",
published_at="2026-10-20T08:00:00Z", published_by="test", report=report,
)
restored = InvestigationReportRevision.model_validate_json(snapshot.model_dump_json())
assert restored.report.ui_blocks[0].block.spec.elements["chart"].props["series"][0]["data"] == [0.3, 0.27]
assert (restored.revision, restored.execution_generation) == (3, 2)
old = json.loads(snapshot.model_dump_json())
del old["report"]["ui_blocks"]
assert InvestigationReportRevision.model_validate(old).report.ui_blocks == []
@pytest.mark.parametrize("defect", ["unknown", "script", "unaligned", "nan", "cycle", "unreachable", "duplicate", "foreign", "many", "oversized"])
def test_report_ui_rejects_invalid_or_unbound_content(defect):
data = rich_report()
entry = data["ui_blocks"][0]
spec = entry["block"]["spec"]
chart = spec["elements"]["chart"]
if defect == "unknown":
chart["type"] = "iframe"
elif defect == "script":
chart["props"]["onClick"] = "execute()"
elif defect == "unaligned":
chart["props"]["series"][0]["data"] = [1]
elif defect == "nan":
chart["props"]["series"][0]["data"][0] = float("nan")
elif defect == "cycle":
spec["elements"]["chart"] = {"type": "Stack", "props": {"columns": "one", "gap": "normal"}, "children": ["chart"]}
elif defect == "unreachable":
spec["elements"]["unused"] = deepcopy(chart)
elif defect == "duplicate":
data["ui_blocks"].append(deepcopy(entry))
elif defect == "foreign":
entry["evidence_refs"] = ["other-task"]
elif defect == "many":
data["ui_blocks"] = [{**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": f"b{i}"}} for i in range(9)]
elif defect == "oversized":
# Individually valid blocks can still exceed the UTF-8 report budget.
elements = {"root": {"type": "Stack", "props": {"columns": "one", "gap": "normal"}, "children": [f"n{i}" for i in range(20)]}}
elements.update({f"n{i}": {"type": "Text", "props": {"text": "资" * 3900, "tone": "neutral"}, "children": []} for i in range(20)})
entry["block"]["spec"] = {"root": "root", "elements": elements}
data["ui_blocks"] = [entry, {**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": "second"}}]
with pytest.raises(ValidationError):
InvestigationReportInput.model_validate(data)
+110
View File
@@ -0,0 +1,110 @@
import asyncio
import json
import logging
import httpx
import pytest
from pydantic import ValidationError
from app.core.config import settings
from app.domain.schemas.place_search import ReverseGeocodingRequest
from app.domain.schemas.project_map_config import PlaceSearchConfig
from app.services.place_search import PlaceSearchError
from app.services.reverse_geocoding import reverse_geocode_tianditu
CONFIG = PlaceSearchConfig(enabled=True, admin_code='341126', region='凤阳县')
POINT = ReverseGeocodingRequest(longitude=117.570309, latitude=32.868029)
def provider_result(**component):
return {'status': '0', 'result': {
'formatted_address': '安徽省滁州市凤阳县府城镇朝阳路76号',
'location': {'lon': 117.57, 'lat': 32.86},
'addressComponent': {'poi': '府城镇', 'road': '朝阳路', 'county': '凤阳县',
'county_code': '156341126', 'province_code': '156340000', **component}}}
@pytest.fixture(autouse=True)
def token(monkeypatch):
monkeypatch.setattr(settings, 'TIANDITU_TOKEN', 'test-only-token')
def lookup(data=None, *, config=CONFIG, status=200, failure=None):
calls = []
def transport(request):
calls.append(request)
if failure:
raise failure
return httpx.Response(status, json=provider_result() if data is None else data)
async def run():
async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as client:
return await reverse_geocode_tianditu(POINT, config=config, client=client)
return asyncio.run(run()), calls
def test_reverse_lookup_keeps_clicked_coordinate_and_normalizes_the_address():
result, calls = lookup()
assert result.name == '府城镇' and result.address == '安徽省滁州市凤阳县府城镇朝阳路76号'
assert (result.longitude, result.latitude) == (POINT.longitude, POINT.latitude)
assert result.district == '凤阳县' and result.category == '附近地名'
params = calls[0].url.params
assert params['type'] == 'geocode' and params['tk'] == 'test-only-token'
assert json.loads(params['postStr']) == {'lon': POINT.longitude, 'lat': POINT.latitude, 'ver': 1}
def test_reverse_lookup_supports_city_scope_and_falls_back_to_real_road_name():
result, _ = lookup(provider_result(poi='', county='青浦区', county_code='156310118', province_code='156310000'),
config=PlaceSearchConfig(enabled=True, admin_code='310000', region='上海市'))
assert result.name == '朝阳路' and result.category == '地址' and result.district == '青浦区'
@pytest.mark.parametrize('data,status', [
({'status': '0', 'result': None}, 404),
({'status': '0', 'result': {'formatted_address': ''}}, 404),
({'status': '1'}, 502),
({'status': '0', 'result': 'bad'}, 502),
(provider_result(county='奉贤区', county_code='156310120', province_code='156310000'), 422),
(provider_result(county='定远县', county_code='156341125'), 422),
])
def test_reverse_lookup_distinguishes_no_data_bad_response_and_out_of_scope(data, status):
with pytest.raises(PlaceSearchError) as exc:
lookup(data)
assert exc.value.status_code == status
@pytest.mark.parametrize('failure,status', [(httpx.ReadTimeout('private-url'), 504), (httpx.ConnectError('private-url'), 502)])
def test_reverse_lookup_sanitizes_transport_failures(failure, status):
with pytest.raises(PlaceSearchError) as exc:
lookup(failure=failure)
assert exc.value.status_code == status and 'private-url' not in exc.value.detail
def test_reverse_lookup_redacts_provider_key_from_http_logs(caplog):
caplog.set_level(logging.INFO, logger='httpx')
lookup()
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('test-only-token' not in message for message in messages)
@pytest.mark.parametrize('body', [
{'longitude': 181, 'latitude': 32}, {'longitude': 117, 'latitude': -91},
{'longitude': float('nan'), 'latitude': 32}, {'longitude': 117, 'latitude': float('inf')},
{'longitude': 117, 'latitude': 32, 'region': '全国'},
])
def test_reverse_lookup_rejects_invalid_coordinates_and_scope_overrides(body):
with pytest.raises(ValidationError):
ReverseGeocodingRequest(**body)
def test_reverse_lookup_requires_configuration_and_shared_key(monkeypatch):
with pytest.raises(PlaceSearchError) as exc:
lookup(config=CONFIG.model_copy(update={'enabled': False}))
assert exc.value.status_code == 503
monkeypatch.setattr(settings, 'TIANDITU_TOKEN', '')
with pytest.raises(PlaceSearchError) as exc:
lookup()
assert exc.value.status_code == 503
+58
View File
@@ -49,6 +49,32 @@ class _FakeConnection:
return self.cursor_instance
class _ReadingCursor:
def __init__(self, rows):
self.rows = rows
self.calls: list[tuple[str, tuple]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def execute(self, query, params):
self.calls.append((str(query), params))
async def fetchall(self):
return self.rows
class _ReadingConnection:
def __init__(self, rows):
self.cursor_instance = _ReadingCursor(rows)
def cursor(self):
return self.cursor_instance
def test_update_scada_field_inserts_when_update_hits_no_rows():
ScadaRepository = _load_scada_repository()
conn = _FakeConnection(initial_rowcount=0)
@@ -114,3 +140,35 @@ def test_update_scada_field_batch_uses_one_set_based_statement():
["170490", "170491"],
[26.5, 27.0],
)
def test_get_latest_scada_readings_uses_canonical_ids_and_one_bounded_query():
ScadaRepository = _load_scada_repository()
observed_at = datetime(2026, 10, 22, 1, 2, tzinfo=timezone.utc)
conn = _ReadingConnection([
{
"time": observed_at,
"device_id": "D-1",
"monitored_value": 0.31,
"cleaned_value": 0.3,
}
])
result = asyncio.run(
ScadaRepository.get_latest_scada_readings(conn, ["D-1", "D-2"])
)
assert result["D-1"]["time"] == observed_at
assert len(conn.cursor_instance.calls) == 1
query, params = conn.cursor_instance.calls[0]
assert "DISTINCT ON (device_id)" in query
assert "ORDER BY device_id, time DESC" in query
assert params == (["D-1", "D-2"],)
def test_get_latest_scada_readings_skips_database_for_empty_page():
ScadaRepository = _load_scada_repository()
conn = _ReadingConnection([])
assert asyncio.run(ScadaRepository.get_latest_scada_readings(conn, [])) == {}
assert conn.cursor_instance.calls == []
+8 -14
View File
@@ -19,8 +19,8 @@ def anyio_backend():
@pytest.mark.anyio
@pytest.mark.parametrize("repository", [OperationalTaskRepository, TaskAlertRepository])
@pytest.mark.parametrize("database", ["tjwater_v2", "other_project"])
async def test_event_order_lock_precedes_business_writes_and_is_v2_only(repository, database):
@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "other_project"])
async def test_event_order_lock_precedes_business_writes_in_each_project_database(repository, database):
@asynccontextmanager
async def transaction():
yield
@@ -29,21 +29,15 @@ async def test_event_order_lock_precedes_business_writes_and_is_v2_only(reposito
info=SimpleNamespace(dbname=database), execute=AsyncMock(), transaction=transaction,
)
async with repository(connection, uuid4()).transaction():
if database == "tjwater_v2":
connection.execute.assert_awaited_once_with(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,),
)
else:
connection.execute.assert_not_awaited()
connection.execute.assert_awaited_once_with(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,),
)
def test_sync_producers_use_same_database_scoped_lock():
connection = SimpleNamespace(info=SimpleNamespace(dbname="tjwater_v2"), execute=Mock())
@pytest.mark.parametrize("database", ["tjwater_v2", "fengyang_v2", "other_project"])
def test_sync_producers_use_same_database_scoped_lock(database):
connection = SimpleNamespace(info=SimpleNamespace(dbname=database), execute=Mock())
lock_event_transaction_sync(connection)
connection.execute.assert_called_once_with(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", (EVENT_ORDER_LOCK,),
)
connection.info.dbname = "other_project"
connection.execute.reset_mock()
lock_event_transaction_sync(connection)
connection.execute.assert_not_called()
+101
View File
@@ -0,0 +1,101 @@
from dataclasses import replace
from uuid import uuid4
import httpx
import pytest
from scripts import run_operational_worker as worker
from scripts import sync_peripheral_work_orders as sync
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
def settings():
projects = (str(uuid4()), str(uuid4()))
return worker.WorkerSettings("http://server.test", "http://agent.test", projects[0],
bearer="TEST_ONLY", project_ids=projects)
def test_explicit_project_list_overrides_single_project_and_deduplicates(monkeypatch, settings):
monkeypatch.setattr(worker, "load_dotenv", lambda *args, **kwargs: None)
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_IDS", ", ".join(settings.project_ids + settings.project_ids[:1]))
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_ID", "ignored-legacy-value")
monkeypatch.setenv("OPERATIONAL_WORKER_BEARER", "TEST_ONLY")
parsed = worker.WorkerSettings.from_environment()
assert parsed.project_ids == settings.project_ids
assert tuple(scope.project_id for scope in parsed.project_scopes()) == settings.project_ids
assert all(not scope.project_ids for scope in parsed.project_scopes())
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_IDS", settings.project_ids[0] + ",invalid")
with pytest.raises(ValueError):
worker.WorkerSettings.from_environment()
def test_existing_single_project_configuration_remains_supported(monkeypatch, settings):
monkeypatch.setattr(worker, "load_dotenv", lambda *args, **kwargs: None)
monkeypatch.delenv("OPERATIONAL_WORKER_PROJECT_IDS", raising=False)
monkeypatch.setenv("OPERATIONAL_WORKER_PROJECT_ID", settings.project_id)
monkeypatch.setenv("OPERATIONAL_WORKER_BEARER", "TEST_ONLY")
assert worker.WorkerSettings.from_environment().project_scopes()[0].project_id == settings.project_id
assert replace(settings, project_ids=()).project_scopes() == (replace(settings, project_ids=()),)
@pytest.mark.anyio
async def test_multi_project_worker_reconciles_other_projects_when_one_fails(monkeypatch, settings):
seen = []
class FakeWorker:
def __init__(self, scope, client):
self.scope = scope
async def tick(self):
seen.append(self.scope.project_id)
if self.scope.project_id == settings.project_ids[0]:
raise httpx.ConnectError("test failure")
return 0
monkeypatch.setattr(worker.WorkerSettings, "from_environment", lambda: settings)
monkeypatch.setattr(worker, "OperationalWorker", FakeWorker)
with pytest.raises(SystemExit) as result:
await worker.run(once=True)
assert result.value.code == 1
assert tuple(seen) == settings.project_ids
@pytest.mark.anyio
@pytest.mark.parametrize("once", [True, False])
async def test_sync_entrypoint_keeps_project_headers_and_cursors_separate(monkeypatch, settings, once):
seen = []
async def check_scope(client, scope, url, credentials):
seen.append((scope.project_id, (await credentials.headers(client))["X-Project-Id"]))
return 0
class FakeSynchronizer:
def __init__(self, client, scope, url, credentials):
self.args = client, scope, url, credentials
async def serve(self, interval):
return await check_scope(*self.args)
monkeypatch.setattr(worker.WorkerSettings, "from_environment", lambda: settings)
monkeypatch.setenv("OPERATIONAL_ACTION_PERIPHERAL_URL", "http://peripheral.test")
monkeypatch.setattr(sync, "sync_once", check_scope)
monkeypatch.setattr(sync, "PeripheralWorkOrderSynchronizer", FakeSynchronizer)
await sync.run(once=once)
assert seen == [(project, project) for project in settings.project_ids]
@pytest.mark.anyio
async def test_project_workers_have_independent_tokens_sessions_and_message_cursors(settings):
async with httpx.AsyncClient() as client:
first, second = [worker.OperationalWorker(scope, client) for scope in settings.project_scopes()]
first.credentials._token = "FIRST_TEST_TOKEN"
first._agent_sessions["run-1"] = "session-1"
first._message_cursor = "cursor-1"
assert second.credentials._token == ""
assert second._agent_sessions == {}
assert second._message_cursor is None