import json import random import uuid from datetime import timedelta from http.client import HTTP_PORT from string import ascii_letters from typing import Callable from urllib.parse import parse_qs, urljoin, urlparse import pystac import pytest from httpx import AsyncClient from pystac.utils import datetime_to_str from shapely.geometry import Polygon from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime from stac_pydantic import Collection, Item from starlette.requests import Request from stac_fastapi.pgstac.models.links import CollectionLinks async def test_create_collection(app_client, load_test_data: Callable): in_json = load_test_data("test_collection.json") in_coll = Collection.model_validate(in_json) resp = await app_client.post( "/collections", json=in_json, ) assert resp.status_code == 201 post_coll = Collection.model_validate(resp.json()) assert in_coll.model_dump(exclude={"links"}) == post_coll.model_dump( exclude={"links"} ) resp = await app_client.get(f"/collections/{post_coll.id}") assert resp.status_code == 200 get_coll = Collection.model_validate(resp.json()) assert post_coll.model_dump(exclude={"links"}) == get_coll.model_dump( exclude={"links"} ) async def test_update_collection(app_client, load_test_data, load_test_collection): in_coll = load_test_collection in_coll = load_test_data("test_collection.json") in_coll["keywords"].append("newkeyword") resp = await app_client.put(f"/collections/{in_coll['id']}", json=in_coll) assert resp.status_code == 200 resp = await app_client.get(f"/collections/{in_coll['id']}") assert resp.status_code == 200 get_coll = Collection.model_validate(resp.json()) in_coll = Collection(**in_coll) assert in_coll.model_dump(exclude={"links"}) == get_coll.model_dump(exclude={"links"}) assert "newkeyword" in get_coll.keywords async def test_delete_collection( app_client, load_test_data: Callable, load_test_collection ): in_coll = load_test_collection resp = await app_client.delete(f"/collections/{in_coll['id']}") assert resp.status_code == 200 resp = await app_client.get(f"/collections/{in_coll['id']}") assert resp.status_code == 404 async def test_create_item(app_client, load_test_data: Callable, load_test_collection): coll = load_test_collection in_json = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{coll['id']}/items", json=in_json, ) assert resp.status_code == 201 in_item = Item.model_validate(in_json) post_item = Item.model_validate(resp.json()) assert in_item.model_dump(exclude={"links"}) == post_item.model_dump( exclude={"links"} ) resp = await app_client.get(f"/collections/{coll['id']}/items/{post_item.id}") assert resp.status_code == 200 get_item = Item.model_validate(resp.json()) assert in_item.model_dump(exclude={"links"}) == get_item.model_dump(exclude={"links"}) get_item = get_item.model_dump(mode="json") post_item = post_item.model_dump(mode="json") post_self_link = next( (link for link in post_item["links"] if link["rel"] == "self"), None ) get_self_link = next( (link for link in get_item["links"] if link["rel"] == "self"), None ) assert post_self_link is not None and get_self_link is not None assert post_self_link["href"] == get_self_link["href"] async def test_create_item_mismatched_collection_id( app_client, load_test_data: Callable, load_test_collection ): # If the collection_id path parameter and the Item's "collection" property do not match, a 400 response should # be returned. coll = load_test_collection in_json = load_test_data("test_item.json") in_json["collection"] = random.choice(ascii_letters) assert in_json["collection"] != coll["id"] resp = await app_client.post( f"/collections/{coll['id']}/items", json=in_json, ) assert resp.status_code == 400 async def test_fetches_valid_item( app_client, load_test_data: Callable, load_test_collection ): coll = load_test_collection in_json = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{coll['id']}/items", json=in_json, ) assert resp.status_code == 201 in_item = Item.model_validate(in_json) post_item = Item.model_validate(resp.json()) assert in_item.model_dump(exclude={"links"}) == post_item.model_dump( exclude={"links"} ) resp = await app_client.get(f"/collections/{coll['id']}/items/{post_item.id}") assert resp.status_code == 200 item_dict = resp.json() # Mock root to allow validation mock_root = pystac.Catalog( id="test", description="test desc", href="https://example.com" ) item = pystac.Item.from_dict( item_dict, preserve_dict=False, root=mock_root, migrate=False ) item.validate() async def test_update_item( app_client, load_test_data: Callable, load_test_collection, load_test_item ): coll = load_test_collection item = load_test_item item["properties"]["description"] = "Update Test" resp = await app_client.put( f"/collections/{coll['id']}/items/{item['id']}", json=item ) assert resp.status_code == 200 put_item = Item.model_validate(resp.json()) resp = await app_client.get(f"/collections/{coll['id']}/items/{item['id']}") assert resp.status_code == 200 get_item = Item.model_validate(resp.json()) item = Item(**item) assert item.model_dump(exclude={"links"}) == get_item.model_dump(exclude={"links"}) assert get_item.properties.description == "Update Test" put_item = put_item.model_dump(mode="json") get_item = get_item.model_dump(mode="json") post_self_link = next( (link for link in put_item["links"] if link["rel"] == "self"), None ) get_self_link = next( (link for link in get_item["links"] if link["rel"] == "self"), None ) assert post_self_link is not None and get_self_link is not None assert post_self_link["href"] == get_self_link["href"] async def test_update_item_mismatched_collection_id( app_client, load_test_data: Callable, load_test_collection, load_test_item ) -> None: coll = load_test_collection in_json = load_test_data("test_item.json") in_json["collection"] = random.choice(ascii_letters) assert in_json["collection"] != coll["id"] item_id = in_json["id"] resp = await app_client.put( f"/collections/{coll['id']}/items/{item_id}", json=in_json, ) assert resp.status_code == 400 async def test_delete_item( app_client, load_test_data: Callable, load_test_collection, load_test_item ): coll = load_test_collection item = load_test_item resp = await app_client.delete(f"/collections/{coll['id']}/items/{item['id']}") assert resp.status_code == 200 resp = await app_client.get(f"/collections/{coll['id']}/items/{item['id']}") assert resp.status_code == 404 async def test_get_collection_items( app_client, load_test_collection, load_test_item, load_test_data ): coll = load_test_collection item = load_test_data("test_item.json") for _ in range(4): item["id"] = str(uuid.uuid4()) resp = await app_client.post( f"/collections/{coll['id']}/items", json=item, ) assert resp.status_code == 201 resp = await app_client.get( f"/collections/{coll['id']}/items", ) assert resp.status_code == 200 fc = resp.json() assert "features" in fc assert len(fc["features"]) == 5 async def test_create_item_conflict( app_client, load_test_data: Callable, load_test_collection ): coll = load_test_collection in_json = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{coll['id']}/items", json=in_json, ) assert resp.status_code == 201 resp = await app_client.post( f"/collections/{coll['id']}/items", json=in_json, ) assert resp.status_code == 409 async def test_delete_missing_item( app_client, load_test_data: Callable, load_test_collection, load_test_item ): coll = load_test_collection item = load_test_item resp = await app_client.delete(f"/collections/{coll['id']}/items/{item['id']}") assert resp.status_code == 200 resp = await app_client.delete(f"/collections/{coll['id']}/items/{item['id']}") assert resp.status_code == 404 async def test_create_item_missing_collection( app_client, load_test_data: Callable, load_test_collection ): coll = load_test_collection item = load_test_data("test_item.json") item["collection"] = None resp = await app_client.post(f"/collections/{coll['id']}/items", json=item) assert resp.status_code == 201 post_item = resp.json() assert post_item["collection"] == coll["id"] async def test_update_new_item( app_client, load_test_data: Callable, load_test_collection ): coll = load_test_collection new_item = load_test_data("test_item.json") new_item["id"] = "test-updatenewitem" resp = await app_client.put( f"/collections/{coll['id']}/items/{new_item['id']}", json=new_item ) assert resp.status_code == 404 async def test_update_item_missing_collection( app_client, load_test_data: Callable, load_test_collection, load_test_item ): coll = load_test_collection item = load_test_item item["collection"] = None resp = await app_client.put( f"/collections/{coll['id']}/items/{item['id']}", json=item ) assert resp.status_code == 200 put_item = resp.json() assert put_item["collection"] == coll["id"] async def test_pagination(app_client, load_test_data, load_test_collection): """Test item collection pagination (paging extension)""" coll = load_test_collection item_count = 21 for idx in range(1, item_count): item = load_test_data("test_item.json") item["id"] = item["id"] + str(idx) item["properties"]["datetime"] = f"2020-01-{idx:02d}T00:00:00Z" resp = await app_client.post(f"/collections/{coll['id']}/items", json=item) assert resp.status_code == 201 resp = await app_client.get(f"/collections/{coll['id']}/items", params={"limit": 3}) assert resp.status_code == 200 first_page = resp.json() assert len(first_page["features"]) == 3 nextlink = [ link["href"] for link in first_page["links"] if link["rel"] == "next" ].pop() assert nextlink is not None assert [f["id"] for f in first_page["features"]] == [ "test-item20", "test-item19", "test-item18", ] resp = await app_client.get(nextlink) assert resp.status_code == 200 second_page = resp.json() assert len(first_page["features"]) == 3 nextlink = [ link["href"] for link in second_page["links"] if link["rel"] == "next" ].pop() assert nextlink is not None prevlink = [ link["href"] for link in second_page["links"] if link["rel"] == "previous" ].pop() assert prevlink is not None assert [f["id"] for f in second_page["features"]] == [ "test-item17", "test-item16", "test-item15", ] resp = await app_client.get(prevlink) assert resp.status_code == 200 back_page = resp.json() assert len(back_page["features"]) == 3 assert [f["id"] for f in back_page["features"]] == [ "test-item20", "test-item19", "test-item18", ] async def test_item_search_by_id_post(app_client, load_test_data, load_test_collection): """Test POST search by item id (core)""" ids = ["test1", "test2", "test3"] for id in ids: test_item = load_test_data("test_item.json") test_item["id"] = id resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 params = {"collections": [test_item["collection"]], "ids": ids} resp = await app_client.post("/search", json=params) assert resp.status_code == 200 resp_json = resp.json() assert len(resp_json["features"]) == len(ids) assert {feat["id"] for feat in resp_json["features"]} == set(ids) async def test_item_search_by_id_no_results_post( app_client, load_test_data, load_test_collection ): """Test POST search by item id (core) when there are no results""" test_item = load_test_data("test_item.json") search_ids = ["nonexistent_id"] params = {"collections": [test_item["collection"]], "ids": search_ids} resp = await app_client.post("/search", json=params) assert resp.status_code == 200 resp_json = resp.json() assert len(resp_json["features"]) == 0 async def test_item_search_spatial_query_post( app_client, load_test_data, load_test_collection ): """Test POST search with spatial query (core)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 # Add second item with a different datetime. second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 params = { "collections": [test_item["collection"]], "intersects": test_item["geometry"], } resp = await app_client.post("/search", json=params) assert resp.status_code == 200 resp_json = resp.json() assert len(resp_json["features"]) == 1 assert resp_json["features"][0]["id"] == test_item["id"] async def test_item_search_temporal_query_post( app_client, load_test_data, load_test_collection ): """Test POST search with single-tailed spatio-temporal query (core)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 # Add second item with a different datetime. second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"]) params = { "collections": [test_item["collection"]], "intersects": test_item["geometry"], "datetime": datetime_to_str(item_date), } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert len(resp_json["features"]) == 1 assert resp_json["features"][0]["id"] == test_item["id"] async def test_item_search_temporal_window_post( app_client, load_test_data, load_test_collection ): """Test POST search with two-tailed spatio-temporal query (core)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 # Add second item with a different datetime. second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"]) item_date_before = item_date - timedelta(seconds=1) item_date_after = item_date + timedelta(seconds=1) params = { "collections": [test_item["collection"]], "datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}", } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert len(resp_json["features"]) == 1 assert resp_json["features"][0]["id"] == test_item["id"] async def test_item_search_temporal_open_window( app_client, load_test_data, load_test_collection ): for dt in ["/", "../..", "../", "/.."]: resp = await app_client.post("/search", json={"datetime": dt}) assert resp.status_code == 400 async def test_item_search_sort_post(app_client, load_test_data, load_test_collection): """Test POST search with sorting (sort extension)""" first_item = load_test_data("test_item.json") item_date = rfc3339_str_to_datetime(first_item["properties"]["datetime"]) resp = await app_client.post( f"/collections/{first_item['collection']}/items", json=first_item ) assert resp.status_code == 201 second_item = load_test_data("test_item.json") second_item["id"] = "another-item" another_item_date = item_date - timedelta(days=1) second_item["properties"]["datetime"] = datetime_to_str(another_item_date) resp = await app_client.post( f"/collections/{second_item['collection']}/items", json=second_item ) assert resp.status_code == 201 params = { "collections": [first_item["collection"]], "sortby": [{"field": "datetime", "direction": "desc"}], } resp = await app_client.post("/search", json=params) assert resp.status_code == 200 resp_json = resp.json() assert resp_json["features"][0]["id"] == first_item["id"] assert resp_json["features"][1]["id"] == second_item["id"] async def test_item_search_by_id_get(app_client, load_test_data, load_test_collection): """Test GET search by item id (core)""" ids = ["test1", "test2", "test3"] for id in ids: test_item = load_test_data("test_item.json") test_item["id"] = id resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 params = {"collections": test_item["collection"], "ids": ",".join(ids)} resp = await app_client.get("/search", params=params) assert resp.status_code == 200 resp_json = resp.json() assert len(resp_json["features"]) == len(ids) assert {feat["id"] for feat in resp_json["features"]} == set(ids) async def test_item_search_bbox_get(app_client, load_test_data, load_test_collection): """Test GET search with spatial query (core)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 # Add second item with a different datetime. second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 params = { "collections": test_item["collection"], "bbox": ",".join([str(coord) for coord in test_item["bbox"]]), } resp = await app_client.get("/search", params=params) assert resp.status_code == 200 resp_json = resp.json() assert len(resp_json["features"]) == 1 assert resp_json["features"][0]["id"] == test_item["id"] async def test_item_search_get_without_collections( app_client, load_test_data, load_test_collection ): """Test GET search without specifying collections""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 # Add second item with a different datetime. second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 params = { "bbox": ",".join([str(coord) for coord in test_item["bbox"]]), } resp = await app_client.get("/search", params=params) assert resp.status_code == 200 resp_json = resp.json() assert len(resp_json["features"]) == 1 assert resp_json["features"][0]["id"] == test_item["id"] async def test_item_search_temporal_window_get( app_client, load_test_data, load_test_collection ): """Test GET search with spatio-temporal query (core)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 # Add second item with a different datetime. second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"]) item_date_before = item_date - timedelta(seconds=1) item_date_after = item_date + timedelta(seconds=1) params = { "collections": test_item["collection"], "datetime": f"{datetime_to_str(item_date_before)}/{datetime_to_str(item_date_after)}", } resp = await app_client.get("/search", params=params) resp_json = resp.json() assert len(resp_json["features"]) == 1 assert resp_json["features"][0]["id"] == test_item["id"] async def test_item_search_sort_get(app_client, load_test_data, load_test_collection): """Test GET search with sorting (sort extension)""" first_item = load_test_data("test_item.json") item_date = rfc3339_str_to_datetime(first_item["properties"]["datetime"]) resp = await app_client.post( f"/collections/{first_item['collection']}/items", json=first_item ) assert resp.status_code == 201 second_item = load_test_data("test_item.json") second_item["id"] = "another-item" another_item_date = item_date - timedelta(days=1) second_item["properties"]["datetime"] = datetime_to_str(another_item_date) resp = await app_client.post( f"/collections/{second_item['collection']}/items", json=second_item ) assert resp.status_code == 201 params = {"collections": [first_item["collection"]], "sortby": "-datetime"} resp = await app_client.get("/search", params=params) assert resp.status_code == 200 resp_json = resp.json() assert resp_json["features"][0]["id"] == first_item["id"] assert resp_json["features"][1]["id"] == second_item["id"] async def test_item_search_post_without_collection( app_client, load_test_data, load_test_collection ): """Test POST search without specifying a collection""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 params = { "bbox": test_item["bbox"], } resp = await app_client.post("/search", json=params) assert resp.status_code == 200 resp_json = resp.json() assert resp_json["features"][0]["id"] == test_item["id"] async def test_item_search_properties_jsonb( app_client, load_test_data, load_test_collection ): """Test POST search with JSONB query (query extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 # EPSG is a JSONB key params = {"query": {"proj:epsg": {"gt": test_item["properties"]["proj:epsg"] - 1}}} resp = await app_client.post("/search", json=params) assert resp.status_code == 200 resp_json = resp.json() assert len(resp_json["features"]) == 1 async def test_item_search_properties_field( app_client, load_test_data, load_test_collection ): """Test POST search indexed field with query (query extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 second_test_item = load_test_data("test_item2.json") second_test_item["properties"]["eo:cloud_cover"] = 5 resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 params = {"query": {"eo:cloud_cover": {"eq": 0}}} resp = await app_client.post("/search", json=params) assert resp.status_code == 200 resp_json = resp.json() assert len(resp_json["features"]) == 1 async def test_item_search_get_query_extension( app_client, load_test_data, load_test_collection ): """Test GET search with JSONB query (query extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 # EPSG is a JSONB key params = { "collections": [test_item["collection"]], "query": json.dumps( {"proj:epsg": {"gt": test_item["properties"]["proj:epsg"] + 1}} ), } resp = await app_client.get("/search", params=params) # No items found should still return a 200 but with an empty list of features assert resp.status_code == 200 assert len(resp.json()["features"]) == 0 params["query"] = json.dumps( {"proj:epsg": {"eq": test_item["properties"]["proj:epsg"]}} ) resp = await app_client.get("/search", params=params) resp_json = resp.json() assert len(resp.json()["features"]) == 1 assert ( resp_json["features"][0]["properties"]["proj:epsg"] == test_item["properties"]["proj:epsg"] ) async def test_item_search_post_filter_extension_cql( app_client, load_test_data, load_test_collection ): """Test POST search with JSONB query (cql json filter extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 # EPSG is a JSONB key params = { "collections": [test_item["collection"]], "filter": { "gt": [ {"property": "proj:epsg"}, test_item["properties"]["proj:epsg"] + 1, ] }, } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert resp.status_code == 200 assert len(resp_json.get("features")) == 0 params = { "collections": [test_item["collection"]], "filter": { "eq": [ {"property": "proj:epsg"}, test_item["properties"]["proj:epsg"], ] }, } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert len(resp.json()["features"]) == 1 assert ( resp_json["features"][0]["properties"]["proj:epsg"] == test_item["properties"]["proj:epsg"] ) async def test_item_search_post_filter_extension_cql2( app_client, load_test_data, load_test_collection ): """Test POST search with JSONB query (cql2 json filter extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 # make sure we have 2 items resp = await app_client.post("/search", json={}) resp_json = resp.json() assert resp.status_code == 200 assert len(resp_json.get("features")) == 2 # EPSG is a JSONB key params = { "collections": [test_item["collection"]], "filter-lang": "cql2-json", "filter": { "op": "gt", "args": [ {"property": "proj:epsg"}, test_item["properties"]["proj:epsg"] + 1, ], }, } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert resp.status_code == 200 assert len(resp_json.get("features")) == 0 params = { "collections": [test_item["collection"]], "filter-lang": "cql2-json", "filter": { "op": "eq", "args": [ {"property": "proj:epsg"}, test_item["properties"]["proj:epsg"], ], }, } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert len(resp.json()["features"]) == 1 assert ( resp_json["features"][0]["properties"]["proj:epsg"] == test_item["properties"]["proj:epsg"] ) # Test IN operator params = { "collections": [test_item["collection"]], "filter-lang": "cql2-json", "filter": { "op": "in", "args": [ {"property": "proj:epsg"}, [test_item["properties"]["proj:epsg"]], ], }, } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert resp.status_code == 200 assert len(resp_json.get("features")) == 1 params = { "collections": [test_item["collection"]], "filter-lang": "cql2-json", "filter": { "op": "in", "args": [ {"property": "proj:epsg"}, [test_item["properties"]["proj:epsg"] + 1], ], }, } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert resp.status_code == 200 assert len(resp_json.get("features")) == 0 async def test_item_search_post_filter_extension_cql2_with_query_fails( app_client, load_test_data, load_test_collection ): """Test POST search with JSONB query (cql2 json filter extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 second_test_item = load_test_data("test_item2.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=second_test_item ) assert resp.status_code == 201 # Cannot use `query` and `filter` params = { "collections": [test_item["collection"]], "filter-lang": "cql2-json", "filter": { "op": "gt", "args": [ {"property": "proj:epsg"}, test_item["properties"]["proj:epsg"] + 1, ], }, "query": {"eo:cloud_cover": {"eq": 0}}, } resp = await app_client.post("/search", json=params) assert resp.status_code == 400 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 async def test_get_item_from_missing_item_collection(app_client): """Test reading an item from a collection which does not exist""" resp = await app_client.get("/collections/invalid-collection/items/some-item") assert resp.status_code == 404 async def test_pagination_item_collection( app_client, load_test_data, load_test_collection ): """Test item collection pagination links (paging extension)""" test_item = load_test_data("test_item.json") ids = [] # Ingest 5 items for _ in range(5): uid = str(uuid.uuid4()) test_item["id"] = uid resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 ids.append(uid) # Paginate through all 5 items with a limit of 1 (expecting 5 requests) page = await app_client.get( f"/collections/{test_item['collection']}/items", params={"limit": 1} ) idx = 0 item_ids = [] while True: idx += 1 page_data = page.json() item_ids.append(page_data["features"][0]["id"]) nextlink = [link["href"] for link in page_data["links"] if link["rel"] == "next"] if len(nextlink) < 1: break page = await app_client.get(nextlink.pop()) assert idx < 10 # Our limit is 1 so we expect len(ids) number of requests before we run out of pages assert idx == len(ids) # Confirm we have paginated through all items assert not set(item_ids) - set(ids) async def test_pagination_post(app_client, load_test_data, load_test_collection): """Test POST pagination (paging extension)""" test_item = load_test_data("test_item.json") ids = [] # Ingest 5 items for _ in range(5): uid = str(uuid.uuid4()) test_item["id"] = uid resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 ids.append(uid) # Paginate through all 5 items with a limit of 1 (expecting 5 requests) request_body = { "filter-lang": "cql2-json", "filter": {"op": "in", "args": [{"property": "id"}, ids]}, "limit": 1, } page = await app_client.post("/search", json=request_body) idx = 0 item_ids = [] while True: idx += 1 page_data = page.json() item_ids.append(page_data["features"][0]["id"]) next_link = list(filter(lambda link: link["rel"] == "next", page_data["links"])) if not next_link: break # Merge request bodies request_body.update(next_link[0]["body"]) page = await app_client.post("/search", json=request_body) assert idx < 10 # Our limit is 1 so we expect len(ids) number of requests before we run out of pages assert idx == len(ids) # Confirm we have paginated through all items assert not set(item_ids) - set(ids) async def test_pagination_token_idempotent( app_client, load_test_data, load_test_collection ): """Test that pagination tokens are idempotent (paging extension)""" test_item = load_test_data("test_item.json") ids = [] # Ingest 5 items for _ in range(5): uid = str(uuid.uuid4()) test_item["id"] = uid resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 ids.append(uid) page = await app_client.post( "/search", json={ "filter-lang": "cql2-json", "filter": {"op": "in", "args": [{"property": "id"}, ids]}, "limit": 3, }, ) page_data = page.json() next_link = list(filter(lambda link: link["rel"] == "next", page_data["links"])) # Confirm token is idempotent resp1 = await app_client.get( "/search", params=parse_qs(urlparse(next_link[0]["href"]).query) ) resp2 = await app_client.get( "/search", params=parse_qs(urlparse(next_link[0]["href"]).query) ) resp1_data = resp1.json() resp2_data = resp2.json() # Two different requests with the same pagination token should return the same items assert [item["id"] for item in resp1_data["features"]] == [ item["id"] for item in resp2_data["features"] ] async def test_field_extension_get(app_client, load_test_data, load_test_collection): """Test GET search with included fields (fields extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 params = {"fields": "+properties.proj:epsg,+properties.gsd,+collection"} resp = await app_client.get( f"/collections/{test_item['collection']}/items", params=params ) feat_properties = resp.json()["features"][0]["properties"] assert not set(feat_properties) - {"proj:epsg", "gsd", "datetime"} params = {"fields": "+properties.proj:epsg,+properties.gsd,+collection"} resp = await app_client.get("/search", params=params) feat_properties = resp.json()["features"][0]["properties"] assert not set(feat_properties) - {"proj:epsg", "gsd", "datetime"} async def test_field_extension_post(app_client, load_test_data, load_test_collection): """Test POST search with included and excluded fields (fields extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 body = { "fields": { "exclude": ["assets.B1"], "include": [ "properties.eo:cloud_cover", "properties.orientation", "assets", "collection", ], } } resp = await app_client.post("/search", json=body) resp_json = resp.json() assert "B1" not in resp_json["features"][0]["assets"].keys() assert not set(resp_json["features"][0]["properties"]) - { "orientation", "eo:cloud_cover", "datetime", } async def test_field_extension_exclude_and_include( app_client, load_test_data, load_test_collection ): """Test POST search including/excluding same field (fields extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 body = { "fields": { "exclude": ["properties.eo:cloud_cover"], "include": ["properties.eo:cloud_cover", "collection"], } } resp = await app_client.post("/search", json=body) resp_json = resp.json() assert "properties" not in resp_json["features"][0] async def test_field_extension_exclude_default_includes( app_client, load_test_data, load_test_collection ): """Test POST search excluding a forbidden field (fields extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 body = {"fields": {"exclude": ["geometry"]}} resp = await app_client.post("/search", json=body) resp_json = resp.json() assert "geometry" not in resp_json["features"][0] async def test_field_extension_include_multiple_subkeys( app_client, load_test_item, load_test_collection ): """Test that multiple subkeys of an object field are included""" body = {"fields": {"include": ["properties.width", "properties.height"]}} resp = await app_client.post("/search", json=body) assert resp.status_code == 200 resp_json = resp.json() resp_prop_keys = resp_json["features"][0]["properties"].keys() assert set(resp_prop_keys) == {"width", "height"} async def test_field_extension_include_multiple_deeply_nested_subkeys( app_client, load_test_item, load_test_collection ): """Test that multiple deeply nested subkeys of an object field are included""" body = {"fields": {"include": ["assets.ANG.type", "assets.ANG.href"]}} resp = await app_client.post("/search", json=body) assert resp.status_code == 200 resp_json = resp.json() resp_assets = resp_json["features"][0]["assets"] assert set(resp_assets.keys()) == {"ANG"} assert set(resp_assets["ANG"].keys()) == {"type", "href"} async def test_field_extension_exclude_multiple_deeply_nested_subkeys( app_client, load_test_item, load_test_collection ): """Test that multiple deeply nested subkeys of an object field are excluded""" body = {"fields": {"exclude": ["assets.ANG.type", "assets.ANG.href"]}} resp = await app_client.post("/search", json=body) assert resp.status_code == 200 resp_json = resp.json() resp_assets = resp_json["features"][0]["assets"] assert len(resp_assets.keys()) > 0 assert "type" not in resp_assets["ANG"] assert "href" not in resp_assets["ANG"] async def test_field_extension_exclude_deeply_nested_included_subkeys( app_client, load_test_item, load_test_collection ): """Test that deeply nested keys of a nested object that was included are excluded""" body = { "fields": { "include": ["assets.ANG.type", "assets.ANG.href"], "exclude": ["assets.ANG.href"], } } resp = await app_client.post("/search", json=body) assert resp.status_code == 200 resp_json = resp.json() resp_assets = resp_json["features"][0]["assets"] assert "type" in resp_assets["ANG"] assert "href" not in resp_assets["ANG"] async def test_field_extension_exclude_links( app_client, load_test_item, load_test_collection ): """Links have special injection behavior, ensure they can be excluded with the fields extension""" body = {"fields": {"exclude": ["links"]}} resp = await app_client.post("/search", json=body) assert resp.status_code == 200 resp_json = resp.json() assert "links" not in resp_json["features"][0] async def test_field_extension_include_only_non_existant_field( app_client, load_test_item, load_test_collection ): """Including only a non-existant field should return the full item""" body = {"fields": {"include": ["non_existant_field"]}} resp = await app_client.post("/search", json=body) assert resp.status_code == 200 resp_json = resp.json() assert list(resp_json["features"][0].keys()) == ["id", "collection", "links"] async def test_search_intersects_and_bbox(app_client): """Test POST search intersects and bbox are mutually exclusive (core)""" bbox = [-118, 34, -117, 35] geoj = Polygon.from_bounds(*bbox).__geo_interface__ params = {"bbox": bbox, "intersects": geoj} resp = await app_client.post("/search", json=params) assert resp.status_code == 400 async def test_get_missing_item(app_client, load_test_data): """Test read item which does not exist (transactions extension)""" test_coll = load_test_data("test_collection.json") resp = await app_client.get(f"/collections/{test_coll['id']}/items/invalid-item") assert resp.status_code == 404 async def test_relative_link_construction(app): req = Request( scope={ "type": "http", "scheme": "http", "method": "PUT", "root_path": "/stac", # root_path should not have proto, domain, or port "path": "/", "raw_path": b"/tab/abc", "query_string": b"", "headers": {}, "app": app, "server": ("test", HTTP_PORT), } ) links = CollectionLinks(collection_id="naip", request=req) assert links.link_items()["href"] == ( "http://test/stac{}/collections/naip/items".format(app.state.router_prefix) ) async def test_search_bbox_errors(app_client): body = {"query": {"bbox": [0]}} resp = await app_client.post("/search", json=body) assert resp.status_code == 400 body = {"query": {"bbox": [100.0, 0.0, 0.0, 105.0, 1.0, 1.0]}} resp = await app_client.post("/search", json=body) assert resp.status_code == 400 params = {"bbox": "100.0,0.0,0.0,105.0"} resp = await app_client.get("/search", params=params) assert resp.status_code == 400 async def test_preserves_extra_link( app_client: AsyncClient, load_test_data, load_test_collection ): coll = load_test_collection test_item = load_test_data("test_item.json") expected_href = urljoin(str(app_client.base_url), "preview.html") resp = await app_client.post(f"/collections/{coll['id']}/items", json=test_item) assert resp.status_code == 201 response_item = await app_client.get( f"/collections/{coll['id']}/items/{test_item['id']}", params={"limit": 1}, ) assert response_item.status_code == 200 item = response_item.json() extra_link = [link for link in item["links"] if link["rel"] == "preview"] assert extra_link assert extra_link[0]["href"] == expected_href async def test_item_search_post_filter_extension_cql2_2( app_client, load_test_data, load_test_collection ): """Test POST search with JSONB query (cql json filter extension)""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 # EPSG is a JSONB key params = { "filter-lang": "cql2-json", "filter": { "op": "and", "args": [ { "op": "eq", "args": [ {"property": "proj:epsg"}, test_item["properties"]["proj:epsg"] + 1, ], }, { "op": "in", "args": [ {"property": "collection"}, [test_item["collection"]], ], }, ], }, } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert resp.status_code == 200 assert len(resp_json.get("features")) == 0 params = { "filter-lang": "cql2-json", "filter": { "op": "and", "args": [ { "op": "eq", "args": [ {"property": "proj:epsg"}, test_item["properties"]["proj:epsg"], ], }, { "op": "in", "args": [ {"property": "collection"}, [test_item["collection"]], ], }, ], }, } resp = await app_client.post("/search", json=params) resp_json = resp.json() assert len(resp.json()["features"]) == 1 assert ( resp_json["features"][0]["properties"]["proj:epsg"] == test_item["properties"]["proj:epsg"] ) async def test_search_datetime_validation_errors(app_client): bad_datetimes = [ "37-01-01T12:00:27.87Z", "1985-13-12T23:20:50.52Z", "1985-12-32T23:20:50.52Z", "1985-12-01T25:20:50.52Z", "1985-12-01T00:60:50.52Z", "1985-12-01T00:06:61.52Z", "1990-12-31T23:59:61Z", "1986-04-12T23:20:50.52Z/1985-04-12T23:20:50.52Z", ] for dt in bad_datetimes: body = {"query": {"datetime": dt}} resp = await app_client.post("/search", json=body) assert resp.status_code == 400 resp = await app_client.get("/search?datetime={}".format(dt)) assert resp.status_code == 400 async def test_get_filter_cql2text(app_client, load_test_data, load_test_collection): """Test GET search with cql2-text""" test_item = load_test_data("test_item.json") resp = await app_client.post( f"/collections/{test_item['collection']}/items", json=test_item ) assert resp.status_code == 201 epsg = test_item["properties"]["proj:epsg"] collection = test_item["collection"] filter = f"proj:epsg={epsg} AND collection = '{collection}'" params = {"filter": filter, "filter-lang": "cql2-text"} resp = await app_client.get("/search", params=params) resp_json = resp.json() assert len(resp.json()["features"]) == 1 assert ( resp_json["features"][0]["properties"]["proj:epsg"] == test_item["properties"]["proj:epsg"] ) filter = f"proj:epsg={epsg + 1} AND collection = '{collection}'" params = {"filter": filter, "filter-lang": "cql2-text"} resp = await app_client.get("/search", params=params) resp_json = resp.json() assert len(resp.json()["features"]) == 0 filter = f"proj:epsg={epsg}" params = {"filter": filter, "filter-lang": "cql2-text"} resp = await app_client.get( f"/collections/{test_item['collection']}/items", params=params ) resp_json = resp.json() assert len(resp.json()["features"]) == 1 async def test_item_merge_raster_bands( app_client, load_test2_item, load_test2_collection ): resp = await app_client.get("/collections/test2-collection/items/test2-item") resp_json = resp.json() red_bands = resp_json["assets"]["red"]["raster:bands"] # The merged item should have merged the band dicts from base and item # into a single dict assert len(red_bands) == 1 # The merged item should have the full 6 bands assert len(red_bands[0].keys()) == 6 # The merged item should have kept the item value rather than the base value assert red_bands[0]["offset"] == 2.03976 @pytest.mark.asyncio async def test_get_collection_items_forwarded_header( app_client, load_test_collection, load_test_item ): coll = load_test_collection resp = await app_client.get( f"/collections/{coll['id']}/items", headers={"Forwarded": "proto=https;host=test:1234"}, ) for link in resp.json()["features"][0]["links"]: assert link["href"].startswith("https://test:1234/") @pytest.mark.asyncio async def test_get_collection_items_x_forwarded_headers( app_client, load_test_collection, load_test_item ): coll = load_test_collection resp = await app_client.get( f"/collections/{coll['id']}/items", headers={ "X-Forwarded-Port": "1234", "X-Forwarded-Proto": "https", }, ) for link in resp.json()["features"][0]["links"]: assert link["href"].startswith("https://test:1234/") @pytest.mark.asyncio async def test_get_collection_items_duplicate_forwarded_headers( app_client, load_test_collection, load_test_item ): coll = load_test_collection resp = await app_client.get( f"/collections/{coll['id']}/items", headers={ "Forwarded": "proto=https;host=test:1234", "X-Forwarded-Port": "4321", "X-Forwarded-Proto": "http", }, ) for link in resp.json()["features"][0]["links"]: assert link["href"].startswith("https://test:1234/") async def test_get_filter_extension(app_client, load_test_data, load_test_collection): """Test GET with Filter extension""" test_item = load_test_data("test_item.json") collection_id = test_item["collection"] ids = [] # Ingest 5 items for _ in range(5): uid = str(uuid.uuid4()) test_item["id"] = uid resp = await app_client.post( f"/collections/{collection_id}/items", json=test_item ) assert resp.status_code == 201 ids.append(uid) search_id = ids[2] # SEARCH # CQL2-JSON resp = await app_client.get( "/search", params={ "filter-lang": "cql2-json", "filter": json.dumps({"op": "in", "args": [{"property": "id"}, [search_id]]}), }, ) assert resp.status_code == 200 fc = resp.json() assert len(fc["features"]) == 1 assert fc["features"][0]["id"] == search_id # CQL2-TEXT resp = await app_client.get( "/search", params={ "filter-lang": "cql2-text", "filter": f"id='{search_id}'", }, ) assert resp.status_code == 200 fc = resp.json() assert len(fc["features"]) == 1 assert fc["features"][0]["id"] == search_id # ITEM COLLECTION # CQL2-JSON resp = await app_client.get( f"/collections/{collection_id}/items", params={ "filter-lang": "cql2-json", "filter": json.dumps({"op": "in", "args": [{"property": "id"}, [search_id]]}), }, ) assert resp.status_code == 200 fc = resp.json() assert len(fc["features"]) == 1 assert fc["features"][0]["id"] == search_id # CQL2-TEXT resp = await app_client.get( f"/collections/{collection_id}/items", params={ "filter-lang": "cql2-text", "filter": f"id='{search_id}'", }, ) assert resp.status_code == 200 fc = resp.json() assert len(fc["features"]) == 1 assert fc["features"][0]["id"] == search_id async def test_get_search_link_media(app_client): """Test Search request returned links""" # GET resp = await app_client.get("/search") assert resp.status_code == 200 links = resp.json()["links"] assert len(links) == 2 get_self_link = next((link for link in links if link["rel"] == "self"), None) assert get_self_link["type"] == "application/geo+json" # POST resp = await app_client.post("/search", json={}) assert resp.status_code == 200 links = resp.json()["links"] assert len(links) == 2 get_self_link = next((link for link in links if link["rel"] == "self"), None) assert get_self_link["type"] == "application/geo+json"