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

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent d320972d57
commit 1d061f6099
73 changed files with 4384 additions and 228 deletions
+39 -7
View File
@@ -8,6 +8,9 @@ from pydantic import ValidationError
from app.core.config import Settings, settings
from app.domain.schemas.place_search import PlaceSearchRequest
from app.domain.schemas.project_map_config import PlaceSearchConfig
CONFIG = PlaceSearchConfig(enabled=True, admin_code="310120", region="奉贤区")
from app.services.place_search import PlaceSearchError, search_tianditu_places
@@ -44,7 +47,7 @@ def shared_token(monkeypatch):
def test_search_fixes_fengxian_scope_and_normalizes_candidates():
client = FakeClient()
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), client=client))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword=" 南桥路 "), config=CONFIG, client=client))
url, params = client.calls[0]
assert url == "https://api.tianditu.gov.cn/v2/search"
assert params["tk"] == "shared-token"
@@ -60,14 +63,14 @@ def test_search_fixes_fengxian_scope_and_normalizes_candidates():
@pytest.mark.parametrize("data", [payload(count=0, pois=[]), {"status": {"infocode": 3001}}])
def test_no_matches_is_a_successful_empty_result(data):
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="未知地名"), client=FakeClient(data)))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="未知地名"), config=CONFIG, client=FakeClient(data)))
assert result.items == [] and result.total == 0 and not result.truncated
@pytest.mark.parametrize("matched_total, size, truncated", [(0, 0, False), (3, 3, False), (10, 10, False), (28, 10, True), (2000, 10, True)])
def test_search_only_returns_first_ten_results(matched_total, size, truncated):
client = FakeClient(payload(count=matched_total, pois=[poi(hotPointID=f"place-{index}") for index in range(size)]))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client))
assert len(result.items) == result.total == size and result.truncated == truncated
params = json.loads(client.calls[0][1]["postStr"])
assert params["count"] == 10 and params["start"] == 0 and params["specify"] == "156310120"
@@ -90,7 +93,7 @@ def test_search_only_returns_first_ten_results(matched_total, size, truncated):
])
def test_invalid_or_unlocatable_provider_response_is_rejected(data):
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=FakeClient(data)))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=FakeClient(data)))
assert exc.value.status_code == 502
@@ -98,7 +101,7 @@ def test_missing_shared_token_is_configuration_error(monkeypatch):
monkeypatch.setattr(settings, "TIANDITU_TOKEN", "")
client = FakeClient()
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client))
assert exc.value.status_code == 503 and client.calls == []
@@ -108,7 +111,7 @@ def test_http_request_logs_redact_the_shared_token(caplog):
async def run():
transport = httpx.MockTransport(lambda _request: httpx.Response(200, json=payload()))
async with httpx.AsyncClient(transport=transport) as client:
await search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=client)
await search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=client)
asyncio.run(run())
messages = [record.getMessage() for record in caplog.records if record.name == "httpx"]
@@ -122,7 +125,7 @@ def test_http_request_logs_redact_the_shared_token(caplog):
])
def test_transport_errors_are_safe_and_actionable(failure, status):
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), client=FakeClient(error=failure)))
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword="南桥路"), config=CONFIG, client=FakeClient(error=failure)))
assert exc.value.status_code == status
assert "secret-token" not in exc.value.detail
@@ -138,3 +141,32 @@ def test_shared_token_accepts_existing_configuration_names(monkeypatch, key):
for name in ("TIANDITU_TOKEN", "TJWATER_TIANDITU_TOKEN", "TIANDITU_GEOCODER_TOKEN"):
monkeypatch.delenv(name, raising=False)
assert Settings(_env_file=None, **{key: "one-token"}).TIANDITU_TOKEN == "one-token"
@pytest.mark.parametrize('admin_code,region,county_code,county,province', [
('341126', '凤阳县', '156341126', '凤阳县', '156340000'),
('320506', '吴中区', '320506', '吴中区', '320000'),
('440800', '湛江市', '156440803', '霞山区', '156440000'),
('310000', '上海市', '156310118', '青浦区', '156310000'),
])
def test_search_uses_project_scope_for_districts_and_cities(admin_code, region, county_code, county, province):
config = PlaceSearchConfig(enabled=True, admin_code=admin_code, region=region)
client = FakeClient(payload(pois=[poi(countyCode=county_code, county=county, provinceCode=province)]))
result = asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), config=config, client=client))
assert json.loads(client.calls[0][1]['postStr'])['specify'] == '156' + admin_code
assert result.region == region and result.items[0].district == county
def test_configured_city_rejects_other_city_in_same_province():
config = PlaceSearchConfig(enabled=True, admin_code='440800', region='湛江市')
with pytest.raises(PlaceSearchError, match='无效'):
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'), config=config,
client=FakeClient(payload(pois=[poi(countyCode='156440104', county='越秀区', provinceCode='156440000')]))))
def test_disabled_project_does_not_call_provider():
client = FakeClient()
with pytest.raises(PlaceSearchError) as exc:
asyncio.run(search_tianditu_places(PlaceSearchRequest(keyword='地名'),
config=CONFIG.model_copy(update={'enabled': False}), client=client))
assert exc.value.status_code == 503 and client.calls == []