feat: add project-scoped operations
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user