From 14fa45903bfc0468813d106bf55b4acdc07418ea Mon Sep 17 00:00:00 2001 From: mkeller3 Date: Tue, 19 Nov 2024 20:08:10 -0600 Subject: [PATCH] fix: fix endpoints and add test cases --- .coverage | Bin 0 -> 53248 bytes .coveragerc | 5 + TESTING.md | 4 + api/models.py | 2 +- api/routers/collections/models.py | 15 +- api/routers/collections/router.py | 4 + tests/conftest.py | 10 + tests/test_collections.py | 467 ++++++++++++++++++++++++++++++ tests/test_main.py | 22 ++ 9 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 .coverage create mode 100644 .coveragerc create mode 100644 TESTING.md create mode 100644 tests/conftest.py create mode 100644 tests/test_collections.py create mode 100644 tests/test_main.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..638a149df2ebed7082fb5a970d7e756729757a53 GIT binary patch literal 53248 zcmeI4e~c968OLXKXLoLY&QYYBlju%s+T-xJF617DQO3TS@pLc%j z-T_Cj%~K11oMGRGm0=L0F}&r$-%7 zE5=rJgjLjnUSz6X&g(hN(#u1tHO#!+Rg>lUL`QKn)$xqIc6lDk=y@t-u^|#MQqfCV zWt;j{hjgg*k)t9JL&4D@G}08ER24+h&$c z-O`P+%0^kPYO(wU4eaL7yp7;ZwT5JE>$?^bD@U(`WV@-bzP+br2d0yljPPImf$-$!*9PTUbRKV!dvvGc3%u zBa!R$g@TE}#9aDhx;BN(oA1m(p1`Qp7H+l}1Tau4z}7xG&20>h}6RAQQ0t#|{; z#Rf^QM01q%`iPrC=Jd$H#K8QS%yIK&`rJTUO_t=uym?~H=@EQf;@`kDBX~@+3!F0% zym$3SG;3HrUw0l&igyNriDmOErw0_#Hl5%{SpoO{Og~lGLLDhd=uN& zvqdY?XfmSd73#7y20Jr=$}{FQ%B*FLs%85xKegSH&pl0j9RbaAk3Hq;va?h7UT2u5 zPSYx%w#?6av1;eAX7W+U>{PRcQDj=#nH#9Y8k6eOP1V-n{8_2LDZ|{xWejcUv~}_( z7e36dr%vbfFA9`OS~>5|S9}_Fq|(^WJ14Kl8APa{+DV<#q%L}nOi!qr%110ue(wk| z;ZjRl-s$eVQKm@TI?T=!=gdZ{G>ch!iK#q`t&HthTQyV7RTw4GT-w#?znB~F*2?tp zJzpia@t<Qi%^Eh!snwcktu%^7X1@S}lJu0j z7^3ILzZ@np$ri=XBjXmUq!@o1pB@<}aW{lW+}sw$RV|%f7NRqg1bw581TAS%P=($e zK+n?Ct2;ca%~Z+Av!X3o{(vPrwUN%Nf+RWCqR#XZh=M*uQdS2@%A6LZWc9L{p?9v3 zoaGY93A89DpXK)W--kU7-Ya}0CDfuGB~34rCO3*CV_pj~EM`(~;B0w_B=P(Ik?0{I z{&@VZ_-nB_imCL(_Qw6OjnPBOuReU;92$WD2!H?xfB*=900@8p2!Mbftd?qu*us)% z`uace6^UNj)}om6*Z<)grP|z9#Z6uRhc-yHC9Mj&==#6ygO=P_|I33?E!L{ejrD)< z2B|iuRVh=}|A7Ii7HCzDz5bWpk3Eg`zkj7v3$?0;yZ-l)Tg_|b8p3(&f4u(>V#Y009sH0T2KI5C8!X z009vA7!n9ZBrz0^_^u4o@p^ve_{iBe|M%chGc5?|MZxlScJ;lxEJSIeq2JuGCs_Y( zeSA%N)3Viz+7}8B?OyW2O@a{r;oj1rf%K;D*OS&x@sw}m?bx=KaujA1-J0|OY zN!4zX17c{rTRt;!*L|<-Q$JnbyFK~Gv%gMuG~0A%eRtv1#4D%A{?UvR1i5jtGbsui zpQxYRe)U_+Cgu9Ap_dNEHci%7rq8@}&H9IuG8^B%b*%8G?dCnbD<3=h;?wCL#EzMT zIDH1009sH0T2KI5C8!X009sH0T8$p1o-`btp6_sY19D% zAOHd&00JNY0w4eaAOHd&00Of|fIt6__5bYgN6jDr0w4eaAOHd&00JNY0w4eaAaE%N F{2#e$QNjQK literal 0 HcmV?d00001 diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..9df36f9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +omit = test*,*/dist-packages/* + +[report] +omit = *test*,*/dist-packages/* \ No newline at end of file diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..60ff365 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,4 @@ +## Run Unit Tests +```bash +coverage run -m pytest tests/ +``` \ No newline at end of file diff --git a/api/models.py b/api/models.py index 415bf75..43cca1b 100644 --- a/api/models.py +++ b/api/models.py @@ -7,7 +7,7 @@ class HealthCheckResponse(BaseModel): """FastCollections - HealthCheckResponse""" - status: bool + status: str class MediaType(str, Enum): diff --git a/api/routers/collections/models.py b/api/routers/collections/models.py index 56d925a..ccf8c2a 100644 --- a/api/routers/collections/models.py +++ b/api/routers/collections/models.py @@ -281,7 +281,7 @@ class Items(FeatureCollection): model_config = {"arbitrary_types_allowed": True} -class TileMatrixSetLink(BaseModel): +class TileLink(BaseModel): """ TileMatrixSetLink model. Based on http://docs.opengeospatial.org/per/19-069.html#_tilematrixsets @@ -294,6 +294,16 @@ class TileMatrixSetLink(BaseModel): model_config = {"use_enum_values": True} +class TileMatrixSetLink(BaseModel): + """ + TileMatrixSetLink model. + Based on http://docs.opengeospatial.org/per/19-069.html#_tilematrixsets + """ + + tileMatrixSet: str + tileMatrixSetURI: str + + class TileMatrixSetRef(BaseModel): """ TileMatrixSetRef model. @@ -302,7 +312,8 @@ class TileMatrixSetRef(BaseModel): id: str title: Optional[str] = None - links: List[TileMatrixSetLink] + links: List[TileLink] + tileMatrixSetLinks: List[TileMatrixSetLink] class LayerJSON(BaseModel): diff --git a/api/routers/collections/router.py b/api/routers/collections/router.py index b8e6707..000f0cd 100644 --- a/api/routers/collections/router.py +++ b/api/routers/collections/router.py @@ -275,6 +275,8 @@ async def items( status_code=400, detail=f"""Column: {property} is not a column for {schema}.{table}.""", ) + if "gid" not in properties: + properties += ",gid" if new_query_parameters: for field in db_fields: @@ -425,6 +427,8 @@ async def post_items( status_code=400, detail=f"""Column: {property} is not a column for {schema}.{table}.""", ) + if "gid" not in info.properties: + info.properties += ",gid" if info.cql_filter is not None: field_mapping = {} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a30c52a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest +from fastapi.testclient import TestClient + +from api import main + + +@pytest.fixture() +def app(): + with TestClient(app=main.app) as client: + yield client diff --git a/tests/test_collections.py b/tests/test_collections.py new file mode 100644 index 0000000..78dcf30 --- /dev/null +++ b/tests/test_collections.py @@ -0,0 +1,467 @@ +def test_collections(app): + """ + Test the collections endpoint. + """ + response = app.get(url="/api/v1/collections") + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["links"] + assert body["collections"] + + +def test_collection(app): + """ + Test the collection endpoint. + """ + response = app.get(url="/api/v1/collections/public.states") + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["links"] + assert body["title"] == "public.states" + assert body["id"] == "public.states" + + +def test_queryables(app): + """ + Test the queryables endpoint. + """ + response = app.get(url="/api/v1/collections/public.states/queryables") + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["title"] == "public.states" + assert body["properties"] + + +def test_get_items(app): + """ + Test the items endpoint. + """ + + # Test cql_filter + response = app.get( + url="/api/v1/collections/public.states/items?cql_filter=state_name='New York'" + ) + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["links"] + assert body["features"] + + # Test properties + response2 = app.get( + url="/api/v1/collections/public.states/items?properties=state_name&state_name=New York" + ) + body2 = response2.json() + assert response2.status_code == 200 + assert response2.headers["content-type"] == "application/json" + assert body2["links"] + assert body2["features"] + + # Test invalid properties + response3 = app.get( + url="/api/v1/collections/public.states/items?properties=state_names&cql_filter=state_name='New York'" + ) + assert response3.status_code == 400 + + # Test invalid operator + response4 = app.get( + url="/api/v1/collections/public.states/items?properties=state_name&cql_filter==state_name='New York'" + ) + assert response4.status_code == 400 + assert response4.json() == {"detail": "Invalid operator used in cql_filter."} + + # Test invalid column + response5 = app.get( + url="/api/v1/collections/public.states/items?properties=state_name&cql_filter=state_names='New York'" + ) + assert response5.status_code == 400 + assert response5.json() == { + "detail": "Invalid column in cql_filter parameter for public.states." + } + + # Test pagination + response6 = app.get(url="/api/v1/collections/public.states/items?offset=5&limit=1") + body6 = response6.json() + assert response6.status_code == 200 + assert response6.headers["content-type"] == "application/json" + assert body6["links"] + assert body6["features"] + + +def test_post_items(app): + """ + Test the post items endpoint. + """ + + # Test the cql_filter + response = app.post( + url="/api/v1/collections/public.states/items", + json={"cql_filter": "state_name='New York'"}, + ) + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["links"] + assert body["features"] + + # Test the properties + response2 = app.post( + url="/api/v1/collections/public.states/items", json={"properties": "state_name"} + ) + body2 = response2.json() + assert response2.status_code == 200 + assert response2.headers["content-type"] == "application/json" + assert body2["links"] + + # Test invalid properties + response3 = app.post( + url="/api/v1/collections/public.states/items", + json={"properties": "state_names"}, + ) + assert response3.status_code == 400 + + # Test invalid operator + response4 = app.post( + url="/api/v1/collections/public.states/items", + json={"cql_filter": "state_name=='New York'"}, + ) + assert response4.status_code == 400 + assert response4.json() == {"detail": "Invalid operator used in cql_filter."} + + # Test invalid column + response5 = app.post( + url="/api/v1/collections/public.states/items", + json={"cql_filter": "state_names='New York'"}, + ) + assert response5.status_code == 400 + assert response5.json() == { + "detail": "Invalid column in cql_filter parameter for public.states." + } + + # Test pagination + response6 = app.post( + url="/api/v1/collections/public.states/items", json={"offset": 5, "limit": 1} + ) + body6 = response6.json() + assert response6.status_code == 200 + assert response6.headers["content-type"] == "application/json" + assert body6["links"] + assert body6["features"] + + +def test_get_item(app): + """ + Test the item endpoint. + """ + response = app.get(url="/api/v1/collections/public.states/items/1") + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["links"] + assert body["properties"] + + +def test_tiles(app): + """ + Test the tiles endpoint. + """ + response = app.get(url="/api/v1/collections/public.states/tiles") + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["links"] + assert body["tileMatrixSetLinks"] + + +def test_tile(app): + """ + Test the tile endpoint. + """ + response = app.get( + url="/api/v1/collections/public.states/tiles/WorldCRS84Quad/0/0/0" + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/vnd.mapbox-vector-tile" + + empty_response = app.get( + url="/api/v1/collections/public.states/tiles/WorldCRS84Quad/4/8/5" + ) + assert empty_response.status_code == 204 + assert ( + empty_response.headers["content-type"] == "application/vnd.mapbox-vector-tile" + ) + + +def test_tiles_metadata(app): + """ + Test the tiles metadata endpoint. + """ + response = app.get( + url="/api/v1/collections/public.states/tiles/WorldCRS84Quad/metadata" + ) + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["vector_layers"] + + +def test_cache_size(app): + """ + Test the cache size endpoint. + """ + response = app.get(url="/api/v1/collections/public.states/tiles/cache_size") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + +def test_delete_tile_cache(app): + """ + Test the delete tile cache endpoint. + """ + response = app.delete(url="/api/v1/collections/public.states/tiles/cache") + assert response.status_code == 200 + + +def test_statistics(app): + """ + Test the statistics endpoint. + """ + + # Test cql_filter + response = app.post( + url="/api/v1/collections/public.states/statistics", + json={ + "cql_filter": "state_name='New York'", + "aggregate_columns": [{"column": "state_name", "type": "count"}], + }, + ) + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["results"]["count_state_name"] + + # Test distinct + response2 = app.post( + url="/api/v1/collections/public.states/statistics", + json={ + "aggregate_columns": [ + { + "column": "state_name", + "type": "distinct", + "group_method": "count", + "group_column": "state_name", + } + ] + }, + ) + assert response2.status_code == 200 + assert response2.headers["content-type"] == "application/json" + + # Test invalid properties + response3 = app.post( + url="/api/v1/collections/public.states/statistics", + json={"aggregate_columns": [{"column": "state_names", "type": "count"}]}, + ) + assert response3.status_code == 400 + assert response3.json() == { + "detail": "One of the columns used does not exist for public.states." + } + + # Test invalid column name with distinct + response4 = app.post( + url="/api/v1/collections/public.states/statistics", + json={ + "aggregate_columns": [ + { + "column": "state_names", + "type": "distinct", + "group_method": "count", + "group_column": "state_name", + } + ] + }, + ) + assert response4.status_code == 400 + assert response4.json() == { + "detail": "One of the columns used does not exist for public.states." + } + +def test_bins(app): + """ + Test the bins endpoint. + """ + response = app.post(url="/api/v1/collections/public.states/bins", json={ + "column": "population", + "number_of_breaks": 10 + }) + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["results"] + + response2 = app.post(url="/api/v1/collections/public.states/bins", json={ + "column": "populations", + "number_of_breaks": 10 + }) + assert response2.status_code == 400 + +def test_numeric_breaks(app): + """ + Test the numeric breaks endpoint. + """ + response = app.post(url="/api/v1/collections/public.states/numeric_breaks", json={ + "column": "population", + "break_type": "equal_interval", + "number_of_breaks": 10 + }) + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["results"] + + response2 = app.post(url="/api/v1/collections/public.states/numeric_breaks", json={ + "column": "population", + "break_type": "quantile", + "number_of_breaks": 10 + }) + body = response2.json() + assert response2.status_code == 200 + assert response2.headers["content-type"] == "application/json" + assert body["results"] + + response3 = app.post(url="/api/v1/collections/public.states/numeric_breaks", json={ + "column": "populations", + "break_type": "equal_interval", + "number_of_breaks": 10 + }) + assert response3.status_code == 400 + assert response3.json() == { + "detail": "Column: populations does not exist for public.states." + } + +def test_custom_break_values(app): + """ + Test the custom break values endpoint. + """ + response = app.post(url="/api/v1/collections/public.states/custom_break_values", json={ + "column": "population", + "breaks": [ + { + "min": 0, + "max": 100 + }, + { + "min": 100, + "max": 200 + } + ] + }) + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["results"] + + response2 = app.post(url="/api/v1/collections/public.states/custom_break_values", json={ + "column": "populations", + "breaks": [ + { + "min": 0, + "max": 100 + }, + { + "min": 100, + "max": 200 + } + ] + }) + assert response2.status_code == 400 + assert response2.json() == { + "detail": "Column: populations does not exist for public.states." + } + +def test_autocomplete(app): + """ + Test the autocomplete endpoint. + """ + response = app.get(url="/api/v1/collections/public.states/autocomplete/state_name/New") + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["values"] + + response2 = app.get(url="/api/v1/collections/public.states/autocomplete/state_names/New") + assert response2.status_code == 400 + assert response2.json() == { + "detail": "Column: state_names does not exist for public.states." + } + +def test_closest_features(app): + """ + Test the closest features endpoint. + """ + response = app.get(url="/api/v1/collections/public.states/closest_features", params={ + "latitude": 40.7128, + "longitude": -74.006, + "limit": 1, + "cql_filter": "state_name='New York'" + }) + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["features"] + + response2 = app.get(url="/api/v1/collections/public.states/closest_features", params={ + "latitude": 40.7128, + "longitude": -74.006, + "limit": 1, + "cql_filter": "state_names='New York'" + }) + assert response2.status_code == 400 + assert response2.json() == { + "detail": "Invalid column in cql_filter parameter for public.states." + } + + response3 = app.get(url="/api/v1/collections/public.states/closest_features", params={ + "latitude": 40.7128, + "longitude": -74.006, + "limit": 1, + "cql_filter": "state_name LI 'New York'" + }) + assert response3.status_code == 400 + assert response3.json() == { + "detail": "Invalid operator used in cql_filter." + } + +def test_download(app): + """ + Test the download endpoint. + """ + response = app.get(url="/api/v1/collections/public.states/download", params={ + "cql_filter": "state_name='New York'", + "format": "csv", + "file_name": "test" + }) + assert response.status_code == 200 + + response2 = app.get(url="/api/v1/collections/public.states/download", params={ + "cql_filter": "state_names='New York'", + "format": "csv", + "file_name": "test" + }) + assert response2.status_code == 400 + assert response2.json() == { + "detail": "Invalid column in cql_filter parameter for public.states." + } + + response3 = app.get(url="/api/v1/collections/public.states/download", params={ + "cql_filter": "state_name LI 'New York'", + "format": "csv", + "file_name": "test" + }) + assert response3.status_code == 400 + assert response3.json() == { + "detail": "Invalid operator used in cql_filter." + } \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..884310d --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,22 @@ +def test_health_check(app): + response = app.get(url="/api/v1/health_check") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert response.json() == {"status": "UP"} + + +def test_landing_page(app): + response = app.get(url="/api/v1/") + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert body["links"] + assert body["title"] + + +def test_conformance(app): + response = app.get(url="/conformance") + body = response.json() + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert len(body["conformsTo"]) == 16