Files
next-tjwater-server/tests/api/test_place_search_endpoints.py
jiang 1d061f6099
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s
feat: add project-scoped operations
2026-10-22 18:40:20 +08:00

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'] == '请重新取点'