diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4f08479..5615e74 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,23 +9,28 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - - run: pipx install poetry==1.7.1 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: poetry - - name: Install dependencies - run: poetry install --with=dev - - name: Lint code - if: ${{ matrix.python-version == 3.12 }} + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cache/pip + .venv + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + - name: Install + run: | + python -m pip install poetry==1.7.1 + poetry install --with=dev + - name: Lint run: | - python -m pip install pre-commit - pre-commit run --all-files - - name: Type-check code - if: ${{ matrix.python-version == 3.12 }} - run: poetry run mypy src - - name: Run tests - run: poetry run nox + poetry run pre-commit run --all-files + - name: Test + run: poetry run pytest diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index df47d12..5615e74 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,37 +1,36 @@ -name: Build Python Package +name: PR Checks on: - push: - branches: - - main pull_request: - branches: - - main - release: - types: - - published + branches: ["main"] jobs: - build-package: + test: runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/stapi-fastapi - permissions: - id-token: write + strategy: + matrix: + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: - python-version: "3.12.x" - - name: Install dependencies + python-version: ${{ matrix.python-version }} + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cache/pip + .venv + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + restore-keys: | + ${{ runner.os }}-pip-${{ matrix.python-version }}- + ${{ runner.os }}-pip- + - name: Install + run: | + python -m pip install poetry==1.7.1 + poetry install --with=dev + - name: Lint run: | - python -m pip install --upgrade pip - pip install build - pip install . - - name: Build package - run: python -m build - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - if: startsWith(github.ref, 'refs/tags') + poetry run pre-commit run --all-files + - name: Test + run: poetry run pytest diff --git a/CHANGELOG.md b/CHANGELOG.md index 2580740..8167052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Added token-based pagination to `GET /orders`, `GET /products`, + `GET /orders/{order_id}/statuses`, and `POST /products/{product_id}/opportunities`. + +### Changed + +- Replaced the root and product backend Protocol classes with Callable type aliases to + enable future changes to make product opportunity searching, product ordering, and/or + asynchronous (stateful) product opportunity searching optional. +- Backend methods that support pagination now return tuples to include the pagination + token. +- Moved `OrderCollection` construction from the root backend to the `RootRouter` + `get_orders` method. ## [v0.5.0] - 2025-01-08 @@ -64,7 +80,6 @@ none none - ## [v0.3.0] - 2024-12-6 ### Added @@ -75,7 +90,7 @@ none - OrderStatusCode and ProviderRole are now StrEnum instead of (str, Enum) - All types using `Result[A, Exception]` have been replace with the equivalent type `ResultE[A]` -- Order and OrderCollection extend _GeoJsonBase instead of Feature and FeatureCollection, to allow for tighter +- Order and OrderCollection extend \_GeoJsonBase instead of Feature and FeatureCollection, to allow for tighter constraints on fields ### Deprecated @@ -140,7 +155,7 @@ Initial release - Add link `create-order` to OpportunityCollection [unreleased]: https://github.com/stapi-spec/stapi-fastapi/compare/v0.5.0...main -[v0.4.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.5.0 +[v0.5.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.5.0 [v0.4.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.4.0 [v0.3.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.3.0 [v0.2.0]: https://github.com/stapi-spec/stapi-fastapi/tree/v0.2.0 diff --git a/README.md b/README.md index 38c278a..627164f 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,16 @@ STAPI FastAPI provides an `fastapi.APIRouter` which must be included in `GET`: `'/orders`, `/products`, `/orders/{order_id}/statuses` `POST`: `/opportunities`. -Pagination is token based and follows recommendations in the [STAC API pagination]. Limit and token are passed in as query params for `GET` endpoints, and via the body aas separte key/value pairs for `POST` requests. +Pagination is token based and follows recommendations in the [STAC API pagination]. +Limit and token are passed in as query params for `GET` endpoints, and via the body as +separate key/value pairs for `POST` requests. -If pagination is available and more records remain the response object will contain a `next` link object that can be used to get the next page of results. No `next` `Link` returned indicates there are no further records available. +If pagination is available and more records remain the response object will contain a +`next` link object that can be used to get the next page of results. No `next` `Link` +returned indicates there are no further records available. `limit` defaults to 10 and maxes at 100. - ## ADRs ADRs can be found in in the [adrs](./adrs/README.md) directory. diff --git a/noxfile.py b/noxfile.py index 0b011a0..62ff8d1 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=["3.12"]) +@nox.session(python=["3.12", "3.13"]) def tests(session): session.run("poetry", "install", external=True) session.run("poetry", "run", "pytest", external=True) diff --git a/poetry.lock b/poetry.lock index 59bfa29..5ccaf8d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1182,5 +1182,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" -python-versions = "3.12.*" -content-hash = "dbd480b7af18b692724040c7f01a5e302a61564bda31876482f4f0917b3244de" +python-versions = "^3.12.0" +content-hash = "3b108a1181ac360d0e2ade6d25ee881ed0274f5033040a86fbd0cd8676ec813d" diff --git a/pyproject.toml b/pyproject.toml index 32ac640..62ac7d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" packages = [{include = "stapi_fastapi", from="src"}] [tool.poetry.dependencies] -python = "3.12.*" +python = "^3.12.0" fastapi = "^0.115.0" pydantic = "^2.10.1" geojson-pydantic = "^1.1.1" diff --git a/src/stapi_fastapi/__init__.py b/src/stapi_fastapi/__init__.py index 3a94221..44b98ae 100644 --- a/src/stapi_fastapi/__init__.py +++ b/src/stapi_fastapi/__init__.py @@ -1,4 +1,3 @@ -from .backends import ProductBackend, RootBackend from .models import ( Link, OpportunityProperties, @@ -12,10 +11,8 @@ "Link", "OpportunityProperties", "Product", - "ProductBackend", "ProductRouter", "Provider", "ProviderRole", - "RootBackend", "RootRouter", ] diff --git a/src/stapi_fastapi/backends/__init__.py b/src/stapi_fastapi/backends/__init__.py index b4b7ff2..b2fb899 100644 --- a/src/stapi_fastapi/backends/__init__.py +++ b/src/stapi_fastapi/backends/__init__.py @@ -1,7 +1,10 @@ -from .product_backend import ProductBackend -from .root_backend import RootBackend +from .product_backend import CreateOrder, SearchOpportunities +from .root_backend import GetOrder, GetOrders, GetOrderStatuses __all__ = [ - "ProductBackend", - "RootBackend", + "CreateOrder", + "GetOrder", + "GetOrders", + "GetOrderStatuses", + "SearchOpportunities", ] diff --git a/src/stapi_fastapi/backends/product_backend.py b/src/stapi_fastapi/backends/product_backend.py index b55104e..122382b 100644 --- a/src/stapi_fastapi/backends/product_backend.py +++ b/src/stapi_fastapi/backends/product_backend.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Protocol +from typing import Any, Callable, Coroutine from fastapi import Request from returns.maybe import Maybe @@ -10,32 +10,49 @@ from stapi_fastapi.models.order import Order, OrderPayload from stapi_fastapi.routers.product_router import ProductRouter - -class ProductBackend(Protocol): # pragma: nocover - async def search_opportunities( - self, - product_router: ProductRouter, - search: OpportunityRequest, - request: Request, - next: str | None, - limit: int, - ) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: - """ - Search for ordering opportunities for the given search parameters and return pagination token if applicable. - - Backends must validate search constraints and return - `stapi_fastapi.exceptions.ConstraintsException` if not valid. - """ - - async def create_order( - self, - product_router: ProductRouter, - search: OrderPayload, - request: Request, - ) -> ResultE[Order]: - """ - Create a new order. - - Backends must validate order payload and return - `stapi_fastapi.exceptions.ConstraintsException` if not valid. - """ +SearchOpportunities = Callable[ + [ProductRouter, OpportunityRequest, Request, str | None, int], + Coroutine[Any, Any, ResultE[tuple[list[Opportunity], Maybe[str]]]], +] +""" +Type alias for an async function that searches for ordering opportunities for the given +search parameters. + +Args: + product_router (ProductRouter): The product router. + search (OpportunityRequest): The search parameters. + request (Request): FastAPI's Request object. + next (str | None): A pagination token. + limit (int): The maximum number of opportunities to return in a page. + +Returns: + A tuple containing a list of opportunities and a pagination token. + + - Should return returns.result.Success[tuple[list[Opportunity], returns.maybe.Some[str]]] if including a pagination token + - Should return returns.result.Success[tuple[list[Opportunity], returns.maybe.Nothing]] if not including a pagination token + - Returning returns.result.Failure[Exception] will result in a 500. + +Note: + Backends must validate search constraints and return + returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. +""" + +CreateOrder = Callable[ + [ProductRouter, OrderPayload, Request], Coroutine[Any, Any, ResultE[Order]] +] +""" +Type alias for an async function that creates a new order. + +Args: + product_router (ProductRouter): The product router. + payload (OrderPayload): The order payload. + request (Request): FastAPI's Request object. + +Returns: + - Should return returns.result.Success[Order] + - Returning returns.result.Failure[Exception] will result in a 500. + +Note: + Backends must validate order payload and return + returns.result.Failure[stapi_fastapi.exceptions.ConstraintsException] if not valid. +""" diff --git a/src/stapi_fastapi/backends/root_backend.py b/src/stapi_fastapi/backends/root_backend.py index fb3d0e6..f072a93 100644 --- a/src/stapi_fastapi/backends/root_backend.py +++ b/src/stapi_fastapi/backends/root_backend.py @@ -1,4 +1,4 @@ -from typing import Protocol +from typing import Any, Callable, Coroutine, TypeVar from fastapi import Request from returns.maybe import Maybe @@ -9,40 +9,63 @@ OrderStatus, ) +GetOrders = Callable[ + [Request, str | None, int], + Coroutine[Any, Any, ResultE[tuple[list[Order], Maybe[str]]]], +] +""" +Type alias for an async function that returns a list of existing Orders. -class RootBackend[T: OrderStatus](Protocol): # pragma: nocover - async def get_orders( - self, request: Request, next: str | None, limit: int - ) -> ResultE[tuple[list[Order], Maybe[str]]]: - """ - Return a list of existing orders and pagination token if applicable. - """ - ... +Args: + request (Request): FastAPI's Request object. + next (str | None): A pagination token. + limit (int): The maximum number of orders to return in a page. - async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Order]]: - """ - Get details for order with `order_id`. +Returns: + A tuple containing a list of orders and a pagination token. - Should return returns.results.Success[Order] if order is found. + - Should return returns.result.Success[tuple[list[Order], returns.maybe.Some[str]]] if including a pagination token + - Should return returns.result.Success[tuple[list[Order], returns.maybe.Nothing]] if not including a pagination token + - Returning returns.result.Failure[Exception] will result in a 500. +""" - Should return returns.results.Failure[returns.maybe.Nothing] if the - order is not found or if access is denied. +GetOrder = Callable[[str, Request], Coroutine[Any, Any, ResultE[Maybe[Order]]]] +""" +Type alias for an async function that gets details for the order with `order_id`. - A Failure[Exception] will result in a 500. - """ - ... +Args: + order_id (str): The order ID. + request (Request): FastAPI's Request object. - async def get_order_statuses( - self, order_id: str, request: Request, next: str | None, limit: int - ) -> ResultE[tuple[list[T], Maybe[str]]]: - """ - Get statuses for order with `order_id` and return pagination token if applicable +Returns: + - Should return returns.result.Success[returns.maybe.Some[Order]] if order is found. + - Should return returns.result.Success[returns.maybe.Nothing] if the order is not + found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. +""" - Should return returns.results.Success[list[OrderStatus]] if order is found. - Should return returns.results.Failure[Exception] if the order is - not found or if access is denied. +T = TypeVar("T", bound=OrderStatus) - A Failure[Exception] will result in a 500. - """ - ... + +GetOrderStatuses = Callable[ + [str, Request, str | None, int], + Coroutine[Any, Any, ResultE[tuple[list[T], Maybe[str]]]], +] +""" +Type alias for an async function that gets statuses for the order with `order_id`. + +Args: + order_id (str): The order ID. + request (Request): FastAPI's Request object. + next (str | None): A pagination token. + limit (int): The maximum number of statuses to return in a page. + +Returns: + A tuple containing a list of order statuses and a pagination token. + + - Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Some[str]] if order is found and including a pagination token. + - Should return returns.result.Success[tuple[list[OrderStatus], returns.maybe.Nothing]] if order is found and not including a pagination token. + - Should return returns.result.Failure[Exception] if the order is not found or if access is denied. + - Returning returns.result.Failure[Exception] will result in a 500. +""" diff --git a/src/stapi_fastapi/models/product.py b/src/stapi_fastapi/models/product.py index bf5118a..d81e79f 100644 --- a/src/stapi_fastapi/models/product.py +++ b/src/stapi_fastapi/models/product.py @@ -10,7 +10,10 @@ from stapi_fastapi.models.shared import Link if TYPE_CHECKING: - from stapi_fastapi.backends.product_backend import ProductBackend + from stapi_fastapi.backends.product_backend import ( + CreateOrder, + SearchOpportunities, + ) type Constraints = BaseModel @@ -50,26 +53,33 @@ class Product(BaseModel): _constraints: type[Constraints] _opportunity_properties: type[OpportunityProperties] _order_parameters: type[OrderParameters] - _backend: ProductBackend + _create_order: CreateOrder + _search_opportunities: SearchOpportunities def __init__( self, *args, - backend: ProductBackend, + create_order: CreateOrder, + search_opportunities: SearchOpportunities, constraints: type[Constraints], opportunity_properties: type[OpportunityProperties], order_parameters: type[OrderParameters], **kwargs, ) -> None: super().__init__(*args, **kwargs) - self._backend = backend + self._create_order = create_order + self._search_opportunities = search_opportunities self._constraints = constraints self._opportunity_properties = opportunity_properties self._order_parameters = order_parameters @property - def backend(self: Self) -> ProductBackend: - return self._backend + def create_order(self: Self) -> CreateOrder: + return self._create_order + + @property + def search_opportunities(self: Self) -> SearchOpportunities: + return self._search_opportunities @property def constraints(self: Self) -> type[Constraints]: diff --git a/src/stapi_fastapi/routers/product_router.py b/src/stapi_fastapi/routers/product_router.py index e3af43a..9f7d0fe 100644 --- a/src/stapi_fastapi/routers/product_router.py +++ b/src/stapi_fastapi/routers/product_router.py @@ -172,7 +172,7 @@ async def search_opportunities( Explore the opportunities available for a particular set of constraints """ links: list[Link] = [] - match await self.product.backend.search_opportunities( + match await self.product._search_opportunities( self, search, request, next, limit ): case Success((features, Some(pagination_token))): @@ -218,7 +218,7 @@ async def create_order( """ Create a new order. """ - match await self.product.backend.create_order( + match await self.product.create_order( self, payload, request, diff --git a/src/stapi_fastapi/routers/root_router.py b/src/stapi_fastapi/routers/root_router.py index 34577c1..e441b21 100644 --- a/src/stapi_fastapi/routers/root_router.py +++ b/src/stapi_fastapi/routers/root_router.py @@ -7,7 +7,7 @@ from returns.maybe import Maybe, Some from returns.result import Failure, Success -from stapi_fastapi.backends.root_backend import RootBackend +from stapi_fastapi.backends.root_backend import GetOrder, GetOrders, GetOrderStatuses from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.exceptions import NotFoundException from stapi_fastapi.models.conformance import CORE, Conformance @@ -28,7 +28,9 @@ class RootRouter(APIRouter): def __init__( self, - backend: RootBackend, + get_orders: GetOrders, + get_order: GetOrder, + get_order_statuses: GetOrderStatuses, conformances: list[str] = [CORE], name: str = "root", openapi_endpoint_name: str = "openapi", @@ -37,7 +39,9 @@ def __init__( **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.backend = backend + self._get_orders = get_orders + self._get_order = get_order + self._get_order_statuses = get_order_statuses self.name = name self.conformances = conformances self.openapi_endpoint_name = openapi_endpoint_name @@ -177,7 +181,7 @@ async def get_orders( self, request: Request, next: str | None = None, limit: int = 10 ) -> OrderCollection: links: list[Link] = [] - match await self.backend.get_orders(request, next, limit): + match await self._get_orders(request, next, limit): case Success((orders, Some(pagination_token))): for order in orders: order.links.append(self.order_link(request, order)) @@ -204,7 +208,7 @@ async def get_order(self: Self, order_id: str, request: Request) -> Order: """ Get details for order with `order_id`. """ - match await self.backend.get_order(order_id, request): + match await self._get_order(order_id, request): case Success(Some(order)): self.add_order_links(order, request) return order @@ -231,7 +235,7 @@ async def get_order_statuses( limit: int = 10, ) -> OrderStatuses: links: list[Link] = [] - match await self.backend.get_order_statuses(order_id, request, next, limit): + match await self._get_order_statuses(order_id, request, next, limit): case Success((statuses, Some(pagination_token))): links.append(self.order_statuses_link(request, order_id)) links.append(self.pagination_link(request, pagination_token)) diff --git a/tests/application.py b/tests/application.py index fa9294d..b9442c7 100644 --- a/tests/application.py +++ b/tests/application.py @@ -1,219 +1,39 @@ -from collections import defaultdict -from datetime import datetime, timezone -from typing import Literal, Self -from uuid import uuid4 +import os +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any -from fastapi import FastAPI, Request -from pydantic import BaseModel, Field, model_validator -from returns.maybe import Maybe, Nothing, Some -from returns.result import Failure, ResultE, Success +from fastapi import FastAPI + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from stapi_fastapi.backends.product_backend import ProductBackend -from stapi_fastapi.backends.root_backend import RootBackend from stapi_fastapi.models.conformance import CORE -from stapi_fastapi.models.opportunity import ( - Opportunity, - OpportunityProperties, - OpportunityRequest, -) -from stapi_fastapi.models.order import ( - Order, - OrderParameters, - OrderPayload, - OrderStatus, - OrderStatusCode, -) -from stapi_fastapi.models.product import ( - Product, - Provider, - ProviderRole, -) -from stapi_fastapi.routers.product_router import ProductRouter from stapi_fastapi.routers.root_router import RootRouter +from tests.backends import ( + mock_get_order, + mock_get_order_statuses, + mock_get_orders, +) +from tests.shared import InMemoryOrderDB, mock_product_test_spotlight -class InMemoryOrderDB: - def __init__(self) -> None: - self._orders: dict[str, Order] = {} - self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) - - -class MockRootBackend(RootBackend): - def __init__(self, orders: InMemoryOrderDB) -> None: - self._orders_db: InMemoryOrderDB = orders - - async def get_orders( - self, request: Request, next: str | None, limit: int - ) -> ResultE[tuple[list[Order], Maybe[str]]]: - """ - Return orders from backend. Handle pagination/limit if applicable - """ - try: - start = 0 - limit = min(limit, 100) - order_ids = [*self._orders_db._orders.keys()] - - if next: - start = order_ids.index(next) - end = start + limit - ids = order_ids[start:end] - orders = [self._orders_db._orders[order_id] for order_id in ids] - - if end > 0 and end < len(order_ids): - return Success( - (orders, Some(self._orders_db._orders[order_ids[end]].id)) - ) - return Success((orders, Nothing)) - except Exception as e: - return Failure(e) - - async def get_order(self, order_id: str, request: Request) -> ResultE[Maybe[Order]]: - """ - Show details for order with `order_id`. - """ - - return Success(Maybe.from_optional(self._orders_db._orders.get(order_id))) - - async def get_order_statuses( - self, order_id: str, request: Request, next: str | None, limit: int - ) -> ResultE[tuple[list[OrderStatus], Maybe[str]]]: - try: - start = 0 - limit = min(limit, 100) - statuses = self._orders_db._statuses[order_id] - - if next: - start = int(next) - end = start + limit - stati = statuses[start:end] - - if end > 0 and end < len(statuses): - return Success((stati, Some(str(end)))) - return Success((stati, Nothing)) - except Exception as e: - return Failure(e) - - -class MockProductBackend(ProductBackend): - def __init__(self, orders: InMemoryOrderDB) -> None: - self._opportunities: list[Opportunity] = [] - self._allowed_payloads: list[OrderPayload] = [] - self._orders_db: InMemoryOrderDB = orders - - async def search_opportunities( - self, - product_router: ProductRouter, - search: OpportunityRequest, - request: Request, - next: str | None, - limit: int, - ) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: - try: - start = 0 - limit = min(limit, 100) - if next: - start = int(next) - end = start + limit - opportunities = [ - o.model_copy(update=search.model_dump()) - for o in self._opportunities[start:end] - ] - if end > 0 and end < len(self._opportunities): - return Success((opportunities, Some(str(end)))) - return Success((opportunities, Nothing)) - except Exception as e: - return Failure(e) - - async def create_order( - self, product_router: ProductRouter, payload: OrderPayload, request: Request - ) -> ResultE[Order]: - """ - Create a new order. - """ - try: - status = OrderStatus( - timestamp=datetime.now(timezone.utc), - status_code=OrderStatusCode.received, - ) - order = Order( - id=str(uuid4()), - geometry=payload.geometry, - properties={ - "product_id": product_router.product.id, - "created": datetime.now(timezone.utc), - "status": status, - "search_parameters": { - "geometry": payload.geometry, - "datetime": payload.datetime, - "filter": payload.filter, - }, - "order_parameters": payload.order_parameters.model_dump(), - "opportunity_properties": { - "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", - "off_nadir": 10, - }, - }, - links=[], - ) - - self._orders_db._orders[order.id] = order - self._orders_db._statuses[order.id].insert(0, status) - return Success(order) - except Exception as e: - return Failure(e) - - -class MyProductConstraints(BaseModel): - off_nadir: int - - -class OffNadirRange(BaseModel): - minimum: int = Field(ge=0, le=45) - maximum: int = Field(ge=0, le=45) - - @model_validator(mode="after") - def validate_range(self) -> Self: - if self.minimum > self.maximum: - raise ValueError("range minimum cannot be greater than maximum") - return self - - -class MyOpportunityProperties(OpportunityProperties): - off_nadir: OffNadirRange - vehicle_id: list[Literal[1, 2, 5, 7, 8]] - platform: Literal["platform_id"] - - -class MyOrderParameters(OrderParameters): - s3_path: str | None = None - - -order_db = InMemoryOrderDB() -product_backend = MockProductBackend(order_db) -root_backend = MockRootBackend(order_db) +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": InMemoryOrderDB(), + } + finally: + pass -provider = Provider( - name="Test Provider", - description="A provider for Test data", - roles=[ProviderRole.producer], # Example role - url="https://test-provider.example.com", # Must be a valid URL -) -product = Product( - id="test-spotlight", - title="Test Spotlight Product", - description="Test product for test spotlight", - license="CC-BY-4.0", - keywords=["test", "satellite"], - providers=[provider], - links=[], - constraints=MyProductConstraints, - opportunity_properties=MyOpportunityProperties, - order_parameters=MyOrderParameters, - backend=product_backend, +root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + conformances=[CORE], ) - -root_router = RootRouter(root_backend, conformances=[CORE]) -root_router.add_product(product) -app: FastAPI = FastAPI() +root_router.add_product(mock_product_test_spotlight) +app: FastAPI = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") diff --git a/tests/backends.py b/tests/backends.py index ede3655..0810bf7 100644 --- a/tests/backends.py +++ b/tests/backends.py @@ -2,10 +2,9 @@ from uuid import uuid4 from fastapi import Request +from returns.maybe import Maybe, Nothing, Some from returns.result import Failure, ResultE, Success -from stapi_fastapi.backends.product_backend import ProductBackend -from stapi_fastapi.exceptions import ConstraintsException from stapi_fastapi.models.opportunity import ( Opportunity, OpportunityRequest, @@ -13,68 +12,126 @@ from stapi_fastapi.models.order import ( Order, OrderPayload, + OrderProperties, + OrderSearchParameters, OrderStatus, OrderStatusCode, ) from stapi_fastapi.routers.product_router import ProductRouter -from .application import InMemoryOrderDB +async def mock_get_orders( + request: Request, next: str | None, limit: int +) -> ResultE[tuple[list[Order], Maybe[str]]]: + """ + Return orders from backend. Handle pagination/limit if applicable + """ + try: + start = 0 + limit = min(limit, 100) + order_ids = [*request.state._orders_db._orders.keys()] -class MockProductBackend(ProductBackend): - def __init__(self, orders: InMemoryOrderDB) -> None: - self._opportunities: list[Opportunity] = [] - self._allowed_payloads: list[OrderPayload] = [] - self._orders = orders + if next: + start = order_ids.index(next) + end = start + limit + ids = order_ids[start:end] + orders = [request.state._orders_db._orders[order_id] for order_id in ids] - async def search_opportunities( - self, - product_router: ProductRouter, - search: OpportunityRequest, - request: Request, - ) -> ResultE[list[Opportunity]]: - return Success( - [o.model_copy(update=search.model_dump()) for o in self._opportunities] - ) + if end > 0 and end < len(order_ids): + return Success( + (orders, Some(request.state._orders_db._orders[order_ids[end]].id)) + ) + return Success((orders, Nothing)) + except Exception as e: + return Failure(e) + + +async def mock_get_order(order_id: str, request: Request) -> ResultE[Maybe[Order]]: + """ + Show details for order with `order_id`. + """ + + return Success(Maybe.from_optional(request.state._orders_db._orders.get(order_id))) + + +async def mock_get_order_statuses( + order_id: str, request: Request, next: str | None, limit: int +) -> ResultE[tuple[list[OrderStatus], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + statuses = request.state._orders_db._statuses[order_id] + + if next: + start = int(next) + end = start + limit + stati = statuses[start:end] + + if end > 0 and end < len(statuses): + return Success((stati, Some(str(end)))) + return Success((stati, Nothing)) + except Exception as e: + return Failure(e) - async def create_order( - self, - product_router: ProductRouter, - payload: OrderPayload, - request: Request, - ) -> ResultE[Order]: - """ - Create a new order. - """ - if any(allowed == payload for allowed in self._allowed_payloads): - order = Order( - id=str(uuid4()), - geometry=payload.geometry, # maybe set to a different value by opportunity resolution process - properties={ - "product_id": product_router.product.id, - "created": datetime.now(timezone.utc), - "status": OrderStatus( - timestamp=datetime.now(timezone.utc), - status_code=OrderStatusCode.accepted, - ), - "search_parameters": { - "geometry": payload.geometry, - "datetime": payload.datetime, - "filter": payload.filter, - }, - "order_parameters": payload.order_parameters.model_dump(), - "opportunity_properties": { - "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", - "off_nadir": 10, - }, + +async def mock_search_opportunities( + product_router: ProductRouter, + search: OpportunityRequest, + request: Request, + next: str | None, + limit: int, +) -> ResultE[tuple[list[Opportunity], Maybe[str]]]: + try: + start = 0 + limit = min(limit, 100) + if next: + start = int(next) + end = start + limit + opportunities = [ + o.model_copy(update=search.model_dump()) + for o in request.state._opportunities[start:end] + ] + if end > 0 and end < len(request.state._opportunities): + return Success((opportunities, Some(str(end)))) + return Success((opportunities, Nothing)) + except Exception as e: + return Failure(e) + + +async def mock_create_order( + product_router: ProductRouter, payload: OrderPayload, request: Request +) -> ResultE[Order]: + """ + Create a new order. + """ + try: + status = OrderStatus( + timestamp=datetime.now(timezone.utc), + status_code=OrderStatusCode.received, + ) + order = Order( + id=str(uuid4()), + geometry=payload.geometry, + properties=OrderProperties( + product_id=product_router.product.id, + created=datetime.now(timezone.utc), + status=status, + search_parameters=OrderSearchParameters( + geometry=payload.geometry, + datetime=payload.datetime, + filter=payload.filter, + ), + order_parameters=payload.order_parameters.model_dump(), + opportunity_properties={ + "datetime": "2024-01-29T12:00:00Z/2024-01-30T12:00:00Z", + "off_nadir": 10, }, - links=[], - ) - self._orders[order.id] = order - return Success(order) - else: - return Failure( - ConstraintsException( - f"not allowed: payload {payload.model_dump_json()} not in {[p.model_dump_json() for p in self._allowed_payloads]}" - ) - ) + ), + links=[], + ) + + request.state._orders_db._orders[order.id] = order + request.state._orders_db._statuses[order.id].insert(0, status) + return Success(order) + except Exception as e: + return Failure(e) diff --git a/tests/conftest.py b/tests/conftest.py index cc2261c..c060495 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,29 +1,32 @@ -from collections.abc import Iterator +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager from typing import Any, Callable from urllib.parse import urljoin import pytest -from fastapi import FastAPI, status +from fastapi import FastAPI from fastapi.testclient import TestClient -from httpx import Response -from pytest import fail +from stapi_fastapi.models.opportunity import ( + Opportunity, +) from stapi_fastapi.models.product import ( Product, - Provider, - ProviderRole, ) from stapi_fastapi.routers.root_router import RootRouter -from .application import ( +from .backends import ( + mock_get_order, + mock_get_order_statuses, + mock_get_orders, +) +from .shared import ( InMemoryOrderDB, - MockProductBackend, - MockRootBackend, - MyOpportunityProperties, - MyOrderParameters, - MyProductConstraints, + create_mock_opportunity, + find_link, + mock_product_test_satellite_provider, + mock_product_test_spotlight, ) -from .shared import find_link @pytest.fixture(scope="session") @@ -32,71 +35,39 @@ def base_url() -> Iterator[str]: @pytest.fixture -def order_db() -> InMemoryOrderDB: - return InMemoryOrderDB() +def mock_products() -> list[Product]: + return [mock_product_test_spotlight, mock_product_test_satellite_provider] @pytest.fixture -def product_backend(order_db: InMemoryOrderDB) -> MockProductBackend: - return MockProductBackend(order_db) - - -@pytest.fixture -def root_backend(order_db: InMemoryOrderDB) -> MockRootBackend: - return MockRootBackend(order_db) - - -@pytest.fixture -def mock_product_test_spotlight( - product_backend: MockProductBackend, mock_provider: Provider -) -> Product: - """Fixture for a mock Test Spotlight product.""" - return Product( - id="test-spotlight", - title="Test Spotlight Product", - description="Test product for test spotlight", - license="CC-BY-4.0", - keywords=["test", "satellite"], - providers=[mock_provider], - links=[], - constraints=MyProductConstraints, - opportunity_properties=MyOpportunityProperties, - order_parameters=MyOrderParameters, - backend=product_backend, - ) - - -@pytest.fixture -def mock_product_test_satellite_provider( - product_backend: MockProductBackend, mock_provider: Provider -) -> Product: - """Fixture for a mock satellite provider product.""" - return Product( - id="test-satellite-provider", - title="Satellite Product", - description="A product by a satellite provider", - license="CC-BY-4.0", - keywords=["test", "satellite", "provider"], - providers=[mock_provider], - links=[], - constraints=MyProductConstraints, - opportunity_properties=MyOpportunityProperties, - order_parameters=MyOrderParameters, - backend=product_backend, - ) +def mock_opportunities() -> list[Opportunity]: + return [create_mock_opportunity()] @pytest.fixture def stapi_client( - root_backend, - mock_product_test_spotlight, - mock_product_test_satellite_provider, + mock_products: list[Product], base_url: str, + mock_opportunities: list[Opportunity], ) -> Iterator[TestClient]: - root_router = RootRouter(root_backend) - root_router.add_product(mock_product_test_spotlight) - root_router.add_product(mock_product_test_satellite_provider) - app = FastAPI() + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + yield { + "_orders_db": InMemoryOrderDB(), + "_opportunities": mock_opportunities, + } + finally: + pass + + root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + ) + for mock_product in mock_products: + root_router.add_product(mock_product) + app = FastAPI(lifespan=lifespan) app.include_router(root_router, prefix="") with TestClient(app, base_url=f"{base_url}") as client: @@ -104,8 +75,12 @@ def stapi_client( @pytest.fixture -def empty_stapi_client(root_backend, base_url: str) -> Iterator[TestClient]: - root_router = RootRouter(root_backend) +def empty_stapi_client(base_url: str) -> Iterator[TestClient]: + root_router = RootRouter( + get_orders=mock_get_orders, + get_order=mock_get_order, + get_order_statuses=mock_get_order_statuses, + ) app = FastAPI() app.include_router(root_router, prefix="") @@ -139,86 +114,3 @@ def _assert_link( assert link["href"] == url_for(path) return _assert_link - - -@pytest.fixture -def products(mock_product_test_spotlight: Product) -> list[Product]: - return [mock_product_test_spotlight] - - -@pytest.fixture -def mock_provider() -> Provider: - return Provider( - name="Test Provider", - description="A provider for Test data", - roles=[ProviderRole.producer], # Example role - url="https://test-provider.example.com", # Must be a valid URL - ) - - -def pagination_tester( - stapi_client: TestClient, - endpoint: str, - method: str, - limit: int, - target: str, - expected_returns: list, - body: dict | None = None, -) -> None: - retrieved = [] - - res = make_request(stapi_client, endpoint, method, body, None, limit) - assert res.status_code == status.HTTP_200_OK - resp_body = res.json() - - assert len(resp_body[target]) <= limit - retrieved.extend(resp_body[target]) - next_url = next((d["href"] for d in resp_body["links"] if d["rel"] == "next"), None) - - while next_url: - url = next_url - if method == "POST": - body = next( - (d["body"] for d in resp_body["links"] if d["rel"] == "next"), None - ) - - res = make_request(stapi_client, url, method, body, next_url, limit) - assert res.status_code == status.HTTP_200_OK - assert len(resp_body[target]) <= limit - resp_body = res.json() - retrieved.extend(resp_body[target]) - - # get url w/ query params for next call if exists, and POST body if necessary - if resp_body["links"]: - next_url = next( - (d["href"] for d in resp_body["links"] if d["rel"] == "next"), None - ) - else: - next_url = None - - assert len(retrieved) == len(expected_returns) - assert retrieved == expected_returns - - -def make_request( - stapi_client: TestClient, - endpoint: str, - method: str, - body: dict | None, - next_token: str | None, - limit: int, -) -> Response: - """request wrapper for pagination tests""" - - match method: - case "GET": - if next_token: # extract pagination token - next_token = next_token.split("next=")[1] - params = {"next": next_token, "limit": limit} - res = stapi_client.get(endpoint, params=params) - case "POST": - res = stapi_client.post(endpoint, json=body) - case _: - fail(f"method {method} not supported in make request") - - return res diff --git a/tests/shared.py b/tests/shared.py index 4a2aa32..45bff0d 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,7 +1,199 @@ -from typing import Any +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Any, Literal, Self +from uuid import uuid4 + +from fastapi import status +from fastapi.testclient import TestClient +from geojson_pydantic import Point +from geojson_pydantic.types import Position2D +from httpx import Response +from pydantic import BaseModel, Field, model_validator +from pytest import fail + +from stapi_fastapi.models.opportunity import ( + Opportunity, + OpportunityProperties, +) +from stapi_fastapi.models.order import ( + Order, + OrderParameters, + OrderStatus, +) +from stapi_fastapi.models.product import ( + Product, + Provider, + ProviderRole, +) + +from .backends import ( + mock_create_order, + mock_search_opportunities, +) type link_dict = dict[str, Any] def find_link(links: list[link_dict], rel: str) -> link_dict | None: return next((link for link in links if link["rel"] == rel), None) + + +class InMemoryOrderDB: + def __init__(self) -> None: + self._orders: dict[str, Order] = {} + self._statuses: dict[str, list[OrderStatus]] = defaultdict(list) + + +class MyProductConstraints(BaseModel): + off_nadir: int + + +class OffNadirRange(BaseModel): + minimum: int = Field(ge=0, le=45) + maximum: int = Field(ge=0, le=45) + + @model_validator(mode="after") + def validate_range(self) -> Self: + if self.minimum > self.maximum: + raise ValueError("range minimum cannot be greater than maximum") + return self + + +class MyOpportunityProperties(OpportunityProperties): + off_nadir: OffNadirRange + vehicle_id: list[Literal[1, 2, 5, 7, 8]] + platform: Literal["platform_id"] + + +class MyOrderParameters(OrderParameters): + s3_path: str | None = None + + +provider = Provider( + name="Test Provider", + description="A provider for Test data", + roles=[ProviderRole.producer], # Example role + url="https://test-provider.example.com", # Must be a valid URL +) + +mock_product_test_spotlight = Product( + id="test-spotlight", + title="Test Spotlight Product", + description="Test product for test spotlight", + license="CC-BY-4.0", + keywords=["test", "satellite"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + +mock_product_test_satellite_provider = Product( + id="test-satellite-provider", + title="Satellite Product", + description="A product by a satellite provider", + license="CC-BY-4.0", + keywords=["test", "satellite", "provider"], + providers=[provider], + links=[], + create_order=mock_create_order, + search_opportunities=mock_search_opportunities, + constraints=MyProductConstraints, + opportunity_properties=MyOpportunityProperties, + order_parameters=MyOrderParameters, +) + + +def create_mock_opportunity() -> Opportunity: + now = datetime.now(timezone.utc) # Use timezone-aware datetime + start = now + end = start + timedelta(days=5) + + # Create a list of mock opportunities for the given product + return Opportunity( + id=str(uuid4()), + type="Feature", + geometry=Point( + type="Point", + coordinates=Position2D(longitude=0.0, latitude=0.0), + ), + properties=MyOpportunityProperties( + product_id="xyz123", + datetime=(start, end), + off_nadir=OffNadirRange(minimum=20, maximum=22), + vehicle_id=[1], + platform="platform_id", + other_thing="abcd1234", + ), + ) + + +def pagination_tester( + stapi_client: TestClient, + endpoint: str, + method: str, + limit: int, + target: str, + expected_returns: list, + body: dict | None = None, +) -> None: + retrieved = [] + + res = make_request(stapi_client, endpoint, method, body, None, limit) + assert res.status_code == status.HTTP_200_OK + resp_body = res.json() + + assert len(resp_body[target]) <= limit + retrieved.extend(resp_body[target]) + next_url = next((d["href"] for d in resp_body["links"] if d["rel"] == "next"), None) + + while next_url: + url = next_url + if method == "POST": + body = next( + (d["body"] for d in resp_body["links"] if d["rel"] == "next"), None + ) + + res = make_request(stapi_client, url, method, body, next_url, limit) + assert res.status_code == status.HTTP_200_OK + assert len(resp_body[target]) <= limit + resp_body = res.json() + retrieved.extend(resp_body[target]) + + # get url w/ query params for next call if exists, and POST body if necessary + if resp_body["links"]: + next_url = next( + (d["href"] for d in resp_body["links"] if d["rel"] == "next"), None + ) + else: + next_url = None + + assert len(retrieved) == len(expected_returns) + assert retrieved == expected_returns + + +def make_request( + stapi_client: TestClient, + endpoint: str, + method: str, + body: dict | None, + next_token: str | None, + limit: int, +) -> Response: + """request wrapper for pagination tests""" + + match method: + case "GET": + if next_token: # extract pagination token + next_token = next_token.split("next=")[1] + params = {"next": next_token, "limit": limit} + res = stapi_client.get(endpoint, params=params) + case "POST": + res = stapi_client.post(endpoint, json=body) + case _: + fail(f"method {method} not supported in make request") + + return res diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index 56fadef..389d47d 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -1,66 +1,22 @@ -from datetime import UTC, datetime, timedelta, timezone -from typing import List -from uuid import uuid4 +from datetime import UTC, datetime, timedelta import pytest from fastapi.testclient import TestClient -from geojson_pydantic import Point -from geojson_pydantic.types import Position2D from stapi_fastapi.models.opportunity import ( - Opportunity, OpportunityCollection, ) -from tests.application import MyOpportunityProperties -from tests.conftest import pagination_tester -from .backends import MockProductBackend +from .shared import create_mock_opportunity, pagination_tester from .test_datetime_interval import rfc3339_strftime -@pytest.fixture -def mock_test_spotlight_opportunities() -> list[Opportunity]: - """Fixture to create mock data for Opportunities for `test-spotlight-1`.""" - start = datetime.now(timezone.utc) # Use timezone-aware datetime - end = start + timedelta(days=5) - - # Create a list of mock opportunities for the given product - return [ - Opportunity( - id=str(uuid4()), - type="Feature", - geometry=Point( - type="Point", - coordinates=Position2D(longitude=0.0, latitude=0.0), - ), - properties=MyOpportunityProperties( - product_id="xyz123", - datetime=(start, end), - off_nadir={"minimum": 20, "maximum": 22}, - vehicle_id=[1], - platform="platform_id", - ), - ), - ] - - -@pytest.fixture -def mock_test_pagination_opportunities( - mock_test_spotlight_opportunities, -) -> list[Opportunity]: - return [opp for opp in mock_test_spotlight_opportunities for __ in range(0, 3)] - - @pytest.mark.parametrize("product_id", ["test-spotlight"]) def test_search_opportunities_response( product_id: str, - mock_test_spotlight_opportunities: List[Opportunity], - product_backend: MockProductBackend, stapi_client: TestClient, assert_link, ) -> None: - product_backend._opportunities = mock_test_spotlight_opportunities - now = datetime.now(UTC) end = now + timedelta(days=5) format = "%Y-%m-%dT%H:%M:%S.%f%z" @@ -92,6 +48,9 @@ def test_search_opportunities_response( assert response.status_code == 200, f"Failed for product: {product_id}" body = response.json() + # Validate the opportunity was returned + assert len(body["features"]) == 1 + try: _ = OpportunityCollection(**body) except Exception as _: @@ -104,15 +63,14 @@ def test_search_opportunities_response( def test_search_opportunities_pagination( limit: int, stapi_client: TestClient, - product_backend: MockProductBackend, - mock_test_pagination_opportunities: List[Opportunity], ) -> None: + mock_pagination_opportunities = [create_mock_opportunity() for __ in range(3)] + stapi_client.app_state["_opportunities"] = mock_pagination_opportunities product_id = "test-spotlight" - product_backend._opportunities = mock_test_pagination_opportunities expected_returns = [] if limit != 0: expected_returns = [ - x.model_dump(mode="json") for x in mock_test_pagination_opportunities + x.model_dump(mode="json") for x in mock_pagination_opportunities ] now = datetime.now(UTC) diff --git a/tests/test_order.py b/tests/test_order.py index 09990e8..3173489 100644 --- a/tests/test_order.py +++ b/tests/test_order.py @@ -9,11 +9,8 @@ from httpx import Response from stapi_fastapi.models.order import Order, OrderPayload, OrderStatus, OrderStatusCode -from tests.conftest import pagination_tester -from .application import InMemoryOrderDB, MyOrderParameters -from .backends import MockProductBackend -from .shared import find_link +from .shared import MyOrderParameters, find_link, pagination_tester NOW = datetime.now(UTC) START = NOW @@ -55,11 +52,9 @@ def create_order_payloads() -> list[OrderPayload]: @pytest.fixture def new_order_response( product_id: str, - product_backend: MockProductBackend, stapi_client: TestClient, create_order_payloads: list[OrderPayload], ) -> Response: - product_backend._allowed_payloads = create_order_payloads res = stapi_client.post( f"products/{product_id}/orders", json=create_order_payloads[0].model_dump(), @@ -142,6 +137,7 @@ def test_order_status_after_create( f"GET /orders/{body['id']}", body, "monitor", f"/orders/{body['id']}/statuses" ) link = find_link(body["links"], "monitor") + assert link is not None res = stapi_client.get(link["href"]) assert res.status_code == status.HTTP_200_OK @@ -226,10 +222,9 @@ def order_statuses() -> dict[str, list[OrderStatus]]: def test_get_order_status_pagination( limit: int, stapi_client: TestClient, - order_db: InMemoryOrderDB, order_statuses: dict[str, list[OrderStatus]], ) -> None: - order_db._statuses = order_statuses + stapi_client.app_state["_orders_db"]._statuses = order_statuses order_id = "test_order_id" expected_returns = [] @@ -248,11 +243,10 @@ def test_get_order_status_pagination( def test_get_order_statuses_bad_token( stapi_client: TestClient, - order_db: InMemoryOrderDB, order_statuses: dict[str, list[OrderStatus]], limit: int = 2, ) -> None: - order_db._statuses = order_statuses + stapi_client.app_state["_orders_db"]._statuses = order_statuses order_id = "non_existing_order_id" res = stapi_client.get(f"/orders/{order_id}/statuses") diff --git a/tests/test_product.py b/tests/test_product.py index cb2a45b..efca972 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -2,7 +2,9 @@ from fastapi import status from fastapi.testclient import TestClient -from tests.conftest import pagination_tester +from stapi_fastapi.models.product import Product + +from .shared import pagination_tester def test_products_response(stapi_client: TestClient): @@ -71,15 +73,11 @@ def test_product_order_parameters_response( def test_get_products_pagination( limit: int, stapi_client: TestClient, - mock_product_test_spotlight, - mock_product_test_satellite_provider, + mock_products: list[Product], ): expected_returns = [] if limit != 0: - for product in [ - mock_product_test_spotlight, - mock_product_test_satellite_provider, - ]: + for product in mock_products: prod = product.model_dump(mode="json", by_alias=True) product_id = prod["id"] prod["links"] = [