Skip to content

Commit 169a224

Browse files
Centralize pagination test, add constraints (#313)
For now I think a limit of 1000 seems reasonable, but we'll have to evaluate this later (and also whether or not we should have per-endpoint limits). --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 3879b5b commit 169a224

5 files changed

Lines changed: 61 additions & 79 deletions

File tree

src/routers/dependencies.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from fastapi import Depends
55
from loguru import logger
6-
from pydantic import BaseModel
6+
from pydantic import BaseModel, Field
77
from sqlalchemy.ext.asyncio import AsyncConnection
88

99
from core.errors import AuthenticationFailedError, AuthenticationRequiredError
@@ -57,6 +57,10 @@ def fetch_user_or_raise(
5757
return user
5858

5959

60+
LIMIT_DEFAULT = 100
61+
LIMIT_MAX = 1000
62+
63+
6064
class Pagination(BaseModel):
61-
offset: int = 0
62-
limit: int = 100
65+
offset: int = Field(default=0, ge=0)
66+
limit: int = Field(default=LIMIT_DEFAULT, gt=0, le=LIMIT_MAX)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from typing import Any
2+
3+
import pytest
4+
from pydantic import ValidationError
5+
6+
from routers.dependencies import Pagination
7+
8+
9+
def test_pagination_defaults() -> None:
10+
"""Pagination has expected defaults when no values are provided."""
11+
pagination = Pagination()
12+
assert pagination.offset == 0
13+
assert pagination.limit == 100 # noqa: PLR2004
14+
15+
16+
@pytest.mark.parametrize(
17+
("kwargs", "expected_field"),
18+
[
19+
({"limit": "abc", "offset": 0}, "limit"),
20+
({"limit": -5, "offset": 0}, "limit"),
21+
({"limit": 2000, "offset": 0}, "limit"),
22+
({"limit": 5, "offset": "xyz"}, "offset"),
23+
({"limit": 5, "offset": -5}, "offset"),
24+
],
25+
ids=[
26+
"bad_limit_type",
27+
"negative_limit",
28+
"limit_too_large",
29+
"bad_offset_type",
30+
"negative_offset",
31+
],
32+
)
33+
def test_pagination_invalid_type(kwargs: dict[str, Any], expected_field: str) -> None:
34+
"""Non-integer values for limit or offset raise a ValidationError."""
35+
with pytest.raises(ValidationError) as exc_info:
36+
Pagination(**kwargs)
37+
errors = exc_info.value.errors()
38+
assert any(error["loc"] == (expected_field,) for error in errors)

tests/routers/openml/datasets_list_datasets_test.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from core.errors import NoResultsError
1313
from database.users import User
14-
from routers.dependencies import Pagination
14+
from routers.dependencies import LIMIT_DEFAULT, Pagination
1515
from routers.openml.datasets import DatasetStatusFilter, list_datasets
1616
from tests import constants
1717
from tests.users import ADMIN_USER, DATASET_130_OWNER, SOME_USER, ApiKey
@@ -57,12 +57,6 @@ async def test_list_data_identical(
5757
api_key = kwargs.pop("api_key")
5858
api_key_query = f"?api_key={api_key}" if api_key else ""
5959

60-
# Pagination parameters are nested in the new query style
61-
# The old style has no `limit` by default, so we mimic this with a high default
62-
new_style = kwargs | {"pagination": {"limit": limit or 1_000_000}}
63-
if offset is not None:
64-
new_style["pagination"]["offset"] = offset
65-
6660
# old style `/data/filter` encodes all filters as a path
6761
query = [
6862
[filter_, value if not isinstance(value, list) else ",".join(str(v) for v in value)]
@@ -74,13 +68,21 @@ async def test_list_data_identical(
7468
uri += f"/{'/'.join([str(v) for q in query for v in q])}"
7569
uri += api_key_query
7670

71+
# new style just takes the values directly in a JSON body,
72+
# except that the limit and offset parameters are under a pagination field.
73+
if limit is not None:
74+
kwargs.setdefault("pagination", {})["limit"] = limit
75+
if offset is not None:
76+
kwargs.setdefault("pagination", {})["offset"] = offset
77+
7778
py_response, php_response = await asyncio.gather(
78-
py_api.post(f"/datasets/list{api_key_query}", json=new_style),
79+
py_api.post(f"/datasets/list{api_key_query}", json=kwargs),
7980
php_api.get(uri),
8081
)
8182

8283
# Note: RFC 9457 changed some status codes (PRECONDITION_FAILED -> NOT_FOUND for no results)
8384
# and the error response format, so we can't compare error responses directly.
85+
# Validation errors shouldn't occur since the search space doesn't include invalid values
8486
php_is_error = php_response.status_code == HTTPStatus.PRECONDITION_FAILED
8587
py_is_error = py_response.status_code == HTTPStatus.NOT_FOUND
8688

@@ -105,6 +107,9 @@ async def test_list_data_identical(
105107

106108
# PHP API has a double nested dictionary that never has other entries
107109
php_json = php_response.json()["data"]["dataset"]
110+
# The default limit changed from unbound to 100.
111+
if limit is None:
112+
php_json = php_json[:LIMIT_DEFAULT]
108113
assert len(py_json) == len(php_json)
109114
assert py_json == php_json
110115
return None

tests/routers/openml/migration/tasks_migration_test.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
nested_remove_single_element_list,
1212
nested_remove_values,
1313
)
14+
from routers.dependencies import LIMIT_MAX
1415

1516

1617
@pytest.mark.parametrize(
@@ -141,8 +142,7 @@ async def test_list_tasks_equal(
141142
- PHP error status is 412 PRECONDITION_FAILED; Python uses 404 NOT_FOUND.
142143
"""
143144
php_path = _build_php_task_list_path(php_params)
144-
# Use a very large limit on Python side to match PHP's unbounded default result count
145-
py_body = {**py_extra, "pagination": {"limit": 1_000_000, "offset": 0}}
145+
py_body = {**py_extra, "pagination": {"limit": LIMIT_MAX, "offset": 0}}
146146
py_response, php_response = await asyncio.gather(
147147
py_api.post("/tasks/list", json=py_body),
148148
php_api.get(php_path),
@@ -163,6 +163,7 @@ async def test_list_tasks_equal(
163163
php_tasks: list[dict[str, Any]] = (
164164
php_tasks_raw if isinstance(php_tasks_raw, list) else [php_tasks_raw]
165165
)
166+
php_tasks = php_tasks[:LIMIT_MAX]
166167
py_tasks: list[dict[str, Any]] = [_normalize_py_task(t) for t in py_response.json()]
167168

168169
php_ids = {int(t["task_id"]) for t in php_tasks}

tests/routers/openml/task_list_test.py

Lines changed: 0 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -74,72 +74,6 @@ async def test_list_tasks_api_happy_path(py_api: httpx.AsyncClient) -> None:
7474
assert "OpenML100" in task["tag"]
7575

7676

77-
@pytest.mark.parametrize(
78-
("limit", "offset", "expected_status", "expected_max_results"),
79-
[
80-
(-10, 0, HTTPStatus.NOT_FOUND, 0), # negative limit clamped to 0 -> No results
81-
(5, -10, HTTPStatus.OK, 5), # negative offset clamped to 0 -> First 5 results
82-
],
83-
ids=["negative_limit", "negative_offset"],
84-
)
85-
async def test_list_tasks_negative_pagination_safely_clamped(
86-
limit: int,
87-
offset: int,
88-
expected_status: int,
89-
expected_max_results: int,
90-
py_api: httpx.AsyncClient,
91-
) -> None:
92-
"""Negative pagination values are safely clamped to 0 instead of causing 500 errors.
93-
94-
A limit clamped to 0 raises NoResultsError, which the API maps to HTTP 404.
95-
An offset clamped to 0 simply returns the first page of results (200 OK).
96-
97-
Note: This remains an HTTP-level (py_api) test to ensure end-to-end safety is
98-
preserved.
99-
"""
100-
response = await py_api.post(
101-
"/tasks/list",
102-
json={"pagination": {"limit": limit, "offset": offset}},
103-
)
104-
assert response.status_code == expected_status
105-
if expected_status == HTTPStatus.OK:
106-
body = response.json()
107-
assert len(body) <= expected_max_results
108-
# Compare to a baseline with offset=0 to prove it was correctly clamped
109-
baseline = await py_api.post(
110-
"/tasks/list",
111-
json={"pagination": {"limit": limit, "offset": 0}},
112-
)
113-
assert baseline.status_code == HTTPStatus.OK
114-
assert [t["task_id"] for t in body] == [t["task_id"] for t in baseline.json()]
115-
else:
116-
error = response.json()
117-
assert error["type"] == NoResultsError.uri
118-
119-
120-
@pytest.mark.parametrize(
121-
("pagination_override", "expected_field"),
122-
[
123-
({"limit": "abc", "offset": 0}, "limit"), # Invalid type
124-
({"limit": 5, "offset": "xyz"}, "offset"), # Invalid type
125-
],
126-
ids=["bad_limit_type", "bad_offset_type"],
127-
)
128-
async def test_list_tasks_invalid_pagination_type(
129-
pagination_override: dict[str, Any], expected_field: str, py_api: httpx.AsyncClient
130-
) -> None:
131-
"""Invalid pagination types return 422 Unprocessable Entity."""
132-
response = await py_api.post(
133-
"/tasks/list",
134-
json={"pagination": pagination_override},
135-
)
136-
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
137-
# Verify that the error points to the correct field
138-
error = response.json()["errors"][0]
139-
assert error["loc"][-2:] == ["pagination", expected_field]
140-
assert error["type"] in {"type_error.integer", "int_parsing", "int_type"}
141-
142-
14377
@pytest.mark.parametrize(
14478
"value",
14579
["1...2", "abc"],

0 commit comments

Comments
 (0)