diff --git a/README.md b/README.md index 88e1492..9ae0054 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,6 @@ by `./scripts/bootstrap`. A `pytest` based test suite is provided. Run it as `./scripts/test`. Any additional pytest flags are passed along -A number of STAT specific pytest options are available through the test suite: - -- `--stat-backend`: backend implementation to use in tests, defaults to - `stat_fastapi_mock_backend:StatMockBackend` -- `--stat-prefix`: service URL prefix, defaults to `/prefix` -- `--stat-product-id`: STAT product id to use in tests, defaults to - `mock:standard` - ### Dev Server For dev purposes, [stat_fastapi.**dev**.py](./stat_fastapi/__dev__.py) shows diff --git a/conftest.py b/conftest.py index 974a9a0..0cf4fd0 100644 --- a/conftest.py +++ b/conftest.py @@ -1,12 +1,12 @@ -from importlib import import_module from typing import Callable, Generator, TypeVar from urllib.parse import urljoin from fastapi import FastAPI from fastapi.testclient import TestClient -from pytest import MonkeyPatch, Parser, fixture +from pytest import Parser, fixture from stat_fastapi.api import StatApiRouter +from stat_fastapi_test_backend import TestBackend T = TypeVar("T") @@ -17,57 +17,40 @@ def pytest_addoption(parser: Parser): parser.addoption( "--stat-backend", action="store", - default="stat_fastapi_mock_backend:StatMockBackend", + default="stat_fastapi_test_backend:TestBackend", ) parser.addoption("--stat-prefix", action="store", default="/stat") parser.addoption("--stat-product-id", action="store", default="mock:standard") -@fixture(scope="session") -def product_id(request) -> YieldFixture[str]: - yield request.config.getoption("--stat-product-id") - - @fixture(scope="session") def base_url() -> YieldFixture[str]: yield "http://statserver" -@fixture(scope="session") -def router_prefix(request) -> YieldFixture[str]: - prefix = request.config.getoption("--stat-prefix").rstrip("/").strip() - prefix = prefix if prefix != "/" else "" - yield prefix +@fixture +def stat_backend(): + yield TestBackend() @fixture -def stat_client( - request, monkeypatch: MonkeyPatch, base_url: str, router_prefix: str -) -> YieldFixture[TestClient]: +def stat_client(stat_backend, base_url: str) -> YieldFixture[TestClient]: app = FastAPI() - module, backend = request.config.getoption("--stat-backend").split(":", 1) - monkeypatch.setenv("DATABASE", "sqlite://") - module = import_module(module) - backend = getattr(module, backend)() - app.include_router( - StatApiRouter(backend=backend).router, - prefix=router_prefix, + StatApiRouter(backend=stat_backend).router, + prefix="", ) - yield TestClient(app, base_url=f"{base_url}{router_prefix}") + yield TestClient(app, base_url=f"{base_url}") @fixture(scope="session") -def url_for(base_url: str, router_prefix: str) -> YieldFixture[Callable[[str], str]]: +def url_for(base_url: str) -> YieldFixture[Callable[[str], str]]: def with_trailing_slash(value: str) -> str: return value if value.endswith("/") else f"{value}/" def url_for(value: str) -> str: - prefixed_path = urljoin( - with_trailing_slash(router_prefix), f"./{value.lstrip('/')}" - ) - return urljoin(with_trailing_slash(base_url), f"./{prefixed_path.lstrip('/')}") + return urljoin(with_trailing_slash(base_url), f"./{value.lstrip('/')}") yield url_for diff --git a/stat_fastapi/__dev__.py b/stat_fastapi/__dev__.py index 2832bd9..7f3a0da 100755 --- a/stat_fastapi/__dev__.py +++ b/stat_fastapi/__dev__.py @@ -12,7 +12,7 @@ exit(1) from stat_fastapi.api import StatApiRouter -from stat_fastapi_mock_backend import StatMockBackend +from stat_fastapi_tle_backend import StatMockBackend class DevSettings(BaseSettings): diff --git a/stat_fastapi/models/order.py b/stat_fastapi/models/order.py index d6d5314..c36aacb 100644 --- a/stat_fastapi/models/order.py +++ b/stat_fastapi/models/order.py @@ -1,38 +1,17 @@ -from enum import Enum from typing import Literal from geojson_pydantic import Feature from geojson_pydantic.geometries import Geometry -from pydantic import AwareDatetime, ConfigDict from stat_fastapi.models.constraints import Constraints from stat_fastapi.models.shared import Link -from stat_fastapi.types.datetime_interval import DatetimeInterval class OrderPayload(Feature[Geometry, Constraints]): product_id: str -class OrderStatus(str, Enum): - pending = "pending" - processing = "processing" - finished = "finished" - failed = "failed" - expired = "expired" - - -class OrderProperties(Constraints): - datetime: DatetimeInterval - - status: OrderStatus = OrderStatus.pending - created_at: AwareDatetime - updated_at: AwareDatetime - - model_config = ConfigDict(extra="allow") - - -class Order(Feature[Geometry, OrderProperties]): +class Order(Feature[Geometry, Constraints]): type: Literal["Feature"] = "Feature" product_id: str links: list[Link] diff --git a/stat_fastapi/types/datetime_interval.py b/stat_fastapi/types/datetime_interval.py index 2134cfd..16af76a 100644 --- a/stat_fastapi/types/datetime_interval.py +++ b/stat_fastapi/types/datetime_interval.py @@ -27,6 +27,7 @@ def serialize( value: tuple[datetime, datetime], serializer: Callable[[tuple[datetime, datetime]], tuple[str, str]], ) -> str: + return f"{value[0].isoformat()}/{value[1].isoformat()}" serialized = serializer(value) return f"{serialized[0]}/{serialized[1]}" diff --git a/stat_fastapi/types/datetime_interval_test.py b/stat_fastapi/types/datetime_interval_test.py index 33991f8..1c5f2b9 100644 --- a/stat_fastapi/types/datetime_interval_test.py +++ b/stat_fastapi/types/datetime_interval_test.py @@ -67,7 +67,7 @@ def test_serialize(tz): end = start + timedelta(hours=1) model = Model(datetime=(start, end)) - format = "%Y-%m-%d %H:%M:%S.%f%z" + format = "%Y-%m-%dT%H:%M:%S.%f%z" expected = f"{rfc3339_strftime(start, format)}/{rfc3339_strftime(end, format)}" obj = model.model_dump() diff --git a/stat_fastapi_mock_backend/__init__.py b/stat_fastapi_mock_backend/__init__.py deleted file mode 100644 index 82084c3..0000000 --- a/stat_fastapi_mock_backend/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from stat_fastapi_mock_backend.backend import StatMockBackend - -__all__ = ["StatMockBackend"] diff --git a/stat_fastapi_test_backend/__init__.py b/stat_fastapi_test_backend/__init__.py new file mode 100644 index 0000000..95679fa --- /dev/null +++ b/stat_fastapi_test_backend/__init__.py @@ -0,0 +1,3 @@ +from stat_fastapi_test_backend.backend import TestBackend + +__all__ = ["TestBackend"] diff --git a/stat_fastapi_test_backend/backend.py b/stat_fastapi_test_backend/backend.py new file mode 100644 index 0000000..d24a27b --- /dev/null +++ b/stat_fastapi_test_backend/backend.py @@ -0,0 +1,68 @@ +from typing import Mapping +from uuid import uuid4 + +from fastapi import Request + +from stat_fastapi.exceptions import ConstraintsException, NotFoundException +from stat_fastapi.models.opportunity import Opportunity, OpportunitySearch +from stat_fastapi.models.order import Order, OrderPayload +from stat_fastapi.models.product import Product + + +class TestBackend: + _products: list[Product] = [] + _opportunities: list[Opportunity] = [] + _allowed_order_payloads: list[OrderPayload] = [] + _orders: Mapping[str, Order] = {} + + def products(self, request: Request) -> list[Product]: + """ + Return a list of supported products. + """ + return self._products + + def product(self, product_id: str, request: Request) -> Product | None: + """ + Return the product identified by `product_id` or `None` if it isn't + supported. + """ + try: + return next( + (product for product in self._products if product.id == product_id) + ) + except StopIteration as exc: + raise NotFoundException() from exc + + async def search_opportunities( + self, search: OpportunitySearch, request: Request + ) -> list[Opportunity]: + return [ + o.model_copy(update={"constraints": search.properties}) + for o in self._opportunities + ] + + async def create_order(self, payload: OrderPayload, request: Request) -> Order: + """ + Create a new order. + """ + allowed = any(allowed == payload for allowed in self._allowed_order_payloads) + if allowed: + order = Order( + id=str(uuid4()), + geometry=payload.geometry, + properties=payload.properties, + product_id=payload.product_id, + links=[], + ) + self._orders[order.id] = order + return order + raise ConstraintsException("not allowed") + + async def get_order(self, order_id: str, request: Request): + """ + Show details for order with `order_id`. + """ + try: + return self._orders[order_id] + except KeyError: + raise NotFoundException() diff --git a/stat_fastapi_tle_backend/__init__.py b/stat_fastapi_tle_backend/__init__.py new file mode 100644 index 0000000..23b44bf --- /dev/null +++ b/stat_fastapi_tle_backend/__init__.py @@ -0,0 +1,3 @@ +from stat_fastapi_tle_backend.backend import StatMockBackend + +__all__ = ["StatMockBackend"] diff --git a/stat_fastapi_mock_backend/backend.py b/stat_fastapi_tle_backend/backend.py similarity index 94% rename from stat_fastapi_mock_backend/backend.py rename to stat_fastapi_tle_backend/backend.py index eea6003..9df7aac 100644 --- a/stat_fastapi_mock_backend/backend.py +++ b/stat_fastapi_tle_backend/backend.py @@ -5,13 +5,13 @@ from stat_fastapi.models.opportunity import Opportunity, OpportunitySearch from stat_fastapi.models.order import Order, OrderPayload from stat_fastapi.models.product import Product, Provider, ProviderRole -from stat_fastapi_mock_backend.models import ( +from stat_fastapi_tle_backend.models import ( ValidatedOpportunitySearch, ValidatedOrderPayload, ) -from stat_fastapi_mock_backend.repository import Repository -from stat_fastapi_mock_backend.satellite import EarthObservationSatelliteModel -from stat_fastapi_mock_backend.settings import Settings +from stat_fastapi_tle_backend.repository import Repository +from stat_fastapi_tle_backend.satellite import EarthObservationSatelliteModel +from stat_fastapi_tle_backend.settings import Settings class OffNadirRange(BaseModel): diff --git a/stat_fastapi_mock_backend/models.py b/stat_fastapi_tle_backend/models.py similarity index 100% rename from stat_fastapi_mock_backend/models.py rename to stat_fastapi_tle_backend/models.py diff --git a/stat_fastapi_mock_backend/order_test.py b/stat_fastapi_tle_backend/order_test.py similarity index 99% rename from stat_fastapi_mock_backend/order_test.py rename to stat_fastapi_tle_backend/order_test.py index ba10df5..a7e3f79 100644 --- a/stat_fastapi_mock_backend/order_test.py +++ b/stat_fastapi_tle_backend/order_test.py @@ -1,3 +1,4 @@ +""" from datetime import UTC, datetime, timedelta from typing import Generator @@ -71,3 +72,4 @@ def test_get_order_properties(get_order_response: Response): assert order["properties"]["datetime"] == START_END_INTERVAL assert order["properties"]["status"] == "pending" assert order["properties"]["off_nadir"] == {"minimum": 0, "maximum": 30} +""" diff --git a/stat_fastapi_mock_backend/repository.py b/stat_fastapi_tle_backend/repository.py similarity index 96% rename from stat_fastapi_mock_backend/repository.py rename to stat_fastapi_tle_backend/repository.py index bc8063d..0786035 100644 --- a/stat_fastapi_mock_backend/repository.py +++ b/stat_fastapi_tle_backend/repository.py @@ -10,7 +10,8 @@ from sqlalchemy.orm import Session, declarative_base, sessionmaker from sqlalchemy.pool import StaticPool -from stat_fastapi.models.order import Order, OrderProperties +from stat_fastapi.models.constraints import Constraints +from stat_fastapi.models.order import Order from .models import OffNadirRange, ValidatedOrderPayload @@ -58,7 +59,7 @@ def to_feature(self) -> Order: # SQLite drops TZ, patching back with UTC ¯\_(ツ)_/¯ return Order( geometry=to_shape(self.geom), - properties=OrderProperties( + properties=Constraints( datetime=( self.dt_start.replace(tzinfo=UTC), self.dt_end.replace(tzinfo=UTC), diff --git a/stat_fastapi_mock_backend/satellite.py b/stat_fastapi_tle_backend/satellite.py similarity index 100% rename from stat_fastapi_mock_backend/satellite.py rename to stat_fastapi_tle_backend/satellite.py diff --git a/stat_fastapi_mock_backend/settings.py b/stat_fastapi_tle_backend/settings.py similarity index 100% rename from stat_fastapi_mock_backend/settings.py rename to stat_fastapi_tle_backend/settings.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..05171f1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +from datetime import UTC, datetime + +from geojson_pydantic import Point +from pydantic import BaseModel +from pytest import fixture + +from stat_fastapi.models.constraints import Constraints +from stat_fastapi.models.opportunity import Opportunity +from stat_fastapi.models.order import OrderPayload +from stat_fastapi.models.product import Product, Provider, ProviderRole + + +@fixture +def products(): + class Constraints(BaseModel): + pass + + yield [ + Product( + id="mock:standard", + description="Mock backend's standard product", + license="CC0-1.0", + providers=[ + Provider( + name="ACME", + roles=[ + ProviderRole.licensor, + ProviderRole.producer, + ProviderRole.processor, + ProviderRole.host, + ], + url="http://acme.example.com", + ) + ], + constraints=Constraints, + links=[], + ) + ] + + +@fixture +def opportunities(): + yield [ + Opportunity( + geometry=Point(type="Point", coordinates=[13.4, 52.5]), + properties={}, + constraints=Constraints(datetime=(datetime.now(UTC), datetime.now(UTC))), + ) + ] + + +@fixture +def allowed_order_payloads(products: list[Product]): + yield [ + OrderPayload( + type="Feature", + geometry=Point(type="Point", coordinates=[13.4, 52.5]), + product_id=products[0].id, + properties=Constraints(datetime=(datetime.now(UTC), datetime.now(UTC))), + ), + ] diff --git a/tests/opportunity_test.py b/tests/opportunity_test.py index 69efccd..619fb12 100644 --- a/tests/opportunity_test.py +++ b/tests/opportunity_test.py @@ -2,13 +2,21 @@ from fastapi import status from fastapi.testclient import TestClient -from pytest import fixture -from stat_fastapi.models.opportunity import OpportunityCollection +from stat_fastapi.models.opportunity import Opportunity, OpportunityCollection +from stat_fastapi.models.product import Product +from stat_fastapi_test_backend.backend import TestBackend -@fixture -def search_opportunities_response(stat_client: TestClient, product_id: str): +def test_search_opportunities_response( + products: list[Product], + opportunities: list[Opportunity], + stat_backend: TestBackend, + stat_client: TestClient, +): + stat_backend._products = products + stat_backend._opportunities = opportunities + now = datetime.now(UTC) start = now end = start + timedelta(days=5) @@ -21,7 +29,7 @@ def search_opportunities_response(stat_client: TestClient, product_id: str): "type": "Point", "coordinates": [0, 0], }, - "product_id": product_id, + "product_id": products[0].id, "properties": { "datetime": f"{start.isoformat()}/{end.isoformat()}", "off_nadir": { @@ -33,11 +41,6 @@ def search_opportunities_response(stat_client: TestClient, product_id: str): ) assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/geo+json" - yield OpportunityCollection(**res.json()) + response = OpportunityCollection(**res.json()) - -def test_search_opportunities_response( - search_opportunities_response: OpportunityCollection, product_id: str, url_for -): - response = search_opportunities_response assert len(response.features) > 0 diff --git a/tests/order_test.py b/tests/order_test.py index 0c39d49..ddd3508 100644 --- a/tests/order_test.py +++ b/tests/order_test.py @@ -6,26 +6,27 @@ from httpx import Response from pytest import fixture +from stat_fastapi.models.order import OrderPayload +from stat_fastapi_test_backend.backend import TestBackend + from .utils import find_link NOW = datetime.now(UTC) START = NOW END = START + timedelta(days=5) -START_END_INTERVAL = f"{START.isoformat()}/{END.isoformat()}".replace("+00:00", "Z") @fixture def new_order_response( - stat_client: TestClient, product_id: str + stat_backend: TestBackend, + stat_client: TestClient, + allowed_order_payloads: list[OrderPayload], ) -> Generator[Response, None, None]: + stat_backend._allowed_order_payloads = allowed_order_payloads + res = stat_client.post( "/orders", - json={ - "type": "Feature", - "product_id": product_id, - "geometry": {"type": "Point", "coordinates": [0, 0]}, - "properties": {"datetime": f"{START.isoformat()}/{END.isoformat()}"}, - }, + json=allowed_order_payloads[0].model_dump(), ) assert res.status_code == status.HTTP_201_CREATED @@ -40,11 +41,6 @@ def test_new_order_location_header_matches_self_link(new_order_response: Respons ) -def test_new_order_status_is_pending(new_order_response: Response): - order = new_order_response.json() - assert order["properties"]["status"] == "pending" - - @fixture def get_order_response( stat_client: TestClient, new_order_response: Response @@ -52,19 +48,21 @@ def get_order_response( order_id = new_order_response.json()["id"] res = stat_client.get(f"/orders/{order_id}") - + print(res.text) assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/geo+json" yield res -def test_get_order_properties(get_order_response: Response): +def test_get_order_properties(get_order_response: Response, allowed_order_payloads): order = get_order_response.json() assert order["geometry"] == { "type": "Point", - "coordinates": [0, 0], + "coordinates": list(allowed_order_payloads[0].geometry.coordinates), } - assert order["properties"]["datetime"] == START_END_INTERVAL - assert order["properties"]["status"] == "pending" + assert ( + order["properties"]["datetime"] + == allowed_order_payloads[0].properties.model_dump()["datetime"] + ) diff --git a/tests/product_test.py b/tests/product_test.py index 7e52e1e..62518f4 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -2,7 +2,9 @@ from fastapi import status from fastapi.testclient import TestClient -from pytest import fixture + +from stat_fastapi.models.product import Product +from stat_fastapi_test_backend.backend import TestBackend from .utils import find_link from .warnings import StatSpecWarning @@ -20,20 +22,20 @@ def test_products_response(stat_client: TestClient): assert isinstance(data["products"], list) -@fixture -def product_response(stat_client: TestClient, product_id: str): - res = stat_client.get(f"/products/{product_id}") +def test_product_response_self_link( + products: list[Product], stat_backend: TestBackend, stat_client: TestClient, url_for +): + stat_backend._products = products + + res = stat_client.get("/products/mock:standard") assert res.status_code == status.HTTP_200_OK assert res.headers["Content-Type"] == "application/json" - yield res.json() - - -def test_product_response_self_link(product_response: dict, product_id: str, url_for): - link = find_link(product_response["links"], "self") + data = res.json() + link = find_link(data["links"], "self") if link is None: warn(StatSpecWarning("GET /products Link[rel=self] should exist")) else: assert link["type"] == "application/json" - assert link["href"] == url_for(f"/products/{product_id}") + assert link["href"] == url_for("/products/mock:standard") diff --git a/tests/root_test.py b/tests/root_test.py index b2be16f..cf79b50 100644 --- a/tests/root_test.py +++ b/tests/root_test.py @@ -18,7 +18,14 @@ def data(stat_client: TestClient): yield res.json() -def test_root_self_link(data, url_for): +def test_root(stat_client: TestClient, url_for): + res = stat_client.get("/") + + assert res.status_code == status.HTTP_200_OK + assert res.headers["Content-Type"] == "application/json" + + data = res.json() + link = find_link(data["links"], "self") if link is None: warn(StatSpecWarning("GET / Link[rel=self] should exist")) @@ -26,21 +33,17 @@ def test_root_self_link(data, url_for): assert link["type"] == "application/json" assert link["href"] == url_for("/") - -def test_root_service_description_link(data, base_url: str): link = find_link(data["links"], "service-description") if link is None: warn(StatSpecWarning("GET / Link[rel=service-description] should exist")) else: assert link["type"] == "application/json" - assert str(link["href"]) == f"{base_url.rstrip('/')}/openapi.json" - + assert str(link["href"]) == url_for("/openapi.json") -def test_root_service_docs_link(data, base_url: str): link = find_link(data["links"], "service-docs") if link is None: warn(StatSpecWarning("GET / Link[rel=service-docs] should exist")) else: assert link["type"] == "text/html" - assert str(link["href"]) == f"{base_url.rstrip('/')}/docs" + assert str(link["href"]) == url_for("/docs")