diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 3bf7a999..caeae818 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -16,7 +16,7 @@ jobs: services: elasticsearch_8_svc: - image: docker.elastic.co/elasticsearch/elasticsearch:8.1.3 + image: docker.elastic.co/elasticsearch/elasticsearch:8.10.4 env: cluster.name: stac-cluster node.name: es01 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3d791b..6943541d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Collection-level Assets to the CollectionSerializer [#148](https://github.com/stac-utils/stac-fastapi-elasticsearch/issues/148) +- Pagination for /collections - GET all collections - route [#164](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/164) - Examples folder with example docker setup for running sfes from pip [#147](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/147) - GET /search filter extension queries [#163](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/163) - Added support for GET /search intersection queries [#158](https://github.com/stac-utils/stac-fastapi-elasticsearch/issues/158) ### Changed +- Update elasticsearch version from 8.1.3 to 8.10.4 in cicd, gh actions [#164](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/164) - Updated core stac-fastapi libraries to 2.4.8 from 2.4.3 [#151](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/151) - Use aliases on Elasticsearch indices, add number suffix in index name. [#152](https://github.com/stac-utils/stac-fastapi-elasticsearch/pull/152) diff --git a/README.md b/README.md index a38ce14a..5b4b7685 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,17 @@ curl -X "POST" "http://localhost:8080/collections" \ ``` Note: this "Collections Transaction" behavior is not part of the STAC API, but may be soon. + + +## Collection pagination + +The collections route handles optional `limit` and `token` parameters. The `links` field that is +returned from the `/collections` route contains a `next` link with the token that can be used to +get the next page of results. +```shell +curl -X "GET" "http://localhost:8080/collections?limit=1&token=example_token" +``` ## Testing diff --git a/docker-compose.yml b/docker-compose.yml index d17e3af2..1cad4dee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,7 @@ services: elasticsearch: container_name: es-container - image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.1.3} + image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.10.4} environment: ES_JAVA_OPTS: -Xms512m -Xmx1g volumes: diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/core.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/core.py index e4aa5846..058be6e3 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/core.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/core.py @@ -1,6 +1,7 @@ """Item crud client.""" import logging import re +from base64 import urlsafe_b64encode from datetime import datetime as datetime_type from datetime import timezone from typing import Any, Dict, List, Optional, Set, Type, Union @@ -82,30 +83,58 @@ async def all_collections(self, **kwargs) -> Collections: Raises: Exception: If any error occurs while reading the collections from the database. """ + request: Request = kwargs["request"] base_url = str(kwargs["request"].base_url) + limit = ( + int(request.query_params["limit"]) + if "limit" in request.query_params + else 10 + ) + token = ( + request.query_params["token"] if "token" in request.query_params else None + ) + + hits = await self.database.get_all_collections(limit=limit, token=token) + + next_search_after = None + next_link = None + if len(hits) == limit: + last_hit = hits[-1] + next_search_after = last_hit["sort"] + next_token = urlsafe_b64encode( + ",".join(map(str, next_search_after)).encode() + ).decode() + paging_links = PagingLinks(next=next_token, request=request) + next_link = paging_links.link_next() + + links = [ + { + "rel": Relations.root.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.parent.value, + "type": MimeTypes.json, + "href": base_url, + }, + { + "rel": Relations.self.value, + "type": MimeTypes.json, + "href": urljoin(base_url, "collections"), + }, + ] + + if next_link: + links.append(next_link) + return Collections( collections=[ - self.collection_serializer.db_to_stac(c, base_url=base_url) - for c in await self.database.get_all_collections() - ], - links=[ - { - "rel": Relations.root.value, - "type": MimeTypes.json, - "href": base_url, - }, - { - "rel": Relations.parent.value, - "type": MimeTypes.json, - "href": base_url, - }, - { - "rel": Relations.self.value, - "type": MimeTypes.json, - "href": urljoin(base_url, "collections"), - }, + self.collection_serializer.db_to_stac(c["_source"], base_url=base_url) + for c in hits ], + links=links, ) @overrides diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 273947d5..6b2cd433 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -305,21 +305,34 @@ class DatabaseLogic: """CORE LOGIC""" - async def get_all_collections(self) -> Iterable[Dict[str, Any]]: + async def get_all_collections( + self, token: Optional[str], limit: int + ) -> Iterable[Dict[str, Any]]: """Retrieve a list of all collections from the database. + Args: + token (Optional[str]): The token used to return the next set of results. + limit (int): Number of results to return + Returns: collections (Iterable[Dict[str, Any]]): A list of dictionaries containing the source data for each collection. Notes: The collections are retrieved from the Elasticsearch database using the `client.search` method, - with the `COLLECTIONS_INDEX` as the target index and `size=1000` to retrieve up to 1000 records. + with the `COLLECTIONS_INDEX` as the target index and `size=limit` to retrieve records. The result is a generator of dictionaries containing the source data for each collection. """ - # https://github.com/stac-utils/stac-fastapi-elasticsearch/issues/65 - # collections should be paginated, but at least return more than the default 10 for now - collections = await self.client.search(index=COLLECTIONS_INDEX, size=1000) - return (c["_source"] for c in collections["hits"]["hits"]) + search_after = None + if token: + search_after = urlsafe_b64decode(token.encode()).decode().split(",") + collections = await self.client.search( + index=COLLECTIONS_INDEX, + search_after=search_after, + size=limit, + sort={"id": {"order": "asc"}}, + ) + hits = collections["hits"]["hits"] + return hits async def get_one_item(self, collection_id: str, item_id: str) -> Dict: """Retrieve a single item from the database. diff --git a/stac_fastapi/elasticsearch/tests/api/test_api.py b/stac_fastapi/elasticsearch/tests/api/test_api.py index 3ae65ebd..59c3d6ac 100644 --- a/stac_fastapi/elasticsearch/tests/api/test_api.py +++ b/stac_fastapi/elasticsearch/tests/api/test_api.py @@ -33,17 +33,20 @@ } +@pytest.mark.asyncio async def test_post_search_content_type(app_client, ctx): params = {"limit": 1} resp = await app_client.post("/search", json=params) assert resp.headers["content-type"] == "application/geo+json" +@pytest.mark.asyncio async def test_get_search_content_type(app_client, ctx): resp = await app_client.get("/search") assert resp.headers["content-type"] == "application/geo+json" +@pytest.mark.asyncio async def test_api_headers(app_client): resp = await app_client.get("/api") assert ( @@ -52,11 +55,13 @@ async def test_api_headers(app_client): assert resp.status_code == 200 +@pytest.mark.asyncio async def test_router(app): api_routes = set([f"{list(route.methods)[0]} {route.path}" for route in app.routes]) assert len(api_routes - ROUTES) == 0 +@pytest.mark.asyncio async def test_app_transaction_extension(app_client, ctx): item = copy.deepcopy(ctx.item) item["id"] = str(uuid.uuid4()) @@ -66,6 +71,7 @@ async def test_app_transaction_extension(app_client, ctx): await app_client.delete(f"/collections/{item['collection']}/items/{item['id']}") +@pytest.mark.asyncio async def test_app_search_response(app_client, ctx): resp = await app_client.get("/search", params={"ids": ["test-item"]}) assert resp.status_code == 200 @@ -77,6 +83,7 @@ async def test_app_search_response(app_client, ctx): assert resp_json.get("stac_extensions") is None +@pytest.mark.asyncio async def test_app_context_extension(app_client, ctx, txn_client): test_item = ctx.item test_item["id"] = "test-item-2" @@ -110,6 +117,7 @@ async def test_app_context_extension(app_client, ctx, txn_client): assert matched == 1 +@pytest.mark.asyncio async def test_app_fields_extension(app_client, ctx, txn_client): resp = await app_client.get("/search", params={"collections": ["test-collection"]}) assert resp.status_code == 200 @@ -117,6 +125,7 @@ async def test_app_fields_extension(app_client, ctx, txn_client): assert list(resp_json["features"][0]["properties"]) == ["datetime"] +@pytest.mark.asyncio async def test_app_fields_extension_query(app_client, ctx, txn_client): resp = await app_client.post( "/search", @@ -130,6 +139,7 @@ async def test_app_fields_extension_query(app_client, ctx, txn_client): assert list(resp_json["features"][0]["properties"]) == ["datetime", "proj:epsg"] +@pytest.mark.asyncio async def test_app_fields_extension_no_properties_get(app_client, ctx, txn_client): resp = await app_client.get( "/search", params={"collections": ["test-collection"], "fields": "-properties"} @@ -139,6 +149,7 @@ async def test_app_fields_extension_no_properties_get(app_client, ctx, txn_clien assert "properties" not in resp_json["features"][0] +@pytest.mark.asyncio async def test_app_fields_extension_no_properties_post(app_client, ctx, txn_client): resp = await app_client.post( "/search", @@ -152,6 +163,7 @@ async def test_app_fields_extension_no_properties_post(app_client, ctx, txn_clie assert "properties" not in resp_json["features"][0] +@pytest.mark.asyncio async def test_app_fields_extension_return_all_properties(app_client, ctx, txn_client): item = ctx.item resp = await app_client.get( @@ -168,6 +180,7 @@ async def test_app_fields_extension_return_all_properties(app_client, ctx, txn_c assert feature["properties"][expected_prop] == expected_value +@pytest.mark.asyncio async def test_app_query_extension_gt(app_client, ctx): params = {"query": {"proj:epsg": {"gt": ctx.item["properties"]["proj:epsg"]}}} resp = await app_client.post("/search", json=params) @@ -176,6 +189,7 @@ async def test_app_query_extension_gt(app_client, ctx): assert len(resp_json["features"]) == 0 +@pytest.mark.asyncio async def test_app_query_extension_gte(app_client, ctx): params = {"query": {"proj:epsg": {"gte": ctx.item["properties"]["proj:epsg"]}}} resp = await app_client.post("/search", json=params) @@ -184,22 +198,26 @@ async def test_app_query_extension_gte(app_client, ctx): assert len(resp.json()["features"]) == 1 +@pytest.mark.asyncio async def test_app_query_extension_limit_lt0(app_client): assert (await app_client.post("/search", json={"limit": -1})).status_code == 400 +@pytest.mark.asyncio async def test_app_query_extension_limit_gt10000(app_client): resp = await app_client.post("/search", json={"limit": 10001}) assert resp.status_code == 200 assert resp.json()["context"]["limit"] == 10000 +@pytest.mark.asyncio async def test_app_query_extension_limit_10000(app_client): params = {"limit": 10000} resp = await app_client.post("/search", json=params) assert resp.status_code == 200 +@pytest.mark.asyncio async def test_app_sort_extension(app_client, txn_client, ctx): first_item = ctx.item item_date = datetime.strptime( @@ -225,6 +243,7 @@ async def test_app_sort_extension(app_client, txn_client, ctx): assert resp_json["features"][1]["id"] == second_item["id"] +@pytest.mark.asyncio async def test_search_invalid_date(app_client, ctx): params = { "datetime": "2020-XX-01/2020-10-30", @@ -272,6 +291,7 @@ async def test_search_point_intersects_post(app_client, ctx): assert len(resp_json["features"]) == 1 +@pytest.mark.asyncio async def test_search_point_does_not_intersect(app_client, ctx): point = [15.04, -3.14] intersects = {"type": "Point", "coordinates": point} @@ -287,6 +307,7 @@ async def test_search_point_does_not_intersect(app_client, ctx): assert len(resp_json["features"]) == 0 +@pytest.mark.asyncio async def test_datetime_non_interval(app_client, ctx): dt_formats = [ "2020-02-12T12:30:22+00:00", @@ -308,6 +329,7 @@ async def test_datetime_non_interval(app_client, ctx): assert resp_json["features"][0]["properties"]["datetime"][0:19] == dt[0:19] +@pytest.mark.asyncio async def test_bbox_3d(app_client, ctx): australia_bbox = [106.343365, -47.199523, 0.1, 168.218365, -19.437288, 0.1] params = { @@ -320,6 +342,7 @@ async def test_bbox_3d(app_client, ctx): assert len(resp_json["features"]) == 1 +@pytest.mark.asyncio async def test_search_line_string_intersects(app_client, ctx): line = [[150.04, -33.14], [150.22, -33.89]] intersects = {"type": "LineString", "coordinates": line} diff --git a/stac_fastapi/elasticsearch/tests/clients/test_elasticsearch.py b/stac_fastapi/elasticsearch/tests/clients/test_elasticsearch.py index e46d4d1f..3da8f86d 100644 --- a/stac_fastapi/elasticsearch/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/elasticsearch/tests/clients/test_elasticsearch.py @@ -11,6 +11,7 @@ from ..conftest import MockRequest, create_item +@pytest.mark.asyncio async def test_create_collection(app_client, ctx, core_client, txn_client): in_coll = deepcopy(ctx.collection) in_coll["id"] = str(uuid.uuid4()) @@ -20,6 +21,7 @@ async def test_create_collection(app_client, ctx, core_client, txn_client): await txn_client.delete_collection(in_coll["id"]) +@pytest.mark.asyncio async def test_create_collection_already_exists(app_client, ctx, txn_client): data = deepcopy(ctx.collection) @@ -32,6 +34,7 @@ async def test_create_collection_already_exists(app_client, ctx, txn_client): await txn_client.delete_collection(data["id"]) +@pytest.mark.asyncio async def test_update_collection( core_client, txn_client, @@ -49,6 +52,7 @@ async def test_update_collection( await txn_client.delete_collection(data["id"]) +@pytest.mark.asyncio async def test_delete_collection( core_client, txn_client, @@ -63,6 +67,7 @@ async def test_delete_collection( await core_client.get_collection(data["id"], request=MockRequest) +@pytest.mark.asyncio async def test_get_collection( core_client, txn_client, @@ -76,6 +81,7 @@ async def test_get_collection( await txn_client.delete_collection(data["id"]) +@pytest.mark.asyncio async def test_get_item(app_client, ctx, core_client): got_item = await core_client.get_item( item_id=ctx.item["id"], @@ -86,6 +92,7 @@ async def test_get_item(app_client, ctx, core_client): assert got_item["collection"] == ctx.item["collection"] +@pytest.mark.asyncio async def test_get_collection_items(app_client, ctx, core_client, txn_client): coll = ctx.collection num_of_items_to_create = 5 @@ -106,6 +113,7 @@ async def test_get_collection_items(app_client, ctx, core_client, txn_client): assert item["collection"] == coll["id"] +@pytest.mark.asyncio async def test_create_item(ctx, core_client, txn_client): resp = await core_client.get_item( ctx.item["id"], ctx.item["collection"], request=MockRequest @@ -115,6 +123,7 @@ async def test_create_item(ctx, core_client, txn_client): ) == Item(**resp).dict(exclude={"links": ..., "properties": {"created", "updated"}}) +@pytest.mark.asyncio async def test_create_item_already_exists(ctx, txn_client): with pytest.raises(ConflictError): await txn_client.create_item( @@ -125,6 +134,7 @@ async def test_create_item_already_exists(ctx, txn_client): ) +@pytest.mark.asyncio async def test_update_item(ctx, core_client, txn_client): ctx.item["properties"]["foo"] = "bar" collection_id = ctx.item["collection"] @@ -139,6 +149,7 @@ async def test_update_item(ctx, core_client, txn_client): assert updated_item["properties"]["foo"] == "bar" +@pytest.mark.asyncio async def test_update_geometry(ctx, core_client, txn_client): new_coordinates = [ [ @@ -163,6 +174,7 @@ async def test_update_geometry(ctx, core_client, txn_client): assert updated_item["geometry"]["coordinates"] == new_coordinates +@pytest.mark.asyncio async def test_delete_item(ctx, core_client, txn_client): await txn_client.delete_item(ctx.item["id"], ctx.item["collection"]) @@ -172,6 +184,7 @@ async def test_delete_item(ctx, core_client, txn_client): ) +@pytest.mark.asyncio async def test_bulk_item_insert(ctx, core_client, txn_client, bulk_txn_client): items = {} for _ in range(10): @@ -193,6 +206,7 @@ async def test_bulk_item_insert(ctx, core_client, txn_client, bulk_txn_client): # ) +@pytest.mark.asyncio async def test_feature_collection_insert( core_client, txn_client, @@ -212,6 +226,7 @@ async def test_feature_collection_insert( assert len(fc["features"]) >= 10 +@pytest.mark.asyncio async def test_landing_page_no_collection_title(ctx, core_client, txn_client, app): ctx.collection["id"] = "new_id" del ctx.collection["title"] diff --git a/stac_fastapi/elasticsearch/tests/conftest.py b/stac_fastapi/elasticsearch/tests/conftest.py index 0a24b1f8..51f14534 100644 --- a/stac_fastapi/elasticsearch/tests/conftest.py +++ b/stac_fastapi/elasticsearch/tests/conftest.py @@ -41,11 +41,16 @@ class MockRequest: base_url = "http://test-server" def __init__( - self, method: str = "GET", url: str = "XXXX", app: Optional[Any] = None + self, + method: str = "GET", + url: str = "XXXX", + app: Optional[Any] = None, + query_params: Dict[str, Any] = {"limit": "10"}, ): self.method = method self.url = url self.app = app + self.query_params = query_params or {} class TestSettings(AsyncElasticsearchSettings): diff --git a/stac_fastapi/elasticsearch/tests/resources/test_collection.py b/stac_fastapi/elasticsearch/tests/resources/test_collection.py index f37b36b0..9061ac1e 100644 --- a/stac_fastapi/elasticsearch/tests/resources/test_collection.py +++ b/stac_fastapi/elasticsearch/tests/resources/test_collection.py @@ -1,6 +1,12 @@ +import uuid + import pystac +import pytest + +from ..conftest import create_collection, delete_collections_and_items, refresh_indices +@pytest.mark.asyncio async def test_create_and_delete_collection(app_client, load_test_data): """Test creation and deletion of a collection""" test_collection = load_test_data("test_collection.json") @@ -13,6 +19,7 @@ async def test_create_and_delete_collection(app_client, load_test_data): assert resp.status_code == 204 +@pytest.mark.asyncio async def test_create_collection_conflict(app_client, ctx): """Test creation of a collection which already exists""" # This collection ID is created in the fixture, so this should be a conflict @@ -20,12 +27,14 @@ async def test_create_collection_conflict(app_client, ctx): assert resp.status_code == 409 +@pytest.mark.asyncio async def test_delete_missing_collection(app_client): """Test deletion of a collection which does not exist""" resp = await app_client.delete("/collections/missing-collection") assert resp.status_code == 404 +@pytest.mark.asyncio async def test_update_collection_already_exists(ctx, app_client): """Test updating a collection which already exists""" ctx.collection["keywords"].append("test") @@ -38,6 +47,7 @@ async def test_update_collection_already_exists(ctx, app_client): assert "test" in resp_json["keywords"] +@pytest.mark.asyncio async def test_update_new_collection(app_client, load_test_data): """Test updating a collection which does not exist (same as creation)""" test_collection = load_test_data("test_collection.json") @@ -47,12 +57,14 @@ async def test_update_new_collection(app_client, load_test_data): assert resp.status_code == 404 +@pytest.mark.asyncio async def test_collection_not_found(app_client): """Test read a collection which does not exist""" resp = await app_client.get("/collections/does-not-exist") assert resp.status_code == 404 +@pytest.mark.asyncio async def test_returns_valid_collection(ctx, app_client): """Test validates fetched collection with jsonschema""" resp = await app_client.put("/collections", json=ctx.collection) @@ -70,3 +82,42 @@ async def test_returns_valid_collection(ctx, app_client): resp_json, root=mock_root, preserve_dict=False ) collection.validate() + + +@pytest.mark.asyncio +async def test_pagination_collection(app_client, ctx, txn_client): + """Test collection pagination links""" + + # Clear existing collections if necessary + await delete_collections_and_items(txn_client) + + # Ingest 6 collections + ids = set() + for _ in range(6): + ctx.collection["id"] = str(uuid.uuid4()) + await create_collection(txn_client, collection=ctx.collection) + ids.add(ctx.collection["id"]) + + await refresh_indices(txn_client) + + # Paginate through all 6 collections with a limit of 1 + collection_ids = set() + page = await app_client.get("/collections", params={"limit": 1}) + while True: + page_data = page.json() + assert ( + len(page_data["collections"]) <= 1 + ) # Each page should have 1 or 0 collections + collection_ids.update(coll["id"] for coll in page_data["collections"]) + + next_link = next( + (link for link in page_data["links"] if link["rel"] == "next"), None + ) + if not next_link: + break # No more pages + + href = next_link["href"][len("http://test-server") :] + page = await app_client.get(href) + + # Confirm we have paginated through all collections + assert collection_ids == ids diff --git a/stac_fastapi/elasticsearch/tests/resources/test_conformance.py b/stac_fastapi/elasticsearch/tests/resources/test_conformance.py index ab70a00b..d93d8b81 100644 --- a/stac_fastapi/elasticsearch/tests/resources/test_conformance.py +++ b/stac_fastapi/elasticsearch/tests/resources/test_conformance.py @@ -20,6 +20,7 @@ def get_link(landing_page, rel_type): ) +@pytest.mark.asyncio async def test_landing_page_health(response): """Test landing page""" assert response.status_code == 200 @@ -39,6 +40,7 @@ async def test_landing_page_health(response): ] +@pytest.mark.asyncio @pytest.mark.parametrize("rel_type,expected_media_type,expected_path", link_tests) async def test_landing_page_links( response_json, app_client, rel_type, expected_media_type, expected_path @@ -59,6 +61,7 @@ async def test_landing_page_links( # code here seems meaningless since it would be the same as if the endpoint did not exist. Once # https://github.com/stac-utils/stac-fastapi/pull/227 has been merged we can add this to the # parameterized tests above. +@pytest.mark.asyncio async def test_search_link(response_json): search_link = get_link(response_json, "search") diff --git a/stac_fastapi/elasticsearch/tests/resources/test_item.py b/stac_fastapi/elasticsearch/tests/resources/test_item.py index 76f38f79..2cd442f0 100644 --- a/stac_fastapi/elasticsearch/tests/resources/test_item.py +++ b/stac_fastapi/elasticsearch/tests/resources/test_item.py @@ -23,6 +23,7 @@ def rfc3339_str_to_datetime(s: str) -> datetime: return ciso8601.parse_rfc3339(s) +@pytest.mark.asyncio async def test_create_and_delete_item(app_client, ctx, txn_client): """Test creation and deletion of a single item (transactions extension)""" @@ -46,6 +47,7 @@ async def test_create_and_delete_item(app_client, ctx, txn_client): assert resp.status_code == 404 +@pytest.mark.asyncio async def test_create_item_conflict(app_client, ctx): """Test creation of an item which already exists (transactions extension)""" @@ -57,6 +59,7 @@ async def test_create_item_conflict(app_client, ctx): assert resp.status_code == 409 +@pytest.mark.asyncio async def test_delete_missing_item(app_client, load_test_data): """Test deletion of an item which does not exist (transactions extension)""" test_item = load_test_data("test_item.json") @@ -66,6 +69,7 @@ async def test_delete_missing_item(app_client, load_test_data): assert resp.status_code == 404 +@pytest.mark.asyncio async def test_create_item_missing_collection(app_client, ctx): """Test creation of an item without a parent collection (transactions extension)""" ctx.item["collection"] = "stac_is_cool" @@ -75,6 +79,7 @@ async def test_create_item_missing_collection(app_client, ctx): assert resp.status_code == 404 +@pytest.mark.asyncio async def test_create_uppercase_collection_with_item(app_client, ctx, txn_client): """Test creation of a collection and item with uppercase collection ID (transactions extension)""" collection_id = "UPPERCASE" @@ -87,6 +92,7 @@ async def test_create_uppercase_collection_with_item(app_client, ctx, txn_client assert resp.status_code == 200 +@pytest.mark.asyncio async def test_update_item_already_exists(app_client, ctx): """Test updating an item which already exists (transactions extension)""" @@ -106,6 +112,7 @@ async def test_update_item_already_exists(app_client, ctx): ) +@pytest.mark.asyncio async def test_update_new_item(app_client, ctx): """Test updating an item which does not exist (transactions extension)""" test_item = ctx.item @@ -118,6 +125,7 @@ async def test_update_new_item(app_client, ctx): assert resp.status_code == 404 +@pytest.mark.asyncio async def test_update_item_missing_collection(app_client, ctx): """Test updating an item without a parent collection (transactions extension)""" # Try to update collection of the item @@ -128,6 +136,7 @@ async def test_update_item_missing_collection(app_client, ctx): assert resp.status_code == 404 +@pytest.mark.asyncio async def test_update_item_geometry(app_client, ctx): ctx.item["id"] = "update_test_item_1" @@ -162,6 +171,7 @@ async def test_update_item_geometry(app_client, ctx): assert resp.json()["geometry"]["coordinates"] == new_coordinates +@pytest.mark.asyncio async def test_get_item(app_client, ctx): """Test read an item by id (core)""" get_item = await app_client.get( @@ -170,6 +180,7 @@ async def test_get_item(app_client, ctx): assert get_item.status_code == 200 +@pytest.mark.asyncio async def test_returns_valid_item(app_client, ctx): """Test validates fetched item with jsonschema""" test_item = ctx.item @@ -186,6 +197,7 @@ async def test_returns_valid_item(app_client, ctx): item.validate() +@pytest.mark.asyncio async def test_get_item_collection(app_client, ctx, txn_client): """Test read an item collection (core)""" item_count = randint(1, 4) @@ -202,6 +214,7 @@ async def test_get_item_collection(app_client, ctx, txn_client): assert matched == item_count + 1 +@pytest.mark.asyncio async def test_item_collection_filter_bbox(app_client, ctx): item = ctx.item collection = item["collection"] @@ -223,6 +236,7 @@ async def test_item_collection_filter_bbox(app_client, ctx): assert len(resp_json["features"]) == 0 +@pytest.mark.asyncio async def test_item_collection_filter_datetime(app_client, ctx): item = ctx.item collection = item["collection"] @@ -272,6 +286,7 @@ async def test_pagination(app_client, load_test_data): assert second_page["context"]["returned"] == 3 +@pytest.mark.asyncio async def test_item_timestamps(app_client, ctx): """Test created and updated timestamps (common metadata)""" # start_time = now_to_rfc3339_str() @@ -300,6 +315,7 @@ async def test_item_timestamps(app_client, ctx): ) +@pytest.mark.asyncio async def test_item_search_by_id_post(app_client, ctx, txn_client): """Test POST search by item id (core)""" ids = ["test1", "test2", "test3"] @@ -315,6 +331,7 @@ async def test_item_search_by_id_post(app_client, ctx, txn_client): assert set([feat["id"] for feat in resp_json["features"]]) == set(ids) +@pytest.mark.asyncio async def test_item_search_spatial_query_post(app_client, ctx): """Test POST search with spatial query (core)""" test_item = ctx.item @@ -329,6 +346,7 @@ async def test_item_search_spatial_query_post(app_client, ctx): assert resp_json["features"][0]["id"] == test_item["id"] +@pytest.mark.asyncio async def test_item_search_temporal_query_post(app_client, ctx): """Test POST search with single-tailed spatio-temporal query (core)""" @@ -347,6 +365,7 @@ async def test_item_search_temporal_query_post(app_client, ctx): assert resp_json["features"][0]["id"] == test_item["id"] +@pytest.mark.asyncio async def test_item_search_temporal_window_post(app_client, ctx): """Test POST search with two-tailed spatio-temporal query (core)""" test_item = ctx.item @@ -412,6 +431,7 @@ async def test_item_search_sort_post(app_client, load_test_data): ) +@pytest.mark.asyncio async def test_item_search_by_id_get(app_client, ctx, txn_client): """Test GET search by item id (core)""" ids = ["test1", "test2", "test3"] @@ -427,6 +447,7 @@ async def test_item_search_by_id_get(app_client, ctx, txn_client): assert set([feat["id"] for feat in resp_json["features"]]) == set(ids) +@pytest.mark.asyncio async def test_item_search_bbox_get(app_client, ctx): """Test GET search with spatial query (core)""" params = { @@ -439,6 +460,7 @@ async def test_item_search_bbox_get(app_client, ctx): assert resp_json["features"][0]["id"] == ctx.item["id"] +@pytest.mark.asyncio async def test_item_search_get_without_collections(app_client, ctx): """Test GET search without specifying collections""" @@ -449,6 +471,7 @@ async def test_item_search_get_without_collections(app_client, ctx): assert resp.status_code == 200 +@pytest.mark.asyncio async def test_item_search_get_with_non_existent_collections(app_client, ctx): """Test GET search with non-existent collections""" @@ -457,6 +480,7 @@ async def test_item_search_get_with_non_existent_collections(app_client, ctx): assert resp.status_code == 200 +@pytest.mark.asyncio async def test_item_search_temporal_window_get(app_client, ctx): """Test GET search with spatio-temporal query (core)""" test_item = ctx.item @@ -495,6 +519,7 @@ async def test_item_search_sort_get(app_client, ctx, txn_client): assert resp_json["features"][1]["id"] == second_item["id"] +@pytest.mark.asyncio async def test_item_search_post_without_collection(app_client, ctx): """Test POST search without specifying a collection""" test_item = ctx.item @@ -505,6 +530,7 @@ async def test_item_search_post_without_collection(app_client, ctx): assert resp.status_code == 200 +@pytest.mark.asyncio async def test_item_search_properties_es(app_client, ctx): """Test POST search with JSONB query (query extension)""" @@ -517,6 +543,7 @@ async def test_item_search_properties_es(app_client, ctx): assert len(resp_json["features"]) == 0 +@pytest.mark.asyncio async def test_item_search_properties_field(app_client): """Test POST search indexed field with query (query extension)""" @@ -528,6 +555,7 @@ async def test_item_search_properties_field(app_client): assert len(resp_json["features"]) == 0 +@pytest.mark.asyncio async def test_item_search_get_query_extension(app_client, ctx): """Test GET search with JSONB query (query extension)""" @@ -554,12 +582,14 @@ async def test_item_search_get_query_extension(app_client, ctx): ) +@pytest.mark.asyncio async def test_get_missing_item_collection(app_client): """Test reading a collection which does not exist""" resp = await app_client.get("/collections/invalid-collection/items") assert resp.status_code == 404 +@pytest.mark.asyncio async def test_pagination_item_collection(app_client, ctx, txn_client): """Test item collection pagination links (paging extension)""" ids = [ctx.item["id"]] @@ -596,6 +626,7 @@ async def test_pagination_item_collection(app_client, ctx, txn_client): assert not set(item_ids) - set(ids) +@pytest.mark.asyncio async def test_pagination_post(app_client, ctx, txn_client): """Test POST pagination (paging extension)""" ids = [ctx.item["id"]] @@ -631,6 +662,7 @@ async def test_pagination_post(app_client, ctx, txn_client): assert not set(item_ids) - set(ids) +@pytest.mark.asyncio async def test_pagination_token_idempotent(app_client, ctx, txn_client): """Test that pagination tokens are idempotent (paging extension)""" ids = [ctx.item["id"]] @@ -661,6 +693,7 @@ async def test_pagination_token_idempotent(app_client, ctx, txn_client): ] +@pytest.mark.asyncio async def test_field_extension_get_includes(app_client, ctx): """Test GET search with included fields (fields extension)""" test_item = ctx.item @@ -673,6 +706,7 @@ async def test_field_extension_get_includes(app_client, ctx): assert not set(feat_properties) - {"proj:epsg", "gsd", "datetime"} +@pytest.mark.asyncio async def test_field_extension_get_excludes(app_client, ctx): """Test GET search with included fields (fields extension)""" test_item = ctx.item @@ -686,6 +720,7 @@ async def test_field_extension_get_excludes(app_client, ctx): assert "gsd" not in resp_json["features"][0]["properties"].keys() +@pytest.mark.asyncio async def test_field_extension_post(app_client, ctx): """Test POST search with included and excluded fields (fields extension)""" test_item = ctx.item diff --git a/stac_fastapi/elasticsearch/tests/resources/test_mgmt.py b/stac_fastapi/elasticsearch/tests/resources/test_mgmt.py index 9d2bc3dc..2b7d9728 100644 --- a/stac_fastapi/elasticsearch/tests/resources/test_mgmt.py +++ b/stac_fastapi/elasticsearch/tests/resources/test_mgmt.py @@ -1,3 +1,7 @@ +import pytest + + +@pytest.mark.asyncio async def test_ping_no_param(app_client): """ Test ping endpoint with a mocked client.