diff --git a/CHANGELOG.md b/CHANGELOG.md index 94dbe9a97..cbd110604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +[v6.5.1] - 2025-09-30 + +### Fixed + +- Issue where token, query param was not being passed to POST collections search logic [#483](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/483) +- Issue where datetime param was not being passed from POST collections search logic to Elasticsearch [#483](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/483) +- Collections search tests to ensure both GET /collections and GET/POST /collections-search endpoints are tested [#483](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/483) + [v6.5.0] - 2025-09-29 ### Added @@ -547,7 +555,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Use genexp in execute_search and get_all_collections to return results. - Added db_to_stac serializer to item_collection method in core.py. -[Unreleased]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.5.0...main +[Unreleased]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.5.1...main +[v6.5.1]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.5.0...v6.5.1 [v6.5.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.4.0...v6.5.0 [v6.4.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.3.0...v6.4.0 [v6.3.0]: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/compare/v6.2.1...v6.3.0 diff --git a/compose.yml b/compose.yml index 77d64198b..8c83ae127 100644 --- a/compose.yml +++ b/compose.yml @@ -22,6 +22,7 @@ services: - ES_VERIFY_CERTS=false - BACKEND=elasticsearch - DATABASE_REFRESH=true + - ENABLE_COLLECTIONS_SEARCH_ROUTE=true ports: - "8080:8080" volumes: @@ -56,6 +57,7 @@ services: - ES_VERIFY_CERTS=false - BACKEND=opensearch - STAC_FASTAPI_RATE_LIMIT=200/minute + - ENABLE_COLLECTIONS_SEARCH_ROUTE=true ports: - "8082:8082" volumes: diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index ac2f228d2..143b4d5ac 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -240,14 +240,16 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: async def all_collections( self, - datetime: Optional[str] = None, limit: Optional[int] = None, + datetime: Optional[str] = None, fields: Optional[List[str]] = None, sortby: Optional[Union[str, List[str]]] = None, filter_expr: Optional[str] = None, filter_lang: Optional[str] = None, q: Optional[Union[str, List[str]]] = None, query: Optional[str] = None, + request: Request = None, + token: Optional[str] = None, **kwargs, ) -> stac_types.Collections: """Read all collections from the database. @@ -266,7 +268,6 @@ async def all_collections( Returns: A Collections object containing all the collections in the database and links to various resources. """ - request = kwargs["request"] base_url = str(request.base_url) # Get the global limit from environment variable @@ -298,7 +299,9 @@ async def all_collections( else: limit = 10 - token = request.query_params.get("token") + # Get token from query params only if not already provided (for GET requests) + if token is None: + token = request.query_params.get("token") # Process fields parameter for filtering collection properties includes, excludes = set(), set() @@ -499,6 +502,10 @@ async def post_all_collections( # Pass all parameters from search_request to all_collections return await self.all_collections( limit=search_request.limit if hasattr(search_request, "limit") else None, + datetime=search_request.datetime + if hasattr(search_request, "datetime") + else None, + token=search_request.token if hasattr(search_request, "token") else None, fields=fields, sortby=sortby, filter_expr=search_request.filter diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/collections_search.py b/stac_fastapi/core/stac_fastapi/core/extensions/collections_search.py index 0ddbefeda..d36197d03 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/collections_search.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/collections_search.py @@ -18,6 +18,10 @@ class CollectionsSearchRequest(ExtendedSearch): """Extended search model for collections with free text search support.""" q: Optional[Union[str, List[str]]] = None + token: Optional[str] = None + query: Optional[ + str + ] = None # Legacy query extension (deprecated but still supported) class CollectionsSearchEndpointExtension(ApiExtension): diff --git a/stac_fastapi/core/stac_fastapi/core/version.py b/stac_fastapi/core/stac_fastapi/core/version.py index d5564adc9..019563e86 100644 --- a/stac_fastapi/core/stac_fastapi/core/version.py +++ b/stac_fastapi/core/stac_fastapi/core/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "6.5.0" +__version__ = "6.5.1" diff --git a/stac_fastapi/elasticsearch/setup.py b/stac_fastapi/elasticsearch/setup.py index adef9b75a..1751df78f 100644 --- a/stac_fastapi/elasticsearch/setup.py +++ b/stac_fastapi/elasticsearch/setup.py @@ -6,8 +6,8 @@ desc = f.read() install_requires = [ - "stac-fastapi-core==6.5.0", - "sfeos-helpers==6.5.0", + "stac-fastapi-core==6.5.1", + "sfeos-helpers==6.5.1", "elasticsearch[async]~=8.18.0", "uvicorn~=0.23.0", "starlette>=0.35.0,<0.36.0", diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index f4f33cb97..9c136411a 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -309,7 +309,6 @@ async def get_all_collections( query_parts.append(search_dict["query"]) except Exception as e: - logger = logging.getLogger(__name__) logger.error(f"Error converting query to Elasticsearch: {e}") # If there's an error, add a query that matches nothing query_parts.append({"bool": {"must_not": {"match_all": {}}}}) @@ -381,7 +380,6 @@ async def get_all_collections( try: matched = count_task.result().get("count") except Exception as e: - logger = logging.getLogger(__name__) logger.error(f"Count task failed: {e}") return collections, next_token, matched diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py index d5564adc9..019563e86 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "6.5.0" +__version__ = "6.5.1" diff --git a/stac_fastapi/opensearch/setup.py b/stac_fastapi/opensearch/setup.py index e301addd6..d7727267f 100644 --- a/stac_fastapi/opensearch/setup.py +++ b/stac_fastapi/opensearch/setup.py @@ -6,8 +6,8 @@ desc = f.read() install_requires = [ - "stac-fastapi-core==6.5.0", - "sfeos-helpers==6.5.0", + "stac-fastapi-core==6.5.1", + "sfeos-helpers==6.5.1", "opensearch-py~=2.8.0", "opensearch-py[async]~=2.8.0", "uvicorn~=0.23.0", diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 8791390bb..d16e8215a 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -293,7 +293,6 @@ async def get_all_collections( query_parts.append(search_dict["query"]) except Exception as e: - logger = logging.getLogger(__name__) logger.error(f"Error converting query to OpenSearch: {e}") # If there's an error, add a query that matches nothing query_parts.append({"bool": {"must_not": {"match_all": {}}}}) @@ -365,7 +364,6 @@ async def get_all_collections( try: matched = count_task.result().get("count") except Exception as e: - logger = logging.getLogger(__name__) logger.error(f"Count task failed: {e}") return collections, next_token, matched diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py index d5564adc9..019563e86 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "6.5.0" +__version__ = "6.5.1" diff --git a/stac_fastapi/sfeos_helpers/setup.py b/stac_fastapi/sfeos_helpers/setup.py index 35306eb60..e7cdd84c6 100644 --- a/stac_fastapi/sfeos_helpers/setup.py +++ b/stac_fastapi/sfeos_helpers/setup.py @@ -6,7 +6,7 @@ desc = f.read() install_requires = [ - "stac-fastapi.core==6.5.0", + "stac-fastapi.core==6.5.1", ] setup( diff --git a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py index d5564adc9..019563e86 100644 --- a/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py +++ b/stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/version.py @@ -1,2 +1,2 @@ """library version.""" -__version__ = "6.5.0" +__version__ = "6.5.1" diff --git a/stac_fastapi/tests/api/test_api_search_collections.py b/stac_fastapi/tests/api/test_api_search_collections.py index 8f5bed73b..029292ed0 100644 --- a/stac_fastapi/tests/api/test_api_search_collections.py +++ b/stac_fastapi/tests/api/test_api_search_collections.py @@ -8,7 +8,7 @@ @pytest.mark.asyncio async def test_collections_sort_id_asc(app_client, txn_client, ctx): - """Verify GET /collections honors ascending sort on id.""" + """Verify GET /collections, GET /collections-search, and POST /collections-search honor ascending sort on id.""" # Create multiple collections with different ids base_collection = ctx.collection @@ -25,29 +25,48 @@ async def test_collections_sort_id_asc(app_client, txn_client, ctx): await refresh_indices(txn_client) - # Test ascending sort by id - resp = await app_client.get( - "/collections", - params=[("sortby", "+id")], - ) - assert resp.status_code == 200 - resp_json = resp.json() - - # Filter collections to only include the ones we created for this test - test_collections = [ - c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + # Define endpoints to test + endpoints = [ + {"method": "GET", "path": "/collections", "params": [("sortby", "+id")]}, + { + "method": "GET", + "path": "/collections-search", + "params": [("sortby", "+id")], + }, + { + "method": "POST", + "path": "/collections-search", + "body": {"sortby": [{"field": "id", "direction": "asc"}]}, + }, ] - # Collections should be sorted alphabetically by id - sorted_ids = sorted(collection_ids) - assert len(test_collections) == len(collection_ids) - for i, expected_id in enumerate(sorted_ids): - assert test_collections[i]["id"] == expected_id + for endpoint in endpoints: + # Test ascending sort by id + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) + + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']}" + resp_json = resp.json() + + # Filter collections to only include the ones we created for this test + test_collections = [ + c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + ] + + # Collections should be sorted alphabetically by id + sorted_ids = sorted(collection_ids) + assert len(test_collections) == len(collection_ids) + for i, expected_id in enumerate(sorted_ids): + assert test_collections[i]["id"] == expected_id @pytest.mark.asyncio async def test_collections_sort_id_desc(app_client, txn_client, ctx): - """Verify GET /collections honors descending sort on id.""" + """Verify GET /collections, GET /collections-search, and POST /collections-search honor descending sort on id.""" # Create multiple collections with different ids base_collection = ctx.collection @@ -64,24 +83,43 @@ async def test_collections_sort_id_desc(app_client, txn_client, ctx): await refresh_indices(txn_client) - # Test descending sort by id - resp = await app_client.get( - "/collections", - params=[("sortby", "-id")], - ) - assert resp.status_code == 200 - resp_json = resp.json() - - # Filter collections to only include the ones we created for this test - test_collections = [ - c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + # Define endpoints to test + endpoints = [ + {"method": "GET", "path": "/collections", "params": [("sortby", "-id")]}, + { + "method": "GET", + "path": "/collections-search", + "params": [("sortby", "-id")], + }, + { + "method": "POST", + "path": "/collections-search", + "body": {"sortby": [{"field": "id", "direction": "desc"}]}, + }, ] - # Collections should be sorted in reverse alphabetical order by id - sorted_ids = sorted(collection_ids, reverse=True) - assert len(test_collections) == len(collection_ids) - for i, expected_id in enumerate(sorted_ids): - assert test_collections[i]["id"] == expected_id + for endpoint in endpoints: + # Test descending sort by id + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) + + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']}" + resp_json = resp.json() + + # Filter collections to only include the ones we created for this test + test_collections = [ + c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + ] + + # Collections should be sorted in reverse alphabetical order by id + sorted_ids = sorted(collection_ids, reverse=True) + assert len(test_collections) == len(collection_ids) + for i, expected_id in enumerate(sorted_ids): + assert test_collections[i]["id"] == expected_id @pytest.mark.asyncio @@ -245,7 +283,7 @@ async def test_collections_free_text_all_endpoints( @pytest.mark.asyncio async def test_collections_filter_search(app_client, txn_client, ctx): - """Verify GET /collections honors the filter parameter for structured search.""" + """Verify GET /collections, GET /collections-search, and POST /collections-search honor the filter parameter for structured search.""" # Create multiple collections with different content base_collection = ctx.collection @@ -287,52 +325,97 @@ async def test_collections_filter_search(app_client, txn_client, ctx): # Use the ID of the first test collection for the filter test_collection_id = test_collections[0]["id"] + # Test 1: CQL2-JSON format # Create a simple filter for exact ID match using CQL2-JSON filter_expr = {"op": "=", "args": [{"property": "id"}, test_collection_id]} # Convert to JSON string for URL parameter filter_json = json.dumps(filter_expr) - # Use CQL2-JSON format with explicit filter-lang - resp = await app_client.get( - f"/collections?filter={filter_json}&filter-lang=cql2-json", - ) + # Define endpoints to test + endpoints = [ + { + "method": "GET", + "path": "/collections", + "params": [("filter", filter_json), ("filter-lang", "cql2-json")], + }, + { + "method": "GET", + "path": "/collections-search", + "params": [("filter", filter_json), ("filter-lang", "cql2-json")], + }, + { + "method": "POST", + "path": "/collections-search", + "body": {"filter": filter_expr, "filter-lang": "cql2-json"}, + }, + ] - assert resp.status_code == 200 - resp_json = resp.json() + for endpoint in endpoints: + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) - # Should find exactly one collection with the specified ID - found_collections = [ - c for c in resp_json["collections"] if c["id"] == test_collection_id - ] + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']}" + resp_json = resp.json() - assert ( - len(found_collections) == 1 - ), f"Expected 1 collection with ID {test_collection_id}, found {len(found_collections)}" - assert found_collections[0]["id"] == test_collection_id + # Should find exactly one collection with the specified ID + found_collections = [ + c for c in resp_json["collections"] if c["id"] == test_collection_id + ] - # Test 2: CQL2-text format with LIKE operator for more advanced filtering - # Use a filter that will match the test collection ID we created - filter_text = f"id LIKE '%{test_collection_id.split('-')[-1]}%'" + assert ( + len(found_collections) == 1 + ), f"Expected 1 collection with ID {test_collection_id}, found {len(found_collections)} for {endpoint['method']} {endpoint['path']}" + assert found_collections[0]["id"] == test_collection_id - resp = await app_client.get( - f"/collections?filter={filter_text}&filter-lang=cql2-text", - ) - assert resp.status_code == 200 - resp_json = resp.json() + # Test 2: CQL2-text format with LIKE operator + filter_text = f"id LIKE '%{test_collection_id.split('-')[-1]}%'" - # Should find the test collection we created - found_collections = [ - c for c in resp_json["collections"] if c["id"] == test_collection_id + endpoints = [ + { + "method": "GET", + "path": "/collections", + "params": [("filter", filter_text), ("filter-lang", "cql2-text")], + }, + { + "method": "GET", + "path": "/collections-search", + "params": [("filter", filter_text), ("filter-lang", "cql2-text")], + }, + { + "method": "POST", + "path": "/collections-search", + "body": {"filter": filter_text, "filter-lang": "cql2-text"}, + }, ] - assert ( - len(found_collections) >= 1 - ), f"Expected at least 1 collection with ID {test_collection_id} using LIKE filter" + + for endpoint in endpoints: + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) + + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']}" + resp_json = resp.json() + + # Should find the test collection we created + found_collections = [ + c for c in resp_json["collections"] if c["id"] == test_collection_id + ] + assert ( + len(found_collections) >= 1 + ), f"Expected at least 1 collection with ID {test_collection_id} using LIKE filter for {endpoint['method']} {endpoint['path']}" @pytest.mark.asyncio async def test_collections_query_extension(app_client, txn_client, ctx): - """Verify GET /collections honors the query extension.""" + """Verify GET /collections, GET /collections-search, and POST /collections-search honor the query extension.""" # Create multiple collections with different content base_collection = ctx.collection # Use unique prefixes to avoid conflicts between tests @@ -370,75 +453,100 @@ async def test_collections_query_extension(app_client, txn_client, ctx): await refresh_indices(txn_client) - # Use the exact ID that was created + # Test 1: Query with equal operator sentinel_id = f"{test_prefix}-sentinel" - query = {"id": {"eq": sentinel_id}} - resp = await app_client.get( - "/collections", - params=[("query", json.dumps(query))], - ) - assert resp.status_code == 200 - resp_json = resp.json() - - # Filter collections to only include the ones we created for this test - found_collections = [ - c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + endpoints = [ + { + "method": "GET", + "path": "/collections", + "params": [("query", json.dumps(query))], + }, + { + "method": "GET", + "path": "/collections-search", + "params": [("query", json.dumps(query))], + }, + { + "method": "POST", + "path": "/collections-search", + "body": {"query": json.dumps(query)}, + }, ] - # Should only find the sentinel collection - assert len(found_collections) == 1 - assert found_collections[0]["id"] == f"{test_prefix}-sentinel" - - # Test query extension with equal operator on ID - query = {"id": {"eq": f"{test_prefix}-sentinel"}} + for endpoint in endpoints: + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) - resp = await app_client.get( - "/collections", - params=[("query", json.dumps(query))], - ) - assert resp.status_code == 200 - resp_json = resp.json() + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']}" + resp_json = resp.json() - # Filter collections to only include the ones we created for this test - found_collections = [ - c for c in resp_json["collections"] if c["id"].startswith(test_prefix) - ] - found_ids = [c["id"] for c in found_collections] + # Filter collections to only include the ones we created for this test + found_collections = [ + c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + ] - # Should find landsat and modis collections but not sentinel - assert len(found_collections) == 1 - assert f"{test_prefix}-sentinel" in found_ids - assert f"{test_prefix}-landsat" not in found_ids - assert f"{test_prefix}-modis" not in found_ids + # Should only find the sentinel collection + assert ( + len(found_collections) == 1 + ), f"Expected 1 collection for {endpoint['method']} {endpoint['path']}" + assert found_collections[0]["id"] == sentinel_id - # Test query extension with not-equal operator on ID + # Test 2: Query with not-equal operator query = {"id": {"neq": f"{test_prefix}-sentinel"}} - resp = await app_client.get( - "/collections", - params=[("query", json.dumps(query))], - ) - assert resp.status_code == 200 - resp_json = resp.json() - - # Filter collections to only include the ones we created for this test - found_collections = [ - c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + endpoints = [ + { + "method": "GET", + "path": "/collections", + "params": [("query", json.dumps(query))], + }, + { + "method": "GET", + "path": "/collections-search", + "params": [("query", json.dumps(query))], + }, + { + "method": "POST", + "path": "/collections-search", + "body": {"query": json.dumps(query)}, + }, ] - found_ids = [c["id"] for c in found_collections] - # Should find landsat and modis collections but not sentinel - assert len(found_collections) == 2 - assert f"{test_prefix}-sentinel" not in found_ids - assert f"{test_prefix}-landsat" in found_ids - assert f"{test_prefix}-modis" in found_ids + for endpoint in endpoints: + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) + + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']}" + resp_json = resp.json() + + # Filter collections to only include the ones we created for this test + found_collections = [ + c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + ] + found_ids = [c["id"] for c in found_collections] + + # Should find landsat and modis collections but not sentinel + assert ( + len(found_collections) == 2 + ), f"Expected 2 collections for {endpoint['method']} {endpoint['path']}" + assert f"{test_prefix}-sentinel" not in found_ids + assert f"{test_prefix}-landsat" in found_ids + assert f"{test_prefix}-modis" in found_ids @pytest.mark.asyncio async def test_collections_datetime_filter(app_client, load_test_data, txn_client): - """Test filtering collections by datetime.""" + """Test filtering collections by datetime across all endpoints.""" # Create a test collection with a specific temporal extent base_collection = load_test_data("test_collection.json") @@ -450,66 +558,71 @@ async def test_collections_datetime_filter(app_client, load_test_data, txn_clien await create_collection(txn_client, base_collection) await refresh_indices(txn_client) - # Test 1: Datetime range that overlaps with collection's temporal extent - resp = await app_client.get( - "/collections?datetime=2020-06-01T00:00:00Z/2021-01-01T00:00:00Z" - ) - assert resp.status_code == 200 - resp_json = resp.json() - found_collections = [ - c for c in resp_json["collections"] if c["id"] == test_collection_id - ] - assert ( - len(found_collections) == 1 - ), f"Expected to find collection {test_collection_id} with overlapping datetime range" - - # Test 2: Datetime range that is completely before collection's temporal extent - resp = await app_client.get( - "/collections?datetime=2019-01-01T00:00:00Z/2019-12-31T23:59:59Z" - ) - assert resp.status_code == 200 - resp_json = resp.json() - found_collections = [ - c for c in resp_json["collections"] if c["id"] == test_collection_id + # Test scenarios with different datetime ranges + test_scenarios = [ + { + "name": "overlapping range", + "datetime": "2020-06-01T00:00:00Z/2021-01-01T00:00:00Z", + "expected_count": 1, + }, + { + "name": "before range", + "datetime": "2019-01-01T00:00:00Z/2019-12-31T23:59:59Z", + "expected_count": 0, + }, + { + "name": "after range", + "datetime": "2021-01-01T00:00:00Z/2021-12-31T23:59:59Z", + "expected_count": 0, + }, + { + "name": "single datetime within range", + "datetime": "2020-06-15T12:00:00Z", + "expected_count": 1, + }, + { + "name": "open-ended future range", + "datetime": "2020-06-01T00:00:00Z/..", + "expected_count": 1, + }, ] - assert ( - len(found_collections) == 0 - ), f"Expected not to find collection {test_collection_id} with non-overlapping datetime range" - # Test 3: Datetime range that is completely after collection's temporal extent - resp = await app_client.get( - "/collections?datetime=2021-01-01T00:00:00Z/2021-12-31T23:59:59Z" - ) - assert resp.status_code == 200 - resp_json = resp.json() - found_collections = [ - c for c in resp_json["collections"] if c["id"] == test_collection_id - ] - assert ( - len(found_collections) == 0 - ), f"Expected not to find collection {test_collection_id} with non-overlapping datetime range" + for scenario in test_scenarios: + endpoints = [ + { + "method": "GET", + "path": "/collections", + "params": [("datetime", scenario["datetime"])], + }, + { + "method": "GET", + "path": "/collections-search", + "params": [("datetime", scenario["datetime"])], + }, + { + "method": "POST", + "path": "/collections-search", + "body": {"datetime": scenario["datetime"]}, + }, + ] - # Test 4: Single datetime that falls within collection's temporal extent - resp = await app_client.get("/collections?datetime=2020-06-15T12:00:00Z") - assert resp.status_code == 200 - resp_json = resp.json() - found_collections = [ - c for c in resp_json["collections"] if c["id"] == test_collection_id - ] - assert ( - len(found_collections) == 1 - ), f"Expected to find collection {test_collection_id} with datetime point within range" + for endpoint in endpoints: + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) - # Test 5: Open-ended range (from a specific date to the future) - resp = await app_client.get("/collections?datetime=2020-06-01T00:00:00Z/..") - assert resp.status_code == 200 - resp_json = resp.json() - found_collections = [ - c for c in resp_json["collections"] if c["id"] == test_collection_id - ] - assert ( - len(found_collections) == 1 - ), f"Expected to find collection {test_collection_id} with open-ended future range" + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']} with {scenario['name']}" + resp_json = resp.json() + found_collections = [ + c for c in resp_json["collections"] if c["id"] == test_collection_id + ] + assert len(found_collections) == scenario["expected_count"], ( + f"Expected {scenario['expected_count']} collection(s) for {scenario['name']} " + f"on {endpoint['method']} {endpoint['path']}, found {len(found_collections)}" + ) # Test 6: Open-ended range (from the past to a date within the collection's range) # TODO: This test is currently skipped due to an unresolved issue with open-ended past range queries. @@ -528,7 +641,7 @@ async def test_collections_datetime_filter(app_client, load_test_data, txn_clien @pytest.mark.asyncio async def test_collections_number_matched_returned(app_client, txn_client, ctx): - """Verify GET /collections returns correct numberMatched and numberReturned values.""" + """Verify GET /collections, GET /collections-search, and POST /collections-search return correct numberMatched and numberReturned values.""" # Create multiple collections with different ids base_collection = ctx.collection @@ -545,56 +658,91 @@ async def test_collections_number_matched_returned(app_client, txn_client, ctx): await refresh_indices(txn_client) - # Test with limit=5 - resp = await app_client.get( - "/collections", - params=[("limit", "5")], - ) - assert resp.status_code == 200 - resp_json = resp.json() - - # Filter collections to only include the ones we created for this test - test_collections = [ - c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + # Test 1: With limit=5 + endpoints = [ + {"method": "GET", "path": "/collections", "params": [("limit", "5")]}, + {"method": "GET", "path": "/collections-search", "params": [("limit", "5")]}, + {"method": "POST", "path": "/collections-search", "body": {"limit": 5}}, ] - # Should return 5 collections - assert len(test_collections) == 5 + for endpoint in endpoints: + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) - # Check that numberReturned matches the number of collections returned - assert resp_json["numberReturned"] == len(resp_json["collections"]) + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']}" + resp_json = resp.json() - # Check that numberMatched is greater than or equal to numberReturned - # (since there might be other collections in the database) - assert resp_json["numberMatched"] >= resp_json["numberReturned"] + # Filter collections to only include the ones we created for this test + test_collections = [ + c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + ] + + # Should return 5 collections + assert ( + len(test_collections) == 5 + ), f"Expected 5 test collections for {endpoint['method']} {endpoint['path']}" - # Check that numberMatched includes at least all our test collections - assert resp_json["numberMatched"] >= len(collection_ids) + # Check that numberReturned matches the number of collections returned + assert resp_json["numberReturned"] == len(resp_json["collections"]) - # Now test with a query that should match only some collections + # Check that numberMatched is greater than or equal to numberReturned + assert resp_json["numberMatched"] >= resp_json["numberReturned"] + + # Check that numberMatched includes at least all our test collections + assert resp_json["numberMatched"] >= len(collection_ids) + + # Test 2: With a query that should match only one collection query = {"id": {"eq": f"{test_prefix}-1"}} - resp = await app_client.get( - "/collections", - params=[("query", json.dumps(query))], - ) - assert resp.status_code == 200 - resp_json = resp.json() - # Filter collections to only include the ones we created for this test - test_collections = [ - c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + endpoints = [ + { + "method": "GET", + "path": "/collections", + "params": [("query", json.dumps(query))], + }, + { + "method": "GET", + "path": "/collections-search", + "params": [("query", json.dumps(query))], + }, + { + "method": "POST", + "path": "/collections-search", + "body": {"query": json.dumps(query)}, + }, ] - # Should return only 1 collection - assert len(test_collections) == 1 - assert test_collections[0]["id"] == f"{test_prefix}-1" + for endpoint in endpoints: + if endpoint["method"] == "GET": + resp = await app_client.get(endpoint["path"], params=endpoint["params"]) + else: # POST + resp = await app_client.post(endpoint["path"], json=endpoint["body"]) - # Check that numberReturned matches the number of collections returned - assert resp_json["numberReturned"] == len(resp_json["collections"]) + assert ( + resp.status_code == 200 + ), f"Failed for {endpoint['method']} {endpoint['path']}" + resp_json = resp.json() + + # Filter collections to only include the ones we created for this test + test_collections = [ + c for c in resp_json["collections"] if c["id"].startswith(test_prefix) + ] + + # Should return only 1 collection + assert ( + len(test_collections) == 1 + ), f"Expected 1 test collection for {endpoint['method']} {endpoint['path']}" + assert test_collections[0]["id"] == f"{test_prefix}-1" + + # Check that numberReturned matches the number of collections returned + assert resp_json["numberReturned"] == len(resp_json["collections"]) - # Check that numberMatched matches the number of collections that match the query - # (should be 1 in this case) - assert resp_json["numberMatched"] >= 1 + # Check that numberMatched matches the number of collections that match the query + assert resp_json["numberMatched"] >= 1 @pytest.mark.asyncio @@ -787,17 +935,35 @@ async def test_collections_pagination_all_endpoints(app_client, txn_client, ctx) for i, expected_id in enumerate(expected_ids): assert test_found[i]["id"] == expected_id - # Test second page using the token from the first page - if "token" in resp_json and resp_json["token"]: - token = resp_json["token"] + # Test second page using the token from the next link + next_link = None + for link in resp_json.get("links", []): + if link.get("rel") == "next": + next_link = link + break - # Make the request with token + if next_link: + # Extract token based on method if endpoint["method"] == "GET": - params = [(endpoint["param"], str(limit)), ("token", token)] - resp = await app_client.get(endpoint["path"], params=params) + # For GET, token is in the URL query params + from urllib.parse import parse_qs, urlparse + + parsed_url = urlparse(next_link["href"]) + query_params = parse_qs(parsed_url.query) + token = query_params.get("token", [None])[0] + + if token: + params = [(endpoint["param"], str(limit)), ("token", token)] + resp = await app_client.get(endpoint["path"], params=params) + else: + continue # Skip if no token found else: # POST - body = {endpoint["body_key"]: limit, "token": token} - resp = await app_client.post(endpoint["path"], json=body) + # For POST, token is in the body + body = next_link.get("body", {}) + if "token" in body: + resp = await app_client.post(endpoint["path"], json=body) + else: + continue # Skip if no token found assert ( resp.status_code == 200 @@ -805,10 +971,7 @@ async def test_collections_pagination_all_endpoints(app_client, txn_client, ctx) resp_json = resp.json() # Filter to our test collections - if endpoint["path"] == "/collections": - found_collections = resp_json - else: # For collection-search endpoints - found_collections = resp_json["collections"] + found_collections = resp_json["collections"] test_found = [ c for c in found_collections if c["id"].startswith(test_prefix)