dz1-spatial-query/stac-fastapi-pgstac/tests/resources/test_item.py

1637 lines
53 KiB
Python
Raw Normal View History

2025-07-03 20:29:02 +08:00
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"