142 lines
8.3 KiB
Python
142 lines
8.3 KiB
Python
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
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, get_metadata_repository
|
|
from app.domain.schemas.place_search import PlaceSearchItem, PlaceSearchResponse
|
|
from app.services.place_search import PlaceSearchError
|
|
|
|
|
|
@pytest.fixture
|
|
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, 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
|
|
assert response.json()["items"][0]["name"] == "南桥路"
|
|
assert response.json()["region"] == "奉贤区"
|
|
assert set(response.json()) == {"items", "total", "truncated", "region"}
|
|
assert service.await_args.args[0].keyword == "南桥路"
|
|
|
|
|
|
@pytest.mark.parametrize("status", [502, 503, 504])
|
|
def test_endpoint_preserves_provider_failure_status(app, monkeypatch, status):
|
|
monkeypatch.setattr(place_search, "search_tianditu_places", AsyncMock(side_effect=PlaceSearchError(status, "查询暂不可用")))
|
|
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
|
|
assert response.status_code == status and response.json()["detail"] == "查询暂不可用"
|
|
|
|
|
|
@pytest.mark.parametrize("extra", [{"region": "北京市"}, {"specify": "156310104"}, {"offset": 0}, {"limit": 10}])
|
|
def test_scope_and_result_limit_cannot_be_overridden(app, monkeypatch, extra):
|
|
service = AsyncMock()
|
|
monkeypatch.setattr(place_search, "search_tianditu_places", service)
|
|
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路", **extra})
|
|
assert response.status_code == 422 and not service.called
|
|
|
|
|
|
def test_search_denies_users_without_project_read_permission(app, monkeypatch):
|
|
app.dependency_overrides[get_project_context] = lambda: ProjectContext(uuid4(), "demo", uuid4(), "none")
|
|
service = AsyncMock()
|
|
monkeypatch.setattr(place_search, "search_tianditu_places", service)
|
|
response = TestClient(app).post("/api/v1/place-search-requests", json={"keyword": "南桥路"})
|
|
assert response.status_code == 403 and not service.called
|
|
|
|
|
|
def test_search_contract_has_typed_results_and_required_project_header(app):
|
|
contract = app.openapi()
|
|
operation = contract["paths"]["/api/v1/place-search-requests"]["post"]
|
|
assert any(param["name"] == "X-Project-Id" and param["required"] for param in operation["parameters"])
|
|
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'] == '请重新取点'
|