diff --git a/app/routes/thematic/geoencoder.py b/app/routes/thematic/geoencoder.py index f33ff378..75d1d214 100644 --- a/app/routes/thematic/geoencoder.py +++ b/app/routes/thematic/geoencoder.py @@ -1,7 +1,12 @@ +import re from typing import Optional, Any, Dict, List from fastapi import APIRouter, HTTPException, Query +from app.crud.versions import get_version, get_version_names +from app.errors import RecordNotFoundError +from app.models.pydantic.responses import Response +from app.routes import VERSION_REGEX from app.routes.datasets.queries import _query_dataset_json @@ -20,7 +25,7 @@ async def geoencode( description="The source of administrative boundaries to use." ), admin_version: str = Query( - None, + ..., description="Version of the administrative boundaries dataset to use.", ), country: str = Query( @@ -46,13 +51,15 @@ async def geoencode( dataset = admin_source_to_dataset[admin_source.upper()] except KeyError: raise HTTPException( - status_code=404, + status_code=400, detail=f"Invalid admin boundary source. Valid sources: {admin_source_to_dataset.keys()}" ) version_str = "v" + str(admin_version).lstrip("v") - sql: str = await admin_boundary_lookup_sql( + await version_is_valid(dataset, version_str) + + sql: str = _admin_boundary_lookup_sql( admin_source, country, region, @@ -63,14 +70,16 @@ async def geoencode( dataset, version_str, sql, None ) - return { - "adminSource": admin_source, - "adminVersion": admin_version, - "matches": json_data - } + return Response( + data={ + "adminSource": admin_source, + "adminVersion": admin_version, + "matches": json_data + } + ) -async def admin_boundary_lookup_sql( +def _admin_boundary_lookup_sql( dataset: str, country_name: str, region_name: Optional[str], @@ -81,7 +90,7 @@ async def admin_boundary_lookup_sql( """ sql = ( f"SELECT gid_0, gid_1, gid_2, country, name_1, name_2 FROM {dataset}" - f" AND WHERE country='{country_name}'" + f" WHERE country='{country_name}'" ) if region_name is not None: sql += f" AND WHERE region='{region_name}'" @@ -89,3 +98,32 @@ async def admin_boundary_lookup_sql( sql += f" AND WHERE subregion='{subregion_name}'" return sql + + +async def version_is_valid( + dataset: str, + version: str, +) -> None: + """ + + """ + if re.match(VERSION_REGEX, version) is None: + raise HTTPException( + status_code=400, + detail=( + "Invalid version name. Version names begin with a 'v' and " + "consist of one to three integers separated by periods. " + "eg. 'v1', 'v7.1', 'v4.1.0', 'v20240801'" + ) + ) + + try: + _ = await get_version(dataset, version) + except RecordNotFoundError: + raise HTTPException( + status_code=400, + detail=( + "Version not found. Existing versions for this dataset " + f"include {await get_version_names(dataset)}" + ) + ) diff --git a/tests_v2/unit/app/routes/thematic/geoencoder/test_geoencoder.py b/tests_v2/unit/app/routes/thematic/geoencoder/test_geoencoder.py index 71c2587e..bdedc39b 100644 --- a/tests_v2/unit/app/routes/thematic/geoencoder/test_geoencoder.py +++ b/tests_v2/unit/app/routes/thematic/geoencoder/test_geoencoder.py @@ -1,28 +1,104 @@ -# import pytest -# from httpx import AsyncClient -# -# -# @pytest.mark.asyncio -# async def test_geoencoder_no_version(async_client: AsyncClient) -> None: -# params = {"country": "Canada"} -# -# resp = await async_client.get("/thematic/geoencode", params=params) -# -# assert resp.status_code == 400 -# -# -# @pytest.mark.asyncio -# async def test_geoencoder_fake_country_no_matches(async_client: AsyncClient) -> None: -# -# params = {"admin_version": "4.1", "country": "Canadiastan"} -# -# resp = await async_client.get("/thematic/geoencode", params=params) -# -# assert resp.json() == { -# "status": "success", -# "data": { -# "adminVersion": "4.1", -# "matches": [] -# } -# } -# assert resp.status_code == 200 +from typing import Optional, Any, Dict, List + +import pytest +from httpx import AsyncClient + +from app.models.pydantic.geostore import GeostoreCommon +from app.routes.thematic import geoencoder +from app.routes.thematic.geoencoder import _admin_boundary_lookup_sql + + +@pytest.mark.asyncio +async def test__admin_boundary_lookup_sql() -> None: + sql = _admin_boundary_lookup_sql( + "some_dataset", "some_country", "some_region", "some_subregion" + ) + assert sql == ( + "SELECT gid_0, gid_1, gid_2, country, name_1, name_2 FROM some_dataset " + "WHERE country='some_country' " + "AND WHERE region='some_region' " + "AND WHERE subregion='some_subregion'" + ) + + +@pytest.mark.asyncio +async def test_geoencoder_no_admin_version(async_client: AsyncClient) -> None: + params = {"country": "Canada"} + + resp = await async_client.get("/thematic/geoencode", params=params) + + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_geoencoder_invalid_version_pattern(async_client: AsyncClient) -> None: + params = {"country": "Canada", "admin_version": "fails_regex"} + + resp = await async_client.get("/thematic/geoencode", params=params) + + assert resp.json().get("message", {}).startswith("Invalid version") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_geoencoder_nonexistant_version(async_client: AsyncClient) -> None: + params = {"country": "Canada", "admin_version": "v4.0"} + + resp = await async_client.get("/thematic/geoencode", params=params) + + assert resp.json().get("message", {}).startswith("Version not found") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_geoencoder_bad_boundary_source(async_client: AsyncClient) -> None: + params = { + "admin_source": "bobs_boundaries", + "admin_version": "4.1", + "country": "Canadiastan" + } + + resp = await async_client.get("/thematic/geoencode", params=params) + + assert resp.json().get("message", {}).startswith("Invalid admin boundary source") + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_geoencoder_no_matches( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch +) -> None: + admin_source = "gadm" + admin_version = "v4.1" + + params = { + "admin_source": admin_source, + "admin_version": admin_version, + "country": "Canadiastan" + } + + async def mock_version_is_valid(dataset: str, version: str): return None + monkeypatch.setattr(geoencoder, "version_is_valid", mock_version_is_valid) + monkeypatch.setattr(geoencoder, "_query_dataset_json", _query_dataset_json_mocked_results) + + resp = await async_client.get("/thematic/geoencode", params=params) + + assert resp.json() == { + "status": "success", + "data": { + "adminSource": admin_source, + "adminVersion": admin_version, + "matches": [] + } + } + assert resp.status_code == 200 + + +async def _query_dataset_json_mocked_results( + dataset: str, + version: str, + sql: str, + geostore: Optional[GeostoreCommon], +) -> List[Dict[str, Any]]: + return []