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"]
|
||||
|
||||
Reference in New Issue
Block a user