pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
175 lines
5.2 KiB
175 lines
5.2 KiB
from typing import Annotated
|
|
|
|
import pytest
|
|
from fastapi import Depends, FastAPI
|
|
from fastapi.pagination import PaginatedResponse, Paginator, paginate
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class Item(BaseModel):
|
|
id: int
|
|
name: str
|
|
|
|
|
|
class QueryLike:
|
|
def __init__(self, items: list[Item]) -> None:
|
|
self.items = items
|
|
self.skip = 0
|
|
self.take = len(items)
|
|
|
|
def count(self) -> int:
|
|
return len(self.items)
|
|
|
|
def offset(self, skip: int) -> "QueryLike":
|
|
query = QueryLike(self.items)
|
|
query.skip = skip
|
|
query.take = self.take
|
|
return query
|
|
|
|
def limit(self, take: int) -> "QueryLike":
|
|
query = QueryLike(self.items)
|
|
query.skip = self.skip
|
|
query.take = take
|
|
return query
|
|
|
|
def all(self) -> list[Item]:
|
|
return self.items[self.skip : self.skip + self.take]
|
|
|
|
|
|
ITEMS = [Item(id=index, name=f"item-{index}") for index in range(1, 26)]
|
|
|
|
|
|
def test_offset_pagination_calculates_skip_limit_and_metadata() -> None:
|
|
paginator = Paginator(page=2, page_size=10)
|
|
|
|
response = paginator.paginate(ITEMS)
|
|
|
|
assert paginator.skip == 10
|
|
assert paginator.limit == 10
|
|
assert response.items == ITEMS[10:20]
|
|
assert response.total == 25
|
|
assert response.page == 2
|
|
assert response.page_size == 10
|
|
assert response.total_pages == 3
|
|
assert response.has_next is True
|
|
assert response.has_previous is True
|
|
|
|
|
|
def test_offset_pagination_sets_boundary_flags() -> None:
|
|
first_page = Paginator(page=1, page_size=10).paginate(ITEMS)
|
|
last_page = Paginator(page=3, page_size=10).paginate(ITEMS)
|
|
|
|
assert first_page.has_previous is False
|
|
assert first_page.has_next is True
|
|
assert last_page.has_previous is True
|
|
assert last_page.has_next is False
|
|
|
|
|
|
def test_empty_offset_pagination_returns_stable_metadata() -> None:
|
|
response = Paginator(page=1, page_size=10).paginate([])
|
|
|
|
assert response.items == []
|
|
assert response.total == 0
|
|
assert response.page == 1
|
|
assert response.page_size == 10
|
|
assert response.total_pages == 0
|
|
assert response.has_next is False
|
|
assert response.has_previous is False
|
|
|
|
|
|
def test_offset_pagination_supports_query_like_sources() -> None:
|
|
response = Paginator(page=2, page_size=5).paginate(QueryLike(ITEMS))
|
|
|
|
assert response.items == ITEMS[5:10]
|
|
assert response.total == 25
|
|
assert response.page == 2
|
|
assert response.total_pages == 5
|
|
|
|
|
|
def test_paginator_rejects_invalid_page_and_page_size() -> None:
|
|
with pytest.raises(ValueError, match="page"):
|
|
Paginator(page=0)
|
|
|
|
with pytest.raises(ValueError, match="page"):
|
|
Paginator(page=-1)
|
|
|
|
with pytest.raises(ValueError, match="page_size"):
|
|
Paginator(page_size=0)
|
|
|
|
|
|
def test_cursor_pagination_returns_opaque_next_and_previous_cursors() -> None:
|
|
first_page = Paginator(page_size=10).paginate_cursor(ITEMS)
|
|
|
|
assert first_page.items == ITEMS[:10]
|
|
assert first_page.has_next is True
|
|
assert first_page.has_previous is False
|
|
assert first_page.next_cursor is not None
|
|
assert first_page.previous_cursor is None
|
|
assert "10" not in first_page.next_cursor
|
|
|
|
second_page = Paginator(
|
|
page_size=10, cursor=first_page.next_cursor
|
|
).paginate_cursor(ITEMS)
|
|
|
|
assert second_page.items == ITEMS[10:20]
|
|
assert second_page.has_next is True
|
|
assert second_page.has_previous is True
|
|
assert second_page.next_cursor is not None
|
|
assert second_page.previous_cursor is not None
|
|
|
|
|
|
def test_cursor_pagination_sets_last_page_boundary_flags() -> None:
|
|
first_page = Paginator(page_size=10).paginate_cursor(ITEMS)
|
|
second_page = Paginator(
|
|
page_size=10, cursor=first_page.next_cursor
|
|
).paginate_cursor(ITEMS)
|
|
last_page = Paginator(page_size=10, cursor=second_page.next_cursor).paginate_cursor(
|
|
ITEMS
|
|
)
|
|
|
|
assert last_page.items == ITEMS[20:25]
|
|
assert last_page.has_next is False
|
|
assert last_page.has_previous is True
|
|
assert last_page.next_cursor is None
|
|
assert last_page.previous_cursor is not None
|
|
|
|
|
|
def test_cursor_pagination_rejects_invalid_cursor() -> None:
|
|
with pytest.raises(ValueError, match="Invalid cursor"):
|
|
Paginator(page_size=10, cursor="not-a-valid-cursor").paginate_cursor(ITEMS)
|
|
|
|
|
|
def test_paginated_response_is_generic_over_pydantic_models() -> None:
|
|
response = PaginatedResponse[Item](
|
|
items=[Item(id=1, name="one")],
|
|
total=1,
|
|
page=1,
|
|
page_size=10,
|
|
total_pages=1,
|
|
has_next=False,
|
|
has_previous=False,
|
|
)
|
|
|
|
assert response.items[0].name == "one"
|
|
|
|
|
|
def test_paginate_dependency_reads_query_parameters() -> None:
|
|
app = FastAPI()
|
|
|
|
@app.get("/items")
|
|
def read_items(
|
|
paginator: Annotated[Paginator, Depends(paginate)],
|
|
) -> dict[str, int]:
|
|
return {"page": paginator.page, "page_size": paginator.page_size}
|
|
|
|
client = TestClient(app)
|
|
|
|
assert client.get("/items").json() == {"page": 1, "page_size": 100}
|
|
assert client.get("/items?page=3&page_size=20").json() == {
|
|
"page": 3,
|
|
"page_size": 20,
|
|
}
|
|
assert client.get("/items?page=0").status_code == 422
|
|
assert client.get("/items?page=-1").status_code == 422
|
|
assert client.get("/items?page_size=0").status_code == 422
|
|
|