django-bolt 0.1.0__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-bolt might be problematic. Click here for more details.
- django_bolt/__init__.py +147 -0
- django_bolt/_core.abi3.so +0 -0
- django_bolt/admin/__init__.py +25 -0
- django_bolt/admin/admin_detection.py +179 -0
- django_bolt/admin/asgi_bridge.py +267 -0
- django_bolt/admin/routes.py +91 -0
- django_bolt/admin/static.py +155 -0
- django_bolt/admin/static_routes.py +111 -0
- django_bolt/api.py +1011 -0
- django_bolt/apps.py +7 -0
- django_bolt/async_collector.py +228 -0
- django_bolt/auth/README.md +464 -0
- django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
- django_bolt/auth/__init__.py +84 -0
- django_bolt/auth/backends.py +236 -0
- django_bolt/auth/guards.py +224 -0
- django_bolt/auth/jwt_utils.py +212 -0
- django_bolt/auth/revocation.py +286 -0
- django_bolt/auth/token.py +335 -0
- django_bolt/binding.py +363 -0
- django_bolt/bootstrap.py +77 -0
- django_bolt/cli.py +133 -0
- django_bolt/compression.py +104 -0
- django_bolt/decorators.py +159 -0
- django_bolt/dependencies.py +128 -0
- django_bolt/error_handlers.py +305 -0
- django_bolt/exceptions.py +294 -0
- django_bolt/health.py +129 -0
- django_bolt/logging/__init__.py +6 -0
- django_bolt/logging/config.py +357 -0
- django_bolt/logging/middleware.py +296 -0
- django_bolt/management/__init__.py +1 -0
- django_bolt/management/commands/__init__.py +0 -0
- django_bolt/management/commands/runbolt.py +427 -0
- django_bolt/middleware/__init__.py +32 -0
- django_bolt/middleware/compiler.py +131 -0
- django_bolt/middleware/middleware.py +247 -0
- django_bolt/openapi/__init__.py +23 -0
- django_bolt/openapi/config.py +196 -0
- django_bolt/openapi/plugins.py +439 -0
- django_bolt/openapi/routes.py +152 -0
- django_bolt/openapi/schema_generator.py +581 -0
- django_bolt/openapi/spec/__init__.py +68 -0
- django_bolt/openapi/spec/base.py +74 -0
- django_bolt/openapi/spec/callback.py +24 -0
- django_bolt/openapi/spec/components.py +72 -0
- django_bolt/openapi/spec/contact.py +21 -0
- django_bolt/openapi/spec/discriminator.py +25 -0
- django_bolt/openapi/spec/encoding.py +67 -0
- django_bolt/openapi/spec/enums.py +41 -0
- django_bolt/openapi/spec/example.py +36 -0
- django_bolt/openapi/spec/external_documentation.py +21 -0
- django_bolt/openapi/spec/header.py +132 -0
- django_bolt/openapi/spec/info.py +50 -0
- django_bolt/openapi/spec/license.py +28 -0
- django_bolt/openapi/spec/link.py +66 -0
- django_bolt/openapi/spec/media_type.py +51 -0
- django_bolt/openapi/spec/oauth_flow.py +36 -0
- django_bolt/openapi/spec/oauth_flows.py +28 -0
- django_bolt/openapi/spec/open_api.py +87 -0
- django_bolt/openapi/spec/operation.py +105 -0
- django_bolt/openapi/spec/parameter.py +147 -0
- django_bolt/openapi/spec/path_item.py +78 -0
- django_bolt/openapi/spec/paths.py +27 -0
- django_bolt/openapi/spec/reference.py +38 -0
- django_bolt/openapi/spec/request_body.py +38 -0
- django_bolt/openapi/spec/response.py +48 -0
- django_bolt/openapi/spec/responses.py +44 -0
- django_bolt/openapi/spec/schema.py +678 -0
- django_bolt/openapi/spec/security_requirement.py +28 -0
- django_bolt/openapi/spec/security_scheme.py +69 -0
- django_bolt/openapi/spec/server.py +34 -0
- django_bolt/openapi/spec/server_variable.py +32 -0
- django_bolt/openapi/spec/tag.py +32 -0
- django_bolt/openapi/spec/xml.py +44 -0
- django_bolt/pagination.py +669 -0
- django_bolt/param_functions.py +49 -0
- django_bolt/params.py +337 -0
- django_bolt/request_parsing.py +128 -0
- django_bolt/responses.py +214 -0
- django_bolt/router.py +48 -0
- django_bolt/serialization.py +193 -0
- django_bolt/status_codes.py +321 -0
- django_bolt/testing/__init__.py +10 -0
- django_bolt/testing/client.py +274 -0
- django_bolt/testing/helpers.py +93 -0
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +1 -0
- django_bolt/tests/admin_tests/conftest.py +6 -0
- django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
- django_bolt/tests/admin_tests/urls.py +9 -0
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +570 -0
- django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
- django_bolt/tests/cbv/test_class_views_features.py +1173 -0
- django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
- django_bolt/tests/conftest.py +165 -0
- django_bolt/tests/test_action_decorator.py +399 -0
- django_bolt/tests/test_auth_secret_key.py +83 -0
- django_bolt/tests/test_decorator_syntax.py +159 -0
- django_bolt/tests/test_error_handling.py +481 -0
- django_bolt/tests/test_file_response.py +192 -0
- django_bolt/tests/test_global_cors.py +172 -0
- django_bolt/tests/test_guards_auth.py +441 -0
- django_bolt/tests/test_guards_integration.py +303 -0
- django_bolt/tests/test_health.py +283 -0
- django_bolt/tests/test_integration_validation.py +400 -0
- django_bolt/tests/test_json_validation.py +536 -0
- django_bolt/tests/test_jwt_auth.py +327 -0
- django_bolt/tests/test_jwt_token.py +458 -0
- django_bolt/tests/test_logging.py +837 -0
- django_bolt/tests/test_logging_merge.py +419 -0
- django_bolt/tests/test_middleware.py +492 -0
- django_bolt/tests/test_middleware_server.py +230 -0
- django_bolt/tests/test_model_viewset.py +323 -0
- django_bolt/tests/test_models.py +24 -0
- django_bolt/tests/test_pagination.py +1258 -0
- django_bolt/tests/test_parameter_validation.py +178 -0
- django_bolt/tests/test_syntax.py +626 -0
- django_bolt/tests/test_testing_utilities.py +163 -0
- django_bolt/tests/test_testing_utilities_simple.py +123 -0
- django_bolt/tests/test_viewset_unified.py +346 -0
- django_bolt/typing.py +273 -0
- django_bolt/views.py +1110 -0
- django_bolt-0.1.0.dist-info/METADATA +629 -0
- django_bolt-0.1.0.dist-info/RECORD +128 -0
- django_bolt-0.1.0.dist-info/WHEEL +4 -0
- django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real ORM-based tests for pagination functionality.
|
|
3
|
+
|
|
4
|
+
Tests pagination with actual Django models, database queries, and HTTP requests.
|
|
5
|
+
No mocking - tests the full integration stack.
|
|
6
|
+
"""
|
|
7
|
+
import pytest
|
|
8
|
+
import msgspec
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from django_bolt import (
|
|
12
|
+
BoltAPI,
|
|
13
|
+
ViewSet,
|
|
14
|
+
ModelViewSet,
|
|
15
|
+
PageNumberPagination,
|
|
16
|
+
LimitOffsetPagination,
|
|
17
|
+
CursorPagination,
|
|
18
|
+
paginate,
|
|
19
|
+
)
|
|
20
|
+
from django_bolt.testing import TestClient
|
|
21
|
+
from django_bolt.tests.test_models import Article
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ============================================================================
|
|
25
|
+
# Schemas
|
|
26
|
+
# ============================================================================
|
|
27
|
+
|
|
28
|
+
class ArticleSchema(msgspec.Struct):
|
|
29
|
+
id: int
|
|
30
|
+
title: str
|
|
31
|
+
content: str
|
|
32
|
+
author: str
|
|
33
|
+
is_published: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ArticleListSchema(msgspec.Struct):
|
|
37
|
+
id: int
|
|
38
|
+
title: str
|
|
39
|
+
author: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ============================================================================
|
|
43
|
+
# Test Fixtures
|
|
44
|
+
# ============================================================================
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def sample_articles(db):
|
|
48
|
+
"""Create sample articles in the database"""
|
|
49
|
+
articles = []
|
|
50
|
+
for i in range(1, 51): # Create 50 articles
|
|
51
|
+
article = Article.objects.create(
|
|
52
|
+
title=f"Article {i}",
|
|
53
|
+
content=f"Content for article {i}",
|
|
54
|
+
author=f"Author {i % 10}",
|
|
55
|
+
is_published=i % 2 == 0, # Half published, half not
|
|
56
|
+
)
|
|
57
|
+
articles.append(article)
|
|
58
|
+
return articles
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ============================================================================
|
|
62
|
+
# PageNumberPagination Tests
|
|
63
|
+
# ============================================================================
|
|
64
|
+
|
|
65
|
+
@pytest.mark.django_db(transaction=True)
|
|
66
|
+
def test_page_number_pagination_first_page(sample_articles):
|
|
67
|
+
"""Test PageNumberPagination first page with real ORM"""
|
|
68
|
+
api = BoltAPI()
|
|
69
|
+
|
|
70
|
+
class SmallPagePagination(PageNumberPagination):
|
|
71
|
+
page_size = 10
|
|
72
|
+
|
|
73
|
+
@api.get("/articles")
|
|
74
|
+
@paginate(SmallPagePagination)
|
|
75
|
+
async def list_articles(request):
|
|
76
|
+
return Article.objects.all()
|
|
77
|
+
|
|
78
|
+
with TestClient(api) as client:
|
|
79
|
+
response = client.get("/articles?page=1")
|
|
80
|
+
assert response.status_code == 200
|
|
81
|
+
|
|
82
|
+
data = response.json()
|
|
83
|
+
assert "items" in data
|
|
84
|
+
assert "total" in data
|
|
85
|
+
assert len(data["items"]) == 10
|
|
86
|
+
assert data["total"] == 50
|
|
87
|
+
assert data["page"] == 1
|
|
88
|
+
assert data["page_size"] == 10
|
|
89
|
+
assert data["total_pages"] == 5
|
|
90
|
+
assert data["has_next"] is True
|
|
91
|
+
assert data["has_previous"] is False
|
|
92
|
+
assert data["next_page"] == 2
|
|
93
|
+
assert data["previous_page"] is None
|
|
94
|
+
|
|
95
|
+
# Check first item (ordered by -created_at)
|
|
96
|
+
assert data["items"][0]["title"] == "Article 50"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.django_db(transaction=True)
|
|
100
|
+
def test_page_number_pagination_middle_page(sample_articles):
|
|
101
|
+
"""Test pagination on middle page"""
|
|
102
|
+
api = BoltAPI()
|
|
103
|
+
|
|
104
|
+
class SmallPagePagination(PageNumberPagination):
|
|
105
|
+
page_size = 10
|
|
106
|
+
|
|
107
|
+
@api.get("/articles")
|
|
108
|
+
@paginate(SmallPagePagination)
|
|
109
|
+
async def list_articles(request):
|
|
110
|
+
return Article.objects.all()
|
|
111
|
+
|
|
112
|
+
with TestClient(api) as client:
|
|
113
|
+
response = client.get("/articles?page=3")
|
|
114
|
+
assert response.status_code == 200
|
|
115
|
+
|
|
116
|
+
data = response.json()
|
|
117
|
+
assert data["page"] == 3
|
|
118
|
+
assert len(data["items"]) == 10
|
|
119
|
+
assert data["has_next"] is True
|
|
120
|
+
assert data["has_previous"] is True
|
|
121
|
+
assert data["next_page"] == 4
|
|
122
|
+
assert data["previous_page"] == 2
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.django_db(transaction=True)
|
|
126
|
+
def test_page_number_pagination_last_page(sample_articles):
|
|
127
|
+
"""Test pagination on last page"""
|
|
128
|
+
api = BoltAPI()
|
|
129
|
+
|
|
130
|
+
class SmallPagePagination(PageNumberPagination):
|
|
131
|
+
page_size = 10
|
|
132
|
+
|
|
133
|
+
@api.get("/articles")
|
|
134
|
+
@paginate(SmallPagePagination)
|
|
135
|
+
async def list_articles(request):
|
|
136
|
+
return Article.objects.all()
|
|
137
|
+
|
|
138
|
+
with TestClient(api) as client:
|
|
139
|
+
response = client.get("/articles?page=5")
|
|
140
|
+
assert response.status_code == 200
|
|
141
|
+
|
|
142
|
+
data = response.json()
|
|
143
|
+
assert data["page"] == 5
|
|
144
|
+
assert len(data["items"]) == 10
|
|
145
|
+
assert data["has_next"] is False
|
|
146
|
+
assert data["has_previous"] is True
|
|
147
|
+
assert data["next_page"] is None
|
|
148
|
+
assert data["previous_page"] == 4
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.django_db(transaction=True)
|
|
152
|
+
def test_page_number_pagination_custom_page_size(sample_articles):
|
|
153
|
+
"""Test pagination with custom page size via query param"""
|
|
154
|
+
api = BoltAPI()
|
|
155
|
+
|
|
156
|
+
class CustomPagination(PageNumberPagination):
|
|
157
|
+
page_size = 10
|
|
158
|
+
page_size_query_param = "page_size"
|
|
159
|
+
|
|
160
|
+
@api.get("/articles")
|
|
161
|
+
@paginate(CustomPagination)
|
|
162
|
+
async def list_articles(request):
|
|
163
|
+
return Article.objects.all()
|
|
164
|
+
|
|
165
|
+
with TestClient(api) as client:
|
|
166
|
+
response = client.get("/articles?page=1&page_size=20")
|
|
167
|
+
assert response.status_code == 200
|
|
168
|
+
|
|
169
|
+
data = response.json()
|
|
170
|
+
assert len(data["items"]) == 20
|
|
171
|
+
assert data["page_size"] == 20
|
|
172
|
+
assert data["total_pages"] == 3
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.django_db(transaction=True)
|
|
176
|
+
def test_page_number_pagination_empty_results(db):
|
|
177
|
+
"""Test pagination with no results"""
|
|
178
|
+
api = BoltAPI()
|
|
179
|
+
|
|
180
|
+
@api.get("/articles")
|
|
181
|
+
@paginate(PageNumberPagination)
|
|
182
|
+
async def list_articles(request):
|
|
183
|
+
return Article.objects.all()
|
|
184
|
+
|
|
185
|
+
with TestClient(api) as client:
|
|
186
|
+
response = client.get("/articles?page=1")
|
|
187
|
+
assert response.status_code == 200
|
|
188
|
+
|
|
189
|
+
data = response.json()
|
|
190
|
+
assert len(data["items"]) == 0
|
|
191
|
+
assert data["total"] == 0
|
|
192
|
+
assert data["total_pages"] == 0
|
|
193
|
+
assert data["has_next"] is False
|
|
194
|
+
assert data["has_previous"] is False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.django_db(transaction=True)
|
|
198
|
+
def test_page_number_pagination_with_filtering(sample_articles):
|
|
199
|
+
"""Test pagination with queryset filtering"""
|
|
200
|
+
api = BoltAPI()
|
|
201
|
+
|
|
202
|
+
class SmallPagePagination(PageNumberPagination):
|
|
203
|
+
page_size = 10
|
|
204
|
+
|
|
205
|
+
@api.get("/articles")
|
|
206
|
+
@paginate(SmallPagePagination)
|
|
207
|
+
async def list_articles(request, is_published: bool = None):
|
|
208
|
+
qs = Article.objects.all()
|
|
209
|
+
if is_published is not None:
|
|
210
|
+
qs = qs.filter(is_published=is_published)
|
|
211
|
+
return qs
|
|
212
|
+
|
|
213
|
+
with TestClient(api) as client:
|
|
214
|
+
response = client.get("/articles?is_published=true&page=1")
|
|
215
|
+
assert response.status_code == 200
|
|
216
|
+
|
|
217
|
+
data = response.json()
|
|
218
|
+
assert data["total"] == 25 # Half are published
|
|
219
|
+
assert len(data["items"]) == 10
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.django_db(transaction=True)
|
|
223
|
+
def test_page_number_pagination_with_ordering(sample_articles):
|
|
224
|
+
"""Test pagination respects queryset ordering"""
|
|
225
|
+
api = BoltAPI()
|
|
226
|
+
|
|
227
|
+
class SmallPagePagination(PageNumberPagination):
|
|
228
|
+
page_size = 10
|
|
229
|
+
|
|
230
|
+
@api.get("/articles")
|
|
231
|
+
@paginate(SmallPagePagination)
|
|
232
|
+
async def list_articles(request):
|
|
233
|
+
return Article.objects.order_by("title")
|
|
234
|
+
|
|
235
|
+
with TestClient(api) as client:
|
|
236
|
+
response = client.get("/articles?page=1")
|
|
237
|
+
assert response.status_code == 200
|
|
238
|
+
|
|
239
|
+
data = response.json()
|
|
240
|
+
# Alphabetical ordering: Article 1, Article 10, Article 11, ..., Article 19, Article 2, ...
|
|
241
|
+
assert data["items"][0]["title"] == "Article 1"
|
|
242
|
+
assert data["items"][1]["title"] == "Article 10"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ============================================================================
|
|
246
|
+
# LimitOffsetPagination Tests
|
|
247
|
+
# ============================================================================
|
|
248
|
+
|
|
249
|
+
@pytest.mark.django_db(transaction=True)
|
|
250
|
+
def test_limit_offset_pagination_basic(sample_articles):
|
|
251
|
+
"""Test LimitOffsetPagination with real ORM"""
|
|
252
|
+
api = BoltAPI()
|
|
253
|
+
|
|
254
|
+
@api.get("/articles")
|
|
255
|
+
@paginate(LimitOffsetPagination)
|
|
256
|
+
async def list_articles(request):
|
|
257
|
+
return Article.objects.all()
|
|
258
|
+
|
|
259
|
+
with TestClient(api) as client:
|
|
260
|
+
response = client.get("/articles?limit=10&offset=0")
|
|
261
|
+
assert response.status_code == 200
|
|
262
|
+
|
|
263
|
+
data = response.json()
|
|
264
|
+
assert len(data["items"]) == 10
|
|
265
|
+
assert data["total"] == 50
|
|
266
|
+
assert data["limit"] == 10
|
|
267
|
+
assert data["offset"] == 0
|
|
268
|
+
assert data["has_next"] is True
|
|
269
|
+
assert data["has_previous"] is False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@pytest.mark.django_db(transaction=True)
|
|
273
|
+
def test_limit_offset_pagination_with_offset(sample_articles):
|
|
274
|
+
"""Test limit-offset with specific offset"""
|
|
275
|
+
api = BoltAPI()
|
|
276
|
+
|
|
277
|
+
@api.get("/articles")
|
|
278
|
+
@paginate(LimitOffsetPagination)
|
|
279
|
+
async def list_articles(request):
|
|
280
|
+
return Article.objects.all()
|
|
281
|
+
|
|
282
|
+
with TestClient(api) as client:
|
|
283
|
+
response = client.get("/articles?limit=10&offset=20")
|
|
284
|
+
assert response.status_code == 200
|
|
285
|
+
|
|
286
|
+
data = response.json()
|
|
287
|
+
assert len(data["items"]) == 10
|
|
288
|
+
assert data["limit"] == 10
|
|
289
|
+
assert data["offset"] == 20
|
|
290
|
+
assert data["has_next"] is True
|
|
291
|
+
assert data["has_previous"] is True
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@pytest.mark.django_db(transaction=True)
|
|
295
|
+
def test_limit_offset_pagination_last_page(sample_articles):
|
|
296
|
+
"""Test limit-offset on last page"""
|
|
297
|
+
api = BoltAPI()
|
|
298
|
+
|
|
299
|
+
@api.get("/articles")
|
|
300
|
+
@paginate(LimitOffsetPagination)
|
|
301
|
+
async def list_articles(request):
|
|
302
|
+
return Article.objects.all()
|
|
303
|
+
|
|
304
|
+
with TestClient(api) as client:
|
|
305
|
+
response = client.get("/articles?limit=10&offset=45")
|
|
306
|
+
assert response.status_code == 200
|
|
307
|
+
|
|
308
|
+
data = response.json()
|
|
309
|
+
assert len(data["items"]) == 5 # Only 5 items left
|
|
310
|
+
assert data["has_next"] is False
|
|
311
|
+
assert data["has_previous"] is True
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@pytest.mark.django_db(transaction=True)
|
|
315
|
+
def test_limit_offset_pagination_default_limit(sample_articles):
|
|
316
|
+
"""Test default limit when not specified"""
|
|
317
|
+
api = BoltAPI()
|
|
318
|
+
|
|
319
|
+
class CustomLimitOffset(LimitOffsetPagination):
|
|
320
|
+
page_size = 15
|
|
321
|
+
|
|
322
|
+
@api.get("/articles")
|
|
323
|
+
@paginate(CustomLimitOffset)
|
|
324
|
+
async def list_articles(request):
|
|
325
|
+
return Article.objects.all()
|
|
326
|
+
|
|
327
|
+
with TestClient(api) as client:
|
|
328
|
+
response = client.get("/articles")
|
|
329
|
+
assert response.status_code == 200
|
|
330
|
+
|
|
331
|
+
data = response.json()
|
|
332
|
+
assert len(data["items"]) == 15
|
|
333
|
+
assert data["limit"] == 15
|
|
334
|
+
assert data["offset"] == 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@pytest.mark.django_db(transaction=True)
|
|
338
|
+
def test_limit_offset_pagination_max_limit_enforcement(sample_articles):
|
|
339
|
+
"""Test that max_page_size is enforced"""
|
|
340
|
+
api = BoltAPI()
|
|
341
|
+
|
|
342
|
+
class LimitedOffsetPagination(LimitOffsetPagination):
|
|
343
|
+
page_size = 10
|
|
344
|
+
max_page_size = 25
|
|
345
|
+
|
|
346
|
+
@api.get("/articles")
|
|
347
|
+
@paginate(LimitedOffsetPagination)
|
|
348
|
+
async def list_articles(request):
|
|
349
|
+
return Article.objects.all()
|
|
350
|
+
|
|
351
|
+
with TestClient(api) as client:
|
|
352
|
+
response = client.get("/articles?limit=100&offset=0")
|
|
353
|
+
assert response.status_code == 200
|
|
354
|
+
|
|
355
|
+
data = response.json()
|
|
356
|
+
assert data["limit"] == 25 # Clamped to max
|
|
357
|
+
assert len(data["items"]) == 25
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@pytest.mark.django_db(transaction=True)
|
|
361
|
+
def test_limit_offset_pagination_with_filtering(sample_articles):
|
|
362
|
+
"""Test limit-offset pagination with filtering"""
|
|
363
|
+
api = BoltAPI()
|
|
364
|
+
|
|
365
|
+
@api.get("/articles")
|
|
366
|
+
@paginate(LimitOffsetPagination)
|
|
367
|
+
async def list_articles(request, author: str = None):
|
|
368
|
+
qs = Article.objects.all()
|
|
369
|
+
if author:
|
|
370
|
+
qs = qs.filter(author=author)
|
|
371
|
+
return qs
|
|
372
|
+
|
|
373
|
+
with TestClient(api) as client:
|
|
374
|
+
response = client.get("/articles?limit=10&offset=0&author=Author 1")
|
|
375
|
+
assert response.status_code == 200
|
|
376
|
+
|
|
377
|
+
data = response.json()
|
|
378
|
+
# Author 1 appears at indices: 1, 11, 21, 31, 41 (5 total)
|
|
379
|
+
assert data["total"] == 5
|
|
380
|
+
assert len(data["items"]) == 5
|
|
381
|
+
assert all(item["author"] == "Author 1" for item in data["items"])
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@pytest.mark.django_db(transaction=True)
|
|
385
|
+
def test_limit_offset_pagination_with_ordering(sample_articles):
|
|
386
|
+
"""Test limit-offset respects queryset ordering"""
|
|
387
|
+
api = BoltAPI()
|
|
388
|
+
|
|
389
|
+
@api.get("/articles")
|
|
390
|
+
@paginate(LimitOffsetPagination)
|
|
391
|
+
async def list_articles(request):
|
|
392
|
+
return Article.objects.order_by("id")
|
|
393
|
+
|
|
394
|
+
with TestClient(api) as client:
|
|
395
|
+
response = client.get("/articles?limit=5&offset=0")
|
|
396
|
+
assert response.status_code == 200
|
|
397
|
+
|
|
398
|
+
data = response.json()
|
|
399
|
+
# Should get Article 1-5 in ascending ID order
|
|
400
|
+
assert data["items"][0]["title"] == "Article 1"
|
|
401
|
+
assert data["items"][4]["title"] == "Article 5"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@pytest.mark.django_db(transaction=True)
|
|
405
|
+
def test_limit_offset_pagination_empty_results(db):
|
|
406
|
+
"""Test limit-offset with no results"""
|
|
407
|
+
api = BoltAPI()
|
|
408
|
+
|
|
409
|
+
@api.get("/articles")
|
|
410
|
+
@paginate(LimitOffsetPagination)
|
|
411
|
+
async def list_articles(request):
|
|
412
|
+
return Article.objects.all()
|
|
413
|
+
|
|
414
|
+
with TestClient(api) as client:
|
|
415
|
+
response = client.get("/articles?limit=10&offset=0")
|
|
416
|
+
assert response.status_code == 200
|
|
417
|
+
|
|
418
|
+
data = response.json()
|
|
419
|
+
assert len(data["items"]) == 0
|
|
420
|
+
assert data["total"] == 0
|
|
421
|
+
assert data["has_next"] is False
|
|
422
|
+
assert data["has_previous"] is False
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@pytest.mark.django_db(transaction=True)
|
|
426
|
+
def test_limit_offset_pagination_offset_beyond_total(sample_articles):
|
|
427
|
+
"""Test offset beyond total results"""
|
|
428
|
+
api = BoltAPI()
|
|
429
|
+
|
|
430
|
+
@api.get("/articles")
|
|
431
|
+
@paginate(LimitOffsetPagination)
|
|
432
|
+
async def list_articles(request):
|
|
433
|
+
return Article.objects.all()
|
|
434
|
+
|
|
435
|
+
with TestClient(api) as client:
|
|
436
|
+
response = client.get("/articles?limit=10&offset=100")
|
|
437
|
+
assert response.status_code == 200
|
|
438
|
+
|
|
439
|
+
data = response.json()
|
|
440
|
+
assert len(data["items"]) == 0
|
|
441
|
+
assert data["total"] == 50
|
|
442
|
+
assert data["has_next"] is False
|
|
443
|
+
assert data["has_previous"] is True
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ============================================================================
|
|
447
|
+
# CursorPagination Tests
|
|
448
|
+
# ============================================================================
|
|
449
|
+
|
|
450
|
+
@pytest.mark.django_db(transaction=True)
|
|
451
|
+
def test_cursor_pagination_first_page(sample_articles):
|
|
452
|
+
"""Test cursor pagination first page"""
|
|
453
|
+
api = BoltAPI()
|
|
454
|
+
|
|
455
|
+
class SmallCursorPagination(CursorPagination):
|
|
456
|
+
page_size = 10
|
|
457
|
+
ordering = "-id"
|
|
458
|
+
|
|
459
|
+
@api.get("/articles")
|
|
460
|
+
@paginate(SmallCursorPagination)
|
|
461
|
+
async def list_articles(request):
|
|
462
|
+
return Article.objects.all()
|
|
463
|
+
|
|
464
|
+
with TestClient(api) as client:
|
|
465
|
+
response = client.get("/articles")
|
|
466
|
+
assert response.status_code == 200
|
|
467
|
+
|
|
468
|
+
data = response.json()
|
|
469
|
+
assert len(data["items"]) == 10
|
|
470
|
+
assert data["page_size"] == 10
|
|
471
|
+
assert data["has_next"] is True
|
|
472
|
+
assert data["has_previous"] is False
|
|
473
|
+
assert data["next_cursor"] is not None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@pytest.mark.django_db(transaction=True)
|
|
477
|
+
def test_cursor_pagination_with_cursor(sample_articles):
|
|
478
|
+
"""Test cursor pagination with cursor value"""
|
|
479
|
+
api = BoltAPI()
|
|
480
|
+
|
|
481
|
+
class SmallCursorPagination(CursorPagination):
|
|
482
|
+
page_size = 10
|
|
483
|
+
ordering = "-id"
|
|
484
|
+
|
|
485
|
+
@api.get("/articles")
|
|
486
|
+
@paginate(SmallCursorPagination)
|
|
487
|
+
async def list_articles(request):
|
|
488
|
+
return Article.objects.all()
|
|
489
|
+
|
|
490
|
+
with TestClient(api) as client:
|
|
491
|
+
# Get first page
|
|
492
|
+
response1 = client.get("/articles")
|
|
493
|
+
assert response1.status_code == 200
|
|
494
|
+
data1 = response1.json()
|
|
495
|
+
next_cursor = data1["next_cursor"]
|
|
496
|
+
|
|
497
|
+
# Get second page using cursor
|
|
498
|
+
response2 = client.get(f"/articles?cursor={next_cursor}")
|
|
499
|
+
assert response2.status_code == 200
|
|
500
|
+
|
|
501
|
+
data2 = response2.json()
|
|
502
|
+
assert len(data2["items"]) == 10
|
|
503
|
+
assert data2["has_previous"] is True
|
|
504
|
+
|
|
505
|
+
# Ensure items are different
|
|
506
|
+
items1_ids = {item["id"] for item in data1["items"]}
|
|
507
|
+
items2_ids = {item["id"] for item in data2["items"]}
|
|
508
|
+
assert items1_ids.isdisjoint(items2_ids)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@pytest.mark.django_db(transaction=True)
|
|
512
|
+
def test_cursor_pagination_ascending_order(sample_articles):
|
|
513
|
+
"""Test cursor pagination with ascending order"""
|
|
514
|
+
api = BoltAPI()
|
|
515
|
+
|
|
516
|
+
class AscendingCursorPagination(CursorPagination):
|
|
517
|
+
page_size = 10
|
|
518
|
+
ordering = "id" # Ascending
|
|
519
|
+
|
|
520
|
+
@api.get("/articles")
|
|
521
|
+
@paginate(AscendingCursorPagination)
|
|
522
|
+
async def list_articles(request):
|
|
523
|
+
return Article.objects.all()
|
|
524
|
+
|
|
525
|
+
with TestClient(api) as client:
|
|
526
|
+
response = client.get("/articles")
|
|
527
|
+
assert response.status_code == 200
|
|
528
|
+
|
|
529
|
+
data = response.json()
|
|
530
|
+
assert len(data["items"]) == 10
|
|
531
|
+
# First item should have lowest ID
|
|
532
|
+
assert data["items"][0]["title"] == "Article 1"
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@pytest.mark.django_db(transaction=True)
|
|
536
|
+
def test_cursor_pagination_last_page(sample_articles):
|
|
537
|
+
"""Test cursor pagination on last page"""
|
|
538
|
+
api = BoltAPI()
|
|
539
|
+
|
|
540
|
+
class SmallCursorPagination(CursorPagination):
|
|
541
|
+
page_size = 10
|
|
542
|
+
ordering = "-id"
|
|
543
|
+
|
|
544
|
+
@api.get("/articles")
|
|
545
|
+
@paginate(SmallCursorPagination)
|
|
546
|
+
async def list_articles(request):
|
|
547
|
+
return Article.objects.all()
|
|
548
|
+
|
|
549
|
+
with TestClient(api) as client:
|
|
550
|
+
# Navigate through pages to get to last
|
|
551
|
+
cursor = None
|
|
552
|
+
page_count = 0
|
|
553
|
+
|
|
554
|
+
while True:
|
|
555
|
+
url = "/articles" if cursor is None else f"/articles?cursor={cursor}"
|
|
556
|
+
response = client.get(url)
|
|
557
|
+
assert response.status_code == 200
|
|
558
|
+
|
|
559
|
+
data = response.json()
|
|
560
|
+
page_count += 1
|
|
561
|
+
|
|
562
|
+
if not data["has_next"]:
|
|
563
|
+
# Last page
|
|
564
|
+
assert data["has_previous"] is True
|
|
565
|
+
assert data["next_cursor"] is None
|
|
566
|
+
break
|
|
567
|
+
|
|
568
|
+
cursor = data["next_cursor"]
|
|
569
|
+
|
|
570
|
+
# Safety check to prevent infinite loop
|
|
571
|
+
if page_count > 10:
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
assert page_count == 5 # 50 items / 10 per page
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@pytest.mark.django_db(transaction=True)
|
|
578
|
+
def test_cursor_pagination_with_filtering(sample_articles):
|
|
579
|
+
"""Test cursor pagination with filtering"""
|
|
580
|
+
api = BoltAPI()
|
|
581
|
+
|
|
582
|
+
class SmallCursorPagination(CursorPagination):
|
|
583
|
+
page_size = 3
|
|
584
|
+
ordering = "-id"
|
|
585
|
+
|
|
586
|
+
@api.get("/articles")
|
|
587
|
+
@paginate(SmallCursorPagination)
|
|
588
|
+
async def list_articles(request, is_published: bool = None):
|
|
589
|
+
qs = Article.objects.all()
|
|
590
|
+
if is_published is not None:
|
|
591
|
+
qs = qs.filter(is_published=is_published)
|
|
592
|
+
return qs
|
|
593
|
+
|
|
594
|
+
with TestClient(api) as client:
|
|
595
|
+
response = client.get("/articles?is_published=true")
|
|
596
|
+
assert response.status_code == 200
|
|
597
|
+
|
|
598
|
+
data = response.json()
|
|
599
|
+
assert len(data["items"]) == 3
|
|
600
|
+
assert all(item["is_published"] for item in data["items"])
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@pytest.mark.django_db(transaction=True)
|
|
604
|
+
def test_cursor_pagination_custom_page_size(sample_articles):
|
|
605
|
+
"""Test cursor pagination with custom page size"""
|
|
606
|
+
api = BoltAPI()
|
|
607
|
+
|
|
608
|
+
class CustomCursorPagination(CursorPagination):
|
|
609
|
+
page_size = 10
|
|
610
|
+
page_size_query_param = "page_size"
|
|
611
|
+
max_page_size = 30
|
|
612
|
+
ordering = "-id"
|
|
613
|
+
|
|
614
|
+
@api.get("/articles")
|
|
615
|
+
@paginate(CustomCursorPagination)
|
|
616
|
+
async def list_articles(request):
|
|
617
|
+
return Article.objects.all()
|
|
618
|
+
|
|
619
|
+
with TestClient(api) as client:
|
|
620
|
+
response = client.get("/articles?page_size=20")
|
|
621
|
+
assert response.status_code == 200
|
|
622
|
+
|
|
623
|
+
data = response.json()
|
|
624
|
+
assert len(data["items"]) == 20
|
|
625
|
+
assert data["page_size"] == 20
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
@pytest.mark.django_db(transaction=True)
|
|
629
|
+
def test_cursor_pagination_max_page_size_enforcement(sample_articles):
|
|
630
|
+
"""Test cursor pagination enforces max page size"""
|
|
631
|
+
api = BoltAPI()
|
|
632
|
+
|
|
633
|
+
class LimitedCursorPagination(CursorPagination):
|
|
634
|
+
page_size = 10
|
|
635
|
+
page_size_query_param = "page_size"
|
|
636
|
+
max_page_size = 15
|
|
637
|
+
ordering = "-id"
|
|
638
|
+
|
|
639
|
+
@api.get("/articles")
|
|
640
|
+
@paginate(LimitedCursorPagination)
|
|
641
|
+
async def list_articles(request):
|
|
642
|
+
return Article.objects.all()
|
|
643
|
+
|
|
644
|
+
with TestClient(api) as client:
|
|
645
|
+
response = client.get("/articles?page_size=50")
|
|
646
|
+
assert response.status_code == 200
|
|
647
|
+
|
|
648
|
+
data = response.json()
|
|
649
|
+
assert data["page_size"] == 15 # Clamped to max
|
|
650
|
+
assert len(data["items"]) == 15
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@pytest.mark.django_db(transaction=True)
|
|
654
|
+
def test_cursor_pagination_empty_results(db):
|
|
655
|
+
"""Test cursor pagination with no results"""
|
|
656
|
+
api = BoltAPI()
|
|
657
|
+
|
|
658
|
+
class SmallCursorPagination(CursorPagination):
|
|
659
|
+
page_size = 10
|
|
660
|
+
ordering = "-id"
|
|
661
|
+
|
|
662
|
+
@api.get("/articles")
|
|
663
|
+
@paginate(SmallCursorPagination)
|
|
664
|
+
async def list_articles(request):
|
|
665
|
+
return Article.objects.all()
|
|
666
|
+
|
|
667
|
+
with TestClient(api) as client:
|
|
668
|
+
response = client.get("/articles")
|
|
669
|
+
assert response.status_code == 200
|
|
670
|
+
|
|
671
|
+
data = response.json()
|
|
672
|
+
assert len(data["items"]) == 0
|
|
673
|
+
assert data["has_next"] is False
|
|
674
|
+
assert data["has_previous"] is False
|
|
675
|
+
assert data["next_cursor"] is None
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@pytest.mark.django_db(transaction=True)
|
|
679
|
+
def test_cursor_pagination_ordering_by_title(sample_articles):
|
|
680
|
+
"""Test cursor pagination with custom ordering field"""
|
|
681
|
+
api = BoltAPI()
|
|
682
|
+
|
|
683
|
+
class TitleOrderedPagination(CursorPagination):
|
|
684
|
+
page_size = 10
|
|
685
|
+
ordering = "title" # Order by title
|
|
686
|
+
|
|
687
|
+
@api.get("/articles")
|
|
688
|
+
@paginate(TitleOrderedPagination)
|
|
689
|
+
async def list_articles(request):
|
|
690
|
+
return Article.objects.all()
|
|
691
|
+
|
|
692
|
+
with TestClient(api) as client:
|
|
693
|
+
response = client.get("/articles")
|
|
694
|
+
assert response.status_code == 200
|
|
695
|
+
|
|
696
|
+
data = response.json()
|
|
697
|
+
assert len(data["items"]) == 10
|
|
698
|
+
# First item should be "Article 1" (alphabetically first)
|
|
699
|
+
assert data["items"][0]["title"] == "Article 1"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@pytest.mark.django_db(transaction=True)
|
|
703
|
+
def test_cursor_pagination_multiple_pages_continuity(sample_articles):
|
|
704
|
+
"""Test that cursor pagination maintains continuity across pages"""
|
|
705
|
+
api = BoltAPI()
|
|
706
|
+
|
|
707
|
+
class SmallCursorPagination(CursorPagination):
|
|
708
|
+
page_size = 10
|
|
709
|
+
ordering = "-id"
|
|
710
|
+
|
|
711
|
+
@api.get("/articles")
|
|
712
|
+
@paginate(SmallCursorPagination)
|
|
713
|
+
async def list_articles(request):
|
|
714
|
+
return Article.objects.all()
|
|
715
|
+
|
|
716
|
+
with TestClient(api) as client:
|
|
717
|
+
# Get first page
|
|
718
|
+
response1 = client.get("/articles")
|
|
719
|
+
data1 = response1.json()
|
|
720
|
+
|
|
721
|
+
# Get second page
|
|
722
|
+
response2 = client.get(f"/articles?cursor={data1['next_cursor']}")
|
|
723
|
+
data2 = response2.json()
|
|
724
|
+
|
|
725
|
+
# Get third page
|
|
726
|
+
response3 = client.get(f"/articles?cursor={data2['next_cursor']}")
|
|
727
|
+
data3 = response3.json()
|
|
728
|
+
|
|
729
|
+
# Collect all IDs
|
|
730
|
+
all_ids = []
|
|
731
|
+
all_ids.extend([item["id"] for item in data1["items"]])
|
|
732
|
+
all_ids.extend([item["id"] for item in data2["items"]])
|
|
733
|
+
all_ids.extend([item["id"] for item in data3["items"]])
|
|
734
|
+
|
|
735
|
+
# Ensure no duplicates
|
|
736
|
+
assert len(all_ids) == len(set(all_ids))
|
|
737
|
+
|
|
738
|
+
# Ensure ordering is maintained (descending IDs)
|
|
739
|
+
assert all_ids == sorted(all_ids, reverse=True)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# ============================================================================
|
|
743
|
+
# ViewSet Integration Tests
|
|
744
|
+
# ============================================================================
|
|
745
|
+
|
|
746
|
+
@pytest.mark.django_db(transaction=True)
|
|
747
|
+
def test_viewset_with_pagination(sample_articles):
|
|
748
|
+
"""Test ViewSet with @paginate decorator"""
|
|
749
|
+
api = BoltAPI()
|
|
750
|
+
|
|
751
|
+
class SmallPagePagination(PageNumberPagination):
|
|
752
|
+
page_size = 10
|
|
753
|
+
|
|
754
|
+
# Use @paginate decorator on ViewSet method
|
|
755
|
+
@api.viewset("/articles")
|
|
756
|
+
class ArticleViewSet(ViewSet):
|
|
757
|
+
queryset = Article.objects.all()
|
|
758
|
+
|
|
759
|
+
@paginate(SmallPagePagination)
|
|
760
|
+
async def list(self, request):
|
|
761
|
+
return await self.get_queryset()
|
|
762
|
+
|
|
763
|
+
with TestClient(api) as client:
|
|
764
|
+
response = client.get("/articles?page=2")
|
|
765
|
+
assert response.status_code == 200
|
|
766
|
+
|
|
767
|
+
data = response.json()
|
|
768
|
+
assert data["page"] == 2
|
|
769
|
+
assert len(data["items"]) == 10
|
|
770
|
+
assert data["total"] == 50
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
@pytest.mark.django_db(transaction=True)
|
|
774
|
+
def test_viewset_with_limit_offset_pagination(sample_articles):
|
|
775
|
+
"""Test ViewSet with LimitOffsetPagination"""
|
|
776
|
+
api = BoltAPI()
|
|
777
|
+
|
|
778
|
+
@api.viewset("/articles")
|
|
779
|
+
class ArticleViewSet(ViewSet):
|
|
780
|
+
queryset = Article.objects.all()
|
|
781
|
+
|
|
782
|
+
@paginate(LimitOffsetPagination)
|
|
783
|
+
async def list(self, request):
|
|
784
|
+
return await self.get_queryset()
|
|
785
|
+
|
|
786
|
+
with TestClient(api) as client:
|
|
787
|
+
response = client.get("/articles?limit=15&offset=10")
|
|
788
|
+
assert response.status_code == 200
|
|
789
|
+
|
|
790
|
+
data = response.json()
|
|
791
|
+
assert data["limit"] == 15
|
|
792
|
+
assert data["offset"] == 10
|
|
793
|
+
assert len(data["items"]) == 15
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@pytest.mark.django_db(transaction=True)
|
|
797
|
+
def test_viewset_with_cursor_pagination(sample_articles):
|
|
798
|
+
"""Test ViewSet with CursorPagination"""
|
|
799
|
+
api = BoltAPI()
|
|
800
|
+
|
|
801
|
+
class SmallCursorPagination(CursorPagination):
|
|
802
|
+
page_size = 10
|
|
803
|
+
ordering = "-id"
|
|
804
|
+
|
|
805
|
+
@api.viewset("/articles")
|
|
806
|
+
class ArticleViewSet(ViewSet):
|
|
807
|
+
queryset = Article.objects.all()
|
|
808
|
+
|
|
809
|
+
@paginate(SmallCursorPagination)
|
|
810
|
+
async def list(self, request):
|
|
811
|
+
return await self.get_queryset()
|
|
812
|
+
|
|
813
|
+
with TestClient(api) as client:
|
|
814
|
+
response = client.get("/articles")
|
|
815
|
+
assert response.status_code == 200
|
|
816
|
+
|
|
817
|
+
data = response.json()
|
|
818
|
+
assert len(data["items"]) == 10
|
|
819
|
+
assert data["has_next"] is True
|
|
820
|
+
assert data["next_cursor"] is not None
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@pytest.mark.django_db(transaction=True)
|
|
824
|
+
def test_viewset_without_pagination(sample_articles):
|
|
825
|
+
"""Test ViewSet without pagination returns all results"""
|
|
826
|
+
api = BoltAPI()
|
|
827
|
+
|
|
828
|
+
@api.viewset("/articles")
|
|
829
|
+
class ArticleViewSet(ViewSet):
|
|
830
|
+
queryset = Article.objects.all()
|
|
831
|
+
pagination_class = None
|
|
832
|
+
|
|
833
|
+
async def list(self, request):
|
|
834
|
+
qs = await self.get_queryset()
|
|
835
|
+
result = await self.paginate_queryset(qs)
|
|
836
|
+
|
|
837
|
+
articles = []
|
|
838
|
+
async for article in result:
|
|
839
|
+
articles.append(ArticleListSchema(
|
|
840
|
+
id=article.id,
|
|
841
|
+
title=article.title,
|
|
842
|
+
author=article.author
|
|
843
|
+
))
|
|
844
|
+
return articles
|
|
845
|
+
|
|
846
|
+
with TestClient(api) as client:
|
|
847
|
+
response = client.get("/articles")
|
|
848
|
+
assert response.status_code == 200
|
|
849
|
+
|
|
850
|
+
data = response.json()
|
|
851
|
+
assert isinstance(data, list)
|
|
852
|
+
assert len(data) == 50
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
@pytest.mark.django_db(transaction=True)
|
|
856
|
+
def test_modelviewset_basic_list(sample_articles):
|
|
857
|
+
"""Test ModelViewSet basic list operation (pagination can be added manually)"""
|
|
858
|
+
api = BoltAPI()
|
|
859
|
+
|
|
860
|
+
@api.viewset("/articles")
|
|
861
|
+
class ArticleViewSet(ModelViewSet):
|
|
862
|
+
queryset = Article.objects.all()
|
|
863
|
+
serializer_class = ArticleSchema
|
|
864
|
+
list_serializer_class = ArticleListSchema
|
|
865
|
+
|
|
866
|
+
with TestClient(api) as client:
|
|
867
|
+
response = client.get("/articles")
|
|
868
|
+
assert response.status_code == 200
|
|
869
|
+
|
|
870
|
+
data = response.json()
|
|
871
|
+
assert isinstance(data, list)
|
|
872
|
+
assert len(data) == 50 # All articles returned (no pagination)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# ============================================================================
|
|
876
|
+
# Edge Cases and Validation
|
|
877
|
+
# ============================================================================
|
|
878
|
+
|
|
879
|
+
@pytest.mark.django_db(transaction=True)
|
|
880
|
+
def test_pagination_max_page_size_enforcement(sample_articles):
|
|
881
|
+
"""Test that max_page_size is enforced"""
|
|
882
|
+
api = BoltAPI()
|
|
883
|
+
|
|
884
|
+
class LimitedPagination(PageNumberPagination):
|
|
885
|
+
page_size = 10
|
|
886
|
+
max_page_size = 25
|
|
887
|
+
page_size_query_param = "page_size"
|
|
888
|
+
|
|
889
|
+
@api.get("/articles")
|
|
890
|
+
@paginate(LimitedPagination)
|
|
891
|
+
async def list_articles(request):
|
|
892
|
+
return Article.objects.all()
|
|
893
|
+
|
|
894
|
+
with TestClient(api) as client:
|
|
895
|
+
response = client.get("/articles?page=1&page_size=100")
|
|
896
|
+
assert response.status_code == 200
|
|
897
|
+
|
|
898
|
+
data = response.json()
|
|
899
|
+
assert data["page_size"] == 25 # Clamped to max
|
|
900
|
+
assert len(data["items"]) == 25
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
@pytest.mark.django_db(transaction=True)
|
|
904
|
+
def test_pagination_invalid_page_defaults_to_first(sample_articles):
|
|
905
|
+
"""Test that invalid page number defaults to page 1"""
|
|
906
|
+
api = BoltAPI()
|
|
907
|
+
|
|
908
|
+
class SmallPagePagination(PageNumberPagination):
|
|
909
|
+
page_size = 10
|
|
910
|
+
|
|
911
|
+
@api.get("/articles")
|
|
912
|
+
@paginate(SmallPagePagination)
|
|
913
|
+
async def list_articles(request):
|
|
914
|
+
return Article.objects.all()
|
|
915
|
+
|
|
916
|
+
with TestClient(api) as client:
|
|
917
|
+
response = client.get("/articles?page=invalid")
|
|
918
|
+
assert response.status_code == 200
|
|
919
|
+
|
|
920
|
+
data = response.json()
|
|
921
|
+
assert data["page"] == 1
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@pytest.mark.django_db(transaction=True)
|
|
925
|
+
def test_pagination_page_exceeds_total_clamps_to_last(sample_articles):
|
|
926
|
+
"""Test that page number exceeding total pages clamps to last page"""
|
|
927
|
+
api = BoltAPI()
|
|
928
|
+
|
|
929
|
+
class SmallPagePagination(PageNumberPagination):
|
|
930
|
+
page_size = 10
|
|
931
|
+
|
|
932
|
+
@api.get("/articles")
|
|
933
|
+
@paginate(SmallPagePagination)
|
|
934
|
+
async def list_articles(request):
|
|
935
|
+
return Article.objects.all()
|
|
936
|
+
|
|
937
|
+
with TestClient(api) as client:
|
|
938
|
+
response = client.get("/articles?page=999")
|
|
939
|
+
assert response.status_code == 200
|
|
940
|
+
|
|
941
|
+
data = response.json()
|
|
942
|
+
assert data["page"] == 5 # Last page
|
|
943
|
+
assert len(data["items"]) == 10
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@pytest.mark.django_db(transaction=True)
|
|
947
|
+
def test_limit_offset_negative_offset_defaults_to_zero(sample_articles):
|
|
948
|
+
"""Test that negative offset defaults to 0"""
|
|
949
|
+
api = BoltAPI()
|
|
950
|
+
|
|
951
|
+
@api.get("/articles")
|
|
952
|
+
@paginate(LimitOffsetPagination)
|
|
953
|
+
async def list_articles(request):
|
|
954
|
+
return Article.objects.all()
|
|
955
|
+
|
|
956
|
+
with TestClient(api) as client:
|
|
957
|
+
response = client.get("/articles?limit=10&offset=-5")
|
|
958
|
+
assert response.status_code == 200
|
|
959
|
+
|
|
960
|
+
data = response.json()
|
|
961
|
+
assert data["offset"] == 0
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
@pytest.mark.django_db(transaction=True)
|
|
965
|
+
def test_pagination_zero_page_size_uses_default(sample_articles):
|
|
966
|
+
"""Test that zero page size uses default"""
|
|
967
|
+
api = BoltAPI()
|
|
968
|
+
|
|
969
|
+
class SmallPagePagination(PageNumberPagination):
|
|
970
|
+
page_size = 10
|
|
971
|
+
page_size_query_param = "page_size"
|
|
972
|
+
|
|
973
|
+
@api.get("/articles")
|
|
974
|
+
@paginate(SmallPagePagination)
|
|
975
|
+
async def list_articles(request):
|
|
976
|
+
return Article.objects.all()
|
|
977
|
+
|
|
978
|
+
with TestClient(api) as client:
|
|
979
|
+
response = client.get("/articles?page=1&page_size=0")
|
|
980
|
+
assert response.status_code == 200
|
|
981
|
+
|
|
982
|
+
data = response.json()
|
|
983
|
+
assert data["page_size"] == 10 # Default
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
@pytest.mark.django_db(transaction=True)
|
|
987
|
+
def test_pagination_with_query_optimization(sample_articles):
|
|
988
|
+
"""Test pagination works with .only() query optimization"""
|
|
989
|
+
api = BoltAPI()
|
|
990
|
+
|
|
991
|
+
class SmallPagePagination(PageNumberPagination):
|
|
992
|
+
page_size = 10
|
|
993
|
+
|
|
994
|
+
@api.get("/articles")
|
|
995
|
+
@paginate(SmallPagePagination)
|
|
996
|
+
async def list_articles(request):
|
|
997
|
+
return Article.objects.only("id", "title", "author")
|
|
998
|
+
|
|
999
|
+
with TestClient(api) as client:
|
|
1000
|
+
response = client.get("/articles?page=1")
|
|
1001
|
+
assert response.status_code == 200
|
|
1002
|
+
|
|
1003
|
+
data = response.json()
|
|
1004
|
+
assert len(data["items"]) == 10
|
|
1005
|
+
assert "title" in data["items"][0]
|
|
1006
|
+
assert "author" in data["items"][0]
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
# ============================================================================
|
|
1010
|
+
# Advanced Integration & Edge Cases
|
|
1011
|
+
# ============================================================================
|
|
1012
|
+
|
|
1013
|
+
@pytest.mark.django_db(transaction=True)
|
|
1014
|
+
def test_pagination_with_select_related(sample_articles):
|
|
1015
|
+
"""Test pagination works with select_related"""
|
|
1016
|
+
api = BoltAPI()
|
|
1017
|
+
|
|
1018
|
+
class SmallPagePagination(PageNumberPagination):
|
|
1019
|
+
page_size = 10
|
|
1020
|
+
|
|
1021
|
+
@api.get("/articles")
|
|
1022
|
+
@paginate(SmallPagePagination)
|
|
1023
|
+
async def list_articles(request):
|
|
1024
|
+
# Even though Article doesn't have FK in this test, this tests compatibility
|
|
1025
|
+
return Article.objects.all().select_related()
|
|
1026
|
+
|
|
1027
|
+
with TestClient(api) as client:
|
|
1028
|
+
response = client.get("/articles?page=1")
|
|
1029
|
+
assert response.status_code == 200
|
|
1030
|
+
|
|
1031
|
+
data = response.json()
|
|
1032
|
+
assert len(data["items"]) == 10
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
@pytest.mark.django_db(transaction=True)
|
|
1036
|
+
def test_pagination_with_prefetch_related(sample_articles):
|
|
1037
|
+
"""Test pagination works with prefetch_related"""
|
|
1038
|
+
api = BoltAPI()
|
|
1039
|
+
|
|
1040
|
+
class SmallPagePagination(PageNumberPagination):
|
|
1041
|
+
page_size = 10
|
|
1042
|
+
|
|
1043
|
+
@api.get("/articles")
|
|
1044
|
+
@paginate(SmallPagePagination)
|
|
1045
|
+
async def list_articles(request):
|
|
1046
|
+
# Even though Article doesn't have M2M in this test, this tests compatibility
|
|
1047
|
+
return Article.objects.all().prefetch_related()
|
|
1048
|
+
|
|
1049
|
+
with TestClient(api) as client:
|
|
1050
|
+
response = client.get("/articles?page=1")
|
|
1051
|
+
assert response.status_code == 200
|
|
1052
|
+
|
|
1053
|
+
data = response.json()
|
|
1054
|
+
assert len(data["items"]) == 10
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
@pytest.mark.django_db(transaction=True)
|
|
1058
|
+
def test_pagination_with_distinct(sample_articles):
|
|
1059
|
+
"""Test pagination works with distinct"""
|
|
1060
|
+
api = BoltAPI()
|
|
1061
|
+
|
|
1062
|
+
class SmallPagePagination(PageNumberPagination):
|
|
1063
|
+
page_size = 10
|
|
1064
|
+
|
|
1065
|
+
@api.get("/articles")
|
|
1066
|
+
@paginate(SmallPagePagination)
|
|
1067
|
+
async def list_articles(request):
|
|
1068
|
+
return Article.objects.all().distinct()
|
|
1069
|
+
|
|
1070
|
+
with TestClient(api) as client:
|
|
1071
|
+
response = client.get("/articles?page=1")
|
|
1072
|
+
assert response.status_code == 200
|
|
1073
|
+
|
|
1074
|
+
data = response.json()
|
|
1075
|
+
assert len(data["items"]) == 10
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@pytest.mark.django_db(transaction=True)
|
|
1079
|
+
def test_pagination_with_values(sample_articles):
|
|
1080
|
+
"""Test pagination works with values()"""
|
|
1081
|
+
api = BoltAPI()
|
|
1082
|
+
|
|
1083
|
+
class SmallPagePagination(PageNumberPagination):
|
|
1084
|
+
page_size = 10
|
|
1085
|
+
|
|
1086
|
+
@api.get("/articles")
|
|
1087
|
+
@paginate(SmallPagePagination)
|
|
1088
|
+
async def list_articles(request):
|
|
1089
|
+
return Article.objects.values("id", "title", "author")
|
|
1090
|
+
|
|
1091
|
+
with TestClient(api) as client:
|
|
1092
|
+
response = client.get("/articles?page=1")
|
|
1093
|
+
assert response.status_code == 200
|
|
1094
|
+
|
|
1095
|
+
data = response.json()
|
|
1096
|
+
assert len(data["items"]) == 10
|
|
1097
|
+
# values() returns dicts
|
|
1098
|
+
assert isinstance(data["items"][0], dict)
|
|
1099
|
+
assert "title" in data["items"][0]
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
@pytest.mark.django_db(transaction=True)
|
|
1103
|
+
def test_pagination_with_values_list(sample_articles):
|
|
1104
|
+
"""Test pagination works with values_list()"""
|
|
1105
|
+
api = BoltAPI()
|
|
1106
|
+
|
|
1107
|
+
class SmallPagePagination(PageNumberPagination):
|
|
1108
|
+
page_size = 10
|
|
1109
|
+
|
|
1110
|
+
@api.get("/articles")
|
|
1111
|
+
@paginate(SmallPagePagination)
|
|
1112
|
+
async def list_articles(request):
|
|
1113
|
+
return Article.objects.values_list("id", "title", "author")
|
|
1114
|
+
|
|
1115
|
+
with TestClient(api) as client:
|
|
1116
|
+
response = client.get("/articles?page=1")
|
|
1117
|
+
assert response.status_code == 200
|
|
1118
|
+
|
|
1119
|
+
data = response.json()
|
|
1120
|
+
assert len(data["items"]) == 10
|
|
1121
|
+
# values_list() returns tuples (serialized as lists)
|
|
1122
|
+
assert isinstance(data["items"][0], list)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
@pytest.mark.django_db(transaction=True)
|
|
1126
|
+
def test_pagination_single_result(db):
|
|
1127
|
+
"""Test pagination with only one result"""
|
|
1128
|
+
api = BoltAPI()
|
|
1129
|
+
|
|
1130
|
+
# Create single article
|
|
1131
|
+
Article.objects.create(
|
|
1132
|
+
title="Solo Article",
|
|
1133
|
+
content="Content",
|
|
1134
|
+
author="Author",
|
|
1135
|
+
is_published=True,
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
class SmallPagePagination(PageNumberPagination):
|
|
1139
|
+
page_size = 10
|
|
1140
|
+
|
|
1141
|
+
@api.get("/articles")
|
|
1142
|
+
@paginate(SmallPagePagination)
|
|
1143
|
+
async def list_articles(request):
|
|
1144
|
+
return Article.objects.all()
|
|
1145
|
+
|
|
1146
|
+
with TestClient(api) as client:
|
|
1147
|
+
response = client.get("/articles?page=1")
|
|
1148
|
+
assert response.status_code == 200
|
|
1149
|
+
|
|
1150
|
+
data = response.json()
|
|
1151
|
+
assert len(data["items"]) == 1
|
|
1152
|
+
assert data["total"] == 1
|
|
1153
|
+
assert data["total_pages"] == 1
|
|
1154
|
+
assert data["has_next"] is False
|
|
1155
|
+
assert data["has_previous"] is False
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
@pytest.mark.django_db(transaction=True)
|
|
1159
|
+
def test_limit_offset_with_zero_limit(sample_articles):
|
|
1160
|
+
"""Test limit offset with zero limit uses default"""
|
|
1161
|
+
api = BoltAPI()
|
|
1162
|
+
|
|
1163
|
+
class CustomLimitOffset(LimitOffsetPagination):
|
|
1164
|
+
page_size = 20
|
|
1165
|
+
|
|
1166
|
+
@api.get("/articles")
|
|
1167
|
+
@paginate(CustomLimitOffset)
|
|
1168
|
+
async def list_articles(request):
|
|
1169
|
+
return Article.objects.all()
|
|
1170
|
+
|
|
1171
|
+
with TestClient(api) as client:
|
|
1172
|
+
response = client.get("/articles?limit=0&offset=0")
|
|
1173
|
+
assert response.status_code == 200
|
|
1174
|
+
|
|
1175
|
+
data = response.json()
|
|
1176
|
+
assert data["limit"] == 20 # Uses default
|
|
1177
|
+
assert len(data["items"]) == 20
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
@pytest.mark.django_db(transaction=True)
|
|
1181
|
+
def test_cursor_pagination_invalid_cursor(sample_articles):
|
|
1182
|
+
"""Test cursor pagination with invalid cursor value"""
|
|
1183
|
+
api = BoltAPI()
|
|
1184
|
+
|
|
1185
|
+
class SmallCursorPagination(CursorPagination):
|
|
1186
|
+
page_size = 10
|
|
1187
|
+
ordering = "-id"
|
|
1188
|
+
|
|
1189
|
+
@api.get("/articles")
|
|
1190
|
+
@paginate(SmallCursorPagination)
|
|
1191
|
+
async def list_articles(request):
|
|
1192
|
+
return Article.objects.all()
|
|
1193
|
+
|
|
1194
|
+
with TestClient(api) as client:
|
|
1195
|
+
# Use invalid cursor - should start from beginning
|
|
1196
|
+
response = client.get("/articles?cursor=invalid_cursor_value")
|
|
1197
|
+
assert response.status_code == 200
|
|
1198
|
+
|
|
1199
|
+
data = response.json()
|
|
1200
|
+
# Invalid cursor should be ignored and start from beginning
|
|
1201
|
+
assert len(data["items"]) == 10
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
@pytest.mark.django_db(transaction=True)
|
|
1205
|
+
def test_multiple_pagination_types_different_endpoints(sample_articles):
|
|
1206
|
+
"""Test using different pagination types on different endpoints"""
|
|
1207
|
+
api = BoltAPI()
|
|
1208
|
+
|
|
1209
|
+
class PagePagination(PageNumberPagination):
|
|
1210
|
+
page_size = 10
|
|
1211
|
+
|
|
1212
|
+
class OffsetPagination(LimitOffsetPagination):
|
|
1213
|
+
page_size = 15
|
|
1214
|
+
|
|
1215
|
+
class CursorPag(CursorPagination):
|
|
1216
|
+
page_size = 5
|
|
1217
|
+
ordering = "-id"
|
|
1218
|
+
|
|
1219
|
+
@api.get("/articles/page")
|
|
1220
|
+
@paginate(PagePagination)
|
|
1221
|
+
async def list_page(request):
|
|
1222
|
+
return Article.objects.all()
|
|
1223
|
+
|
|
1224
|
+
@api.get("/articles/offset")
|
|
1225
|
+
@paginate(OffsetPagination)
|
|
1226
|
+
async def list_offset(request):
|
|
1227
|
+
return Article.objects.all()
|
|
1228
|
+
|
|
1229
|
+
@api.get("/articles/cursor")
|
|
1230
|
+
@paginate(CursorPag)
|
|
1231
|
+
async def list_cursor(request):
|
|
1232
|
+
return Article.objects.all()
|
|
1233
|
+
|
|
1234
|
+
with TestClient(api) as client:
|
|
1235
|
+
# Test page pagination
|
|
1236
|
+
response1 = client.get("/articles/page?page=1")
|
|
1237
|
+
assert response1.status_code == 200
|
|
1238
|
+
data1 = response1.json()
|
|
1239
|
+
assert len(data1["items"]) == 10
|
|
1240
|
+
assert "page" in data1
|
|
1241
|
+
|
|
1242
|
+
# Test limit-offset pagination (no limit specified, uses page_size default)
|
|
1243
|
+
response2 = client.get("/articles/offset")
|
|
1244
|
+
assert response2.status_code == 200
|
|
1245
|
+
data2 = response2.json()
|
|
1246
|
+
assert len(data2["items"]) == 15
|
|
1247
|
+
assert "limit" in data2
|
|
1248
|
+
|
|
1249
|
+
# Test cursor pagination
|
|
1250
|
+
response3 = client.get("/articles/cursor")
|
|
1251
|
+
assert response3.status_code == 200
|
|
1252
|
+
data3 = response3.json()
|
|
1253
|
+
assert len(data3["items"]) == 5
|
|
1254
|
+
assert "next_cursor" in data3
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
if __name__ == "__main__":
|
|
1258
|
+
pytest.main([__file__, "-v", "-s"])
|