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,703 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django ORM Integration Tests for Class-Based Views.
|
|
3
|
+
|
|
4
|
+
This test suite verifies that ViewSets and Mixins work correctly with
|
|
5
|
+
real Django ORM operations (like Django REST Framework).
|
|
6
|
+
|
|
7
|
+
Tests cover:
|
|
8
|
+
- Real database queries with Django async ORM
|
|
9
|
+
- ViewSet with all CRUD operations
|
|
10
|
+
- ListMixin with Article.objects.all()
|
|
11
|
+
- RetrieveMixin with Article.objects.aget(pk=pk)
|
|
12
|
+
- CreateMixin with Article.objects.acreate(**data)
|
|
13
|
+
- UpdateMixin with obj.asave()
|
|
14
|
+
- PartialUpdateMixin with partial updates + asave()
|
|
15
|
+
- DestroyMixin with obj.adelete()
|
|
16
|
+
- End-to-end CRUD workflows
|
|
17
|
+
"""
|
|
18
|
+
import pytest
|
|
19
|
+
import msgspec
|
|
20
|
+
from django_bolt import BoltAPI
|
|
21
|
+
from django_bolt.testing import TestClient
|
|
22
|
+
from django_bolt.views import (
|
|
23
|
+
APIView,
|
|
24
|
+
ViewSet,
|
|
25
|
+
ListMixin,
|
|
26
|
+
RetrieveMixin,
|
|
27
|
+
CreateMixin,
|
|
28
|
+
UpdateMixin,
|
|
29
|
+
PartialUpdateMixin,
|
|
30
|
+
DestroyMixin,
|
|
31
|
+
)
|
|
32
|
+
from django_bolt.exceptions import HTTPException
|
|
33
|
+
from ..test_models import Article
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- Fixtures ---
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def api():
|
|
40
|
+
"""Create a fresh BoltAPI instance for each test."""
|
|
41
|
+
return BoltAPI()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def sample_articles(db):
|
|
46
|
+
"""Create sample articles in the database."""
|
|
47
|
+
from asgiref.sync import async_to_sync
|
|
48
|
+
|
|
49
|
+
articles = []
|
|
50
|
+
for i in range(1, 4):
|
|
51
|
+
article = async_to_sync(Article.objects.acreate)(
|
|
52
|
+
title=f"Article {i}",
|
|
53
|
+
content=f"Content {i}",
|
|
54
|
+
author="Test Author",
|
|
55
|
+
is_published=(i % 2 == 0),
|
|
56
|
+
)
|
|
57
|
+
articles.append(article)
|
|
58
|
+
return articles
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- Schemas ---
|
|
62
|
+
|
|
63
|
+
class ArticleSchema(msgspec.Struct):
|
|
64
|
+
"""Full article schema (without datetime fields for simplicity)."""
|
|
65
|
+
id: int
|
|
66
|
+
title: str
|
|
67
|
+
content: str
|
|
68
|
+
author: str
|
|
69
|
+
is_published: bool
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_model(cls, obj):
|
|
73
|
+
"""Convert Django model instance to schema."""
|
|
74
|
+
return cls(
|
|
75
|
+
id=obj.id,
|
|
76
|
+
title=obj.title,
|
|
77
|
+
content=obj.content,
|
|
78
|
+
author=obj.author,
|
|
79
|
+
is_published=obj.is_published,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ArticleCreateSchema(msgspec.Struct):
|
|
84
|
+
"""Schema for creating articles."""
|
|
85
|
+
title: str
|
|
86
|
+
content: str
|
|
87
|
+
author: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ArticleUpdateSchema(msgspec.Struct):
|
|
91
|
+
"""Schema for updating articles (full update)."""
|
|
92
|
+
title: str
|
|
93
|
+
content: str
|
|
94
|
+
author: str
|
|
95
|
+
is_published: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ArticlePartialUpdateSchema(msgspec.Struct):
|
|
99
|
+
"""Schema for partial updates (all fields optional)."""
|
|
100
|
+
title: str | None = None
|
|
101
|
+
content: str | None = None
|
|
102
|
+
author: str | None = None
|
|
103
|
+
is_published: bool | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- ListMixin Tests ---
|
|
107
|
+
|
|
108
|
+
@pytest.mark.django_db(transaction=True)
|
|
109
|
+
def test_simple_list_without_mixin(api, sample_articles):
|
|
110
|
+
"""Test simple list without mixin to debug."""
|
|
111
|
+
|
|
112
|
+
@api.view("/articles/simple")
|
|
113
|
+
class ArticleListView(APIView):
|
|
114
|
+
async def get(self, request) -> list:
|
|
115
|
+
articles = []
|
|
116
|
+
async for article in Article.objects.all():
|
|
117
|
+
articles.append({
|
|
118
|
+
"id": article.id,
|
|
119
|
+
"title": article.title,
|
|
120
|
+
"content": article.content,
|
|
121
|
+
"author": article.author,
|
|
122
|
+
"is_published": article.is_published,
|
|
123
|
+
})
|
|
124
|
+
return articles
|
|
125
|
+
|
|
126
|
+
with TestClient(api) as client:
|
|
127
|
+
response = client.get("/articles/simple")
|
|
128
|
+
if response.status_code != 200:
|
|
129
|
+
print(f"Error response: {response.text}")
|
|
130
|
+
assert response.status_code == 200
|
|
131
|
+
data = response.json()
|
|
132
|
+
assert len(data) == 3
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.django_db(transaction=True)
|
|
136
|
+
def test_list_mixin_with_real_django_orm(api, sample_articles):
|
|
137
|
+
"""Test ListMixin with real Django ORM queryset."""
|
|
138
|
+
|
|
139
|
+
@api.view("/articles")
|
|
140
|
+
class ArticleListView(ListMixin, APIView):
|
|
141
|
+
serializer_class = ArticleSchema
|
|
142
|
+
|
|
143
|
+
async def get_queryset(self):
|
|
144
|
+
return Article.objects.all()
|
|
145
|
+
|
|
146
|
+
with TestClient(api) as client:
|
|
147
|
+
response = client.get("/articles")
|
|
148
|
+
if response.status_code != 200:
|
|
149
|
+
print(f"Error response: {response.text}")
|
|
150
|
+
assert response.status_code == 200
|
|
151
|
+
data = response.json()
|
|
152
|
+
|
|
153
|
+
# Verify we got all articles
|
|
154
|
+
assert isinstance(data, list)
|
|
155
|
+
assert len(data) == 3
|
|
156
|
+
|
|
157
|
+
# Verify data structure
|
|
158
|
+
for article_data in data:
|
|
159
|
+
assert "id" in article_data
|
|
160
|
+
assert "title" in article_data
|
|
161
|
+
assert "content" in article_data
|
|
162
|
+
assert "author" in article_data
|
|
163
|
+
assert "is_published" in article_data
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.django_db(transaction=True)
|
|
167
|
+
def test_list_mixin_filtered_queryset(api, sample_articles):
|
|
168
|
+
"""Test ListMixin with filtered Django queryset."""
|
|
169
|
+
|
|
170
|
+
@api.view("/articles/published")
|
|
171
|
+
class PublishedArticleListView(ListMixin, APIView):
|
|
172
|
+
serializer_class = ArticleSchema
|
|
173
|
+
|
|
174
|
+
async def get_queryset(self):
|
|
175
|
+
return Article.objects.filter(is_published=True)
|
|
176
|
+
|
|
177
|
+
with TestClient(api) as client:
|
|
178
|
+
response = client.get("/articles/published")
|
|
179
|
+
assert response.status_code == 200
|
|
180
|
+
data = response.json()
|
|
181
|
+
|
|
182
|
+
# Should only get published articles (even numbered from fixture)
|
|
183
|
+
assert isinstance(data, list)
|
|
184
|
+
assert len(data) == 1 # Only Article 2 is published (2 % 2 == 0)
|
|
185
|
+
assert all(article["is_published"] for article in data)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --- RetrieveMixin Tests ---
|
|
189
|
+
|
|
190
|
+
@pytest.mark.django_db(transaction=True)
|
|
191
|
+
def test_retrieve_mixin_with_real_django_orm(api, sample_articles):
|
|
192
|
+
"""Test RetrieveMixin with real Django ORM aget()."""
|
|
193
|
+
|
|
194
|
+
@api.view("/articles/{pk}")
|
|
195
|
+
class ArticleDetailView(RetrieveMixin, ViewSet):
|
|
196
|
+
serializer_class = ArticleSchema
|
|
197
|
+
|
|
198
|
+
async def get_queryset(self):
|
|
199
|
+
return Article.objects.all()
|
|
200
|
+
|
|
201
|
+
article_id = sample_articles[0].id
|
|
202
|
+
|
|
203
|
+
with TestClient(api) as client:
|
|
204
|
+
response = client.get(f"/articles/{article_id}")
|
|
205
|
+
assert response.status_code == 200
|
|
206
|
+
data = response.json()
|
|
207
|
+
|
|
208
|
+
# Verify correct article retrieved
|
|
209
|
+
assert data["id"] == article_id
|
|
210
|
+
assert data["title"] == "Article 1"
|
|
211
|
+
assert data["content"] == "Content 1"
|
|
212
|
+
assert data["author"] == "Test Author"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@pytest.mark.django_db(transaction=True)
|
|
216
|
+
def test_retrieve_mixin_not_found(api):
|
|
217
|
+
"""Test RetrieveMixin returns 404 when object doesn't exist."""
|
|
218
|
+
|
|
219
|
+
@api.view("/articles/{pk}")
|
|
220
|
+
class ArticleDetailView(RetrieveMixin, ViewSet):
|
|
221
|
+
serializer_class = ArticleSchema
|
|
222
|
+
|
|
223
|
+
async def get_queryset(self):
|
|
224
|
+
return Article.objects.all()
|
|
225
|
+
|
|
226
|
+
with TestClient(api) as client:
|
|
227
|
+
response = client.get("/articles/99999")
|
|
228
|
+
assert response.status_code == 404
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# --- CreateMixin Tests ---
|
|
232
|
+
|
|
233
|
+
@pytest.mark.django_db(transaction=True)
|
|
234
|
+
def test_create_mixin_with_real_django_orm(api):
|
|
235
|
+
"""Test CreateMixin with real Django ORM acreate()."""
|
|
236
|
+
|
|
237
|
+
@api.view("/articles")
|
|
238
|
+
class ArticleCreateView(ViewSet):
|
|
239
|
+
serializer_class = ArticleSchema
|
|
240
|
+
|
|
241
|
+
async def get_queryset(self):
|
|
242
|
+
return Article.objects.all()
|
|
243
|
+
|
|
244
|
+
async def post(self, request, data: ArticleCreateSchema):
|
|
245
|
+
"""Create a new article."""
|
|
246
|
+
article = await Article.objects.acreate(
|
|
247
|
+
title=data.title,
|
|
248
|
+
content=data.content,
|
|
249
|
+
author=data.author,
|
|
250
|
+
)
|
|
251
|
+
return ArticleSchema.from_model(article)
|
|
252
|
+
|
|
253
|
+
with TestClient(api) as client:
|
|
254
|
+
response = client.post(
|
|
255
|
+
"/articles",
|
|
256
|
+
json={
|
|
257
|
+
"title": "New Article",
|
|
258
|
+
"content": "New Content",
|
|
259
|
+
"author": "New Author",
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
assert response.status_code == 200
|
|
263
|
+
data = response.json()
|
|
264
|
+
|
|
265
|
+
# Verify article was created
|
|
266
|
+
assert data["title"] == "New Article"
|
|
267
|
+
assert data["content"] == "New Content"
|
|
268
|
+
assert data["author"] == "New Author"
|
|
269
|
+
assert "id" in data
|
|
270
|
+
|
|
271
|
+
# Verify it's actually in the database
|
|
272
|
+
from asgiref.sync import async_to_sync
|
|
273
|
+
article_id = data["id"]
|
|
274
|
+
article = async_to_sync(Article.objects.aget)(id=article_id)
|
|
275
|
+
assert article.title == "New Article"
|
|
276
|
+
assert article.content == "New Content"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# --- UpdateMixin Tests ---
|
|
280
|
+
|
|
281
|
+
@pytest.mark.django_db(transaction=True)
|
|
282
|
+
def test_update_mixin_with_real_django_orm(api, sample_articles):
|
|
283
|
+
"""Test UpdateMixin with real Django ORM asave()."""
|
|
284
|
+
|
|
285
|
+
@api.view("/articles/{pk}")
|
|
286
|
+
class ArticleUpdateView(ViewSet):
|
|
287
|
+
serializer_class = ArticleSchema
|
|
288
|
+
|
|
289
|
+
async def get_queryset(self):
|
|
290
|
+
return Article.objects.all()
|
|
291
|
+
|
|
292
|
+
async def put(self, request, pk: int, data: ArticleUpdateSchema):
|
|
293
|
+
"""Update an article (full update)."""
|
|
294
|
+
article = await self.get_object(pk)
|
|
295
|
+
article.title = data.title
|
|
296
|
+
article.content = data.content
|
|
297
|
+
article.author = data.author
|
|
298
|
+
article.is_published = data.is_published
|
|
299
|
+
await article.asave()
|
|
300
|
+
return ArticleSchema.from_model(article)
|
|
301
|
+
|
|
302
|
+
article_id = sample_articles[0].id
|
|
303
|
+
|
|
304
|
+
with TestClient(api) as client:
|
|
305
|
+
response = client.put(
|
|
306
|
+
f"/articles/{article_id}",
|
|
307
|
+
json={
|
|
308
|
+
"title": "Updated Title",
|
|
309
|
+
"content": "Updated Content",
|
|
310
|
+
"author": "Updated Author",
|
|
311
|
+
"is_published": True,
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
assert response.status_code == 200
|
|
315
|
+
data = response.json()
|
|
316
|
+
|
|
317
|
+
# Verify response
|
|
318
|
+
assert data["id"] == article_id
|
|
319
|
+
assert data["title"] == "Updated Title"
|
|
320
|
+
assert data["content"] == "Updated Content"
|
|
321
|
+
assert data["is_published"] is True
|
|
322
|
+
|
|
323
|
+
# Verify database was updated
|
|
324
|
+
from asgiref.sync import async_to_sync
|
|
325
|
+
article = async_to_sync(Article.objects.aget)(id=article_id)
|
|
326
|
+
assert article.title == "Updated Title"
|
|
327
|
+
assert article.content == "Updated Content"
|
|
328
|
+
assert article.is_published is True
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --- PartialUpdateMixin Tests ---
|
|
332
|
+
|
|
333
|
+
@pytest.mark.django_db(transaction=True)
|
|
334
|
+
def test_partial_update_mixin_with_real_django_orm(api, sample_articles):
|
|
335
|
+
"""Test PartialUpdateMixin with real Django ORM asave()."""
|
|
336
|
+
|
|
337
|
+
@api.view("/articles/{pk}")
|
|
338
|
+
class ArticlePartialUpdateView(ViewSet):
|
|
339
|
+
serializer_class = ArticleSchema
|
|
340
|
+
|
|
341
|
+
async def get_queryset(self):
|
|
342
|
+
return Article.objects.all()
|
|
343
|
+
|
|
344
|
+
async def patch(self, request, pk: int, data: ArticlePartialUpdateSchema):
|
|
345
|
+
"""Partially update an article."""
|
|
346
|
+
article = await self.get_object(pk)
|
|
347
|
+
if data.title is not None:
|
|
348
|
+
article.title = data.title
|
|
349
|
+
if data.content is not None:
|
|
350
|
+
article.content = data.content
|
|
351
|
+
if data.author is not None:
|
|
352
|
+
article.author = data.author
|
|
353
|
+
if data.is_published is not None:
|
|
354
|
+
article.is_published = data.is_published
|
|
355
|
+
await article.asave()
|
|
356
|
+
return ArticleSchema.from_model(article)
|
|
357
|
+
|
|
358
|
+
article_id = sample_articles[0].id
|
|
359
|
+
original_content = sample_articles[0].content
|
|
360
|
+
|
|
361
|
+
with TestClient(api) as client:
|
|
362
|
+
# Only update title, leave other fields unchanged
|
|
363
|
+
response = client.patch(
|
|
364
|
+
f"/articles/{article_id}",
|
|
365
|
+
json={"title": "Partially Updated Title"},
|
|
366
|
+
)
|
|
367
|
+
assert response.status_code == 200
|
|
368
|
+
data = response.json()
|
|
369
|
+
|
|
370
|
+
# Verify title was updated
|
|
371
|
+
assert data["title"] == "Partially Updated Title"
|
|
372
|
+
|
|
373
|
+
# Verify database was updated and other fields unchanged
|
|
374
|
+
from asgiref.sync import async_to_sync
|
|
375
|
+
article = async_to_sync(Article.objects.aget)(id=article_id)
|
|
376
|
+
assert article.title == "Partially Updated Title"
|
|
377
|
+
assert article.content == original_content # Unchanged
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --- DestroyMixin Tests ---
|
|
381
|
+
|
|
382
|
+
@pytest.mark.django_db(transaction=True)
|
|
383
|
+
def test_destroy_mixin_with_real_django_orm(api, sample_articles):
|
|
384
|
+
"""Test DestroyMixin with real Django ORM adelete()."""
|
|
385
|
+
|
|
386
|
+
@api.view("/articles/{pk}")
|
|
387
|
+
class ArticleDestroyView(DestroyMixin, ViewSet):
|
|
388
|
+
async def get_queryset(self):
|
|
389
|
+
return Article.objects.all()
|
|
390
|
+
|
|
391
|
+
article_id = sample_articles[0].id
|
|
392
|
+
|
|
393
|
+
# Verify article exists before deletion
|
|
394
|
+
from asgiref.sync import async_to_sync
|
|
395
|
+
exists_before = async_to_sync(Article.objects.filter(id=article_id).aexists)()
|
|
396
|
+
assert exists_before is True
|
|
397
|
+
|
|
398
|
+
with TestClient(api) as client:
|
|
399
|
+
response = client.delete(f"/articles/{article_id}")
|
|
400
|
+
assert response.status_code == 200
|
|
401
|
+
data = response.json()
|
|
402
|
+
assert "detail" in data
|
|
403
|
+
|
|
404
|
+
# Verify article was deleted from database
|
|
405
|
+
exists_after = async_to_sync(Article.objects.filter(id=article_id).aexists)()
|
|
406
|
+
assert exists_after is False
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# --- Full CRUD ViewSet Tests ---
|
|
410
|
+
|
|
411
|
+
@pytest.mark.django_db(transaction=True)
|
|
412
|
+
def test_full_crud_viewset_with_django_orm(api):
|
|
413
|
+
"""
|
|
414
|
+
Test a complete CRUD ViewSet with all mixins using real Django ORM.
|
|
415
|
+
This verifies the ViewSet works like Django REST Framework.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
class ArticleViewSet(ViewSet):
|
|
419
|
+
serializer_class = ArticleSchema
|
|
420
|
+
|
|
421
|
+
async def get_queryset(self):
|
|
422
|
+
return Article.objects.all()
|
|
423
|
+
|
|
424
|
+
async def get(self, request):
|
|
425
|
+
"""List all articles."""
|
|
426
|
+
articles = []
|
|
427
|
+
async for article in Article.objects.all():
|
|
428
|
+
articles.append(ArticleSchema.from_model(article))
|
|
429
|
+
return articles
|
|
430
|
+
|
|
431
|
+
async def post(self, request, data: ArticleCreateSchema):
|
|
432
|
+
"""Create an article."""
|
|
433
|
+
article = await Article.objects.acreate(
|
|
434
|
+
title=data.title,
|
|
435
|
+
content=data.content,
|
|
436
|
+
author=data.author,
|
|
437
|
+
)
|
|
438
|
+
return ArticleSchema.from_model(article)
|
|
439
|
+
|
|
440
|
+
class ArticleDetailViewSet(ViewSet):
|
|
441
|
+
serializer_class = ArticleSchema
|
|
442
|
+
|
|
443
|
+
async def get_queryset(self):
|
|
444
|
+
return Article.objects.all()
|
|
445
|
+
|
|
446
|
+
async def get(self, request, pk: int):
|
|
447
|
+
"""Retrieve an article."""
|
|
448
|
+
article = await self.get_object(pk)
|
|
449
|
+
return ArticleSchema.from_model(article)
|
|
450
|
+
|
|
451
|
+
async def put(self, request, pk: int, data: ArticleUpdateSchema):
|
|
452
|
+
"""Update an article."""
|
|
453
|
+
article = await self.get_object(pk)
|
|
454
|
+
article.title = data.title
|
|
455
|
+
article.content = data.content
|
|
456
|
+
article.author = data.author
|
|
457
|
+
article.is_published = data.is_published
|
|
458
|
+
await article.asave()
|
|
459
|
+
return ArticleSchema.from_model(article)
|
|
460
|
+
|
|
461
|
+
async def patch(self, request, pk: int, data: ArticlePartialUpdateSchema):
|
|
462
|
+
"""Partially update an article."""
|
|
463
|
+
article = await self.get_object(pk)
|
|
464
|
+
if data.title is not None:
|
|
465
|
+
article.title = data.title
|
|
466
|
+
if data.content is not None:
|
|
467
|
+
article.content = data.content
|
|
468
|
+
if data.author is not None:
|
|
469
|
+
article.author = data.author
|
|
470
|
+
if data.is_published is not None:
|
|
471
|
+
article.is_published = data.is_published
|
|
472
|
+
await article.asave()
|
|
473
|
+
return ArticleSchema.from_model(article)
|
|
474
|
+
|
|
475
|
+
async def delete(self, request, pk: int):
|
|
476
|
+
"""Delete an article."""
|
|
477
|
+
article = await self.get_object(pk)
|
|
478
|
+
await article.adelete()
|
|
479
|
+
return {"detail": "Object deleted successfully"}
|
|
480
|
+
|
|
481
|
+
# Register routes with decorator syntax
|
|
482
|
+
@api.view("/articles", methods=["GET", "POST"])
|
|
483
|
+
class ArticleViewSetRegistered(ArticleViewSet):
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
@api.view("/articles/{pk}", methods=["GET", "PUT", "PATCH", "DELETE"])
|
|
487
|
+
class ArticleDetailViewSetRegistered(ArticleDetailViewSet):
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
with TestClient(api) as client:
|
|
491
|
+
# 1. List (should be empty initially)
|
|
492
|
+
response = client.get("/articles")
|
|
493
|
+
assert response.status_code == 200
|
|
494
|
+
assert response.json() == []
|
|
495
|
+
|
|
496
|
+
# 2. Create first article
|
|
497
|
+
response = client.post(
|
|
498
|
+
"/articles",
|
|
499
|
+
json={
|
|
500
|
+
"title": "First Article",
|
|
501
|
+
"content": "First Content",
|
|
502
|
+
"author": "Author 1",
|
|
503
|
+
},
|
|
504
|
+
)
|
|
505
|
+
assert response.status_code == 200
|
|
506
|
+
article1_id = response.json()["id"]
|
|
507
|
+
|
|
508
|
+
# 3. Create second article
|
|
509
|
+
response = client.post(
|
|
510
|
+
"/articles",
|
|
511
|
+
json={
|
|
512
|
+
"title": "Second Article",
|
|
513
|
+
"content": "Second Content",
|
|
514
|
+
"author": "Author 2",
|
|
515
|
+
},
|
|
516
|
+
)
|
|
517
|
+
assert response.status_code == 200
|
|
518
|
+
article2_id = response.json()["id"]
|
|
519
|
+
|
|
520
|
+
# 4. List (should now have 2 articles)
|
|
521
|
+
response = client.get("/articles")
|
|
522
|
+
assert response.status_code == 200
|
|
523
|
+
articles = response.json()
|
|
524
|
+
assert len(articles) == 2
|
|
525
|
+
|
|
526
|
+
# 5. Retrieve single article
|
|
527
|
+
response = client.get(f"/articles/{article1_id}")
|
|
528
|
+
assert response.status_code == 200
|
|
529
|
+
data = response.json()
|
|
530
|
+
assert data["title"] == "First Article"
|
|
531
|
+
|
|
532
|
+
# 6. Update (full)
|
|
533
|
+
response = client.put(
|
|
534
|
+
f"/articles/{article1_id}",
|
|
535
|
+
json={
|
|
536
|
+
"title": "Updated First Article",
|
|
537
|
+
"content": "Updated Content",
|
|
538
|
+
"author": "Updated Author",
|
|
539
|
+
"is_published": True,
|
|
540
|
+
},
|
|
541
|
+
)
|
|
542
|
+
assert response.status_code == 200
|
|
543
|
+
assert response.json()["title"] == "Updated First Article"
|
|
544
|
+
|
|
545
|
+
# 7. Partial update
|
|
546
|
+
response = client.patch(
|
|
547
|
+
f"/articles/{article2_id}",
|
|
548
|
+
json={"title": "Partially Updated Second Article"},
|
|
549
|
+
)
|
|
550
|
+
assert response.status_code == 200
|
|
551
|
+
assert response.json()["title"] == "Partially Updated Second Article"
|
|
552
|
+
|
|
553
|
+
# 8. Delete
|
|
554
|
+
response = client.delete(f"/articles/{article1_id}")
|
|
555
|
+
assert response.status_code == 200
|
|
556
|
+
|
|
557
|
+
# 9. Verify deletion
|
|
558
|
+
response = client.get(f"/articles/{article1_id}")
|
|
559
|
+
assert response.status_code == 404
|
|
560
|
+
|
|
561
|
+
# 10. List (should now have 1 article)
|
|
562
|
+
response = client.get("/articles")
|
|
563
|
+
assert response.status_code == 200
|
|
564
|
+
articles = response.json()
|
|
565
|
+
assert len(articles) == 1
|
|
566
|
+
assert articles[0]["id"] == article2_id
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# --- Custom ViewSet Tests ---
|
|
570
|
+
|
|
571
|
+
@pytest.mark.django_db(transaction=True)
|
|
572
|
+
def test_custom_viewset_method_with_django_orm(api, sample_articles):
|
|
573
|
+
"""Test custom ViewSet method with Django ORM operations."""
|
|
574
|
+
|
|
575
|
+
@api.view("/articles/{pk}")
|
|
576
|
+
class ArticleViewSet(ViewSet):
|
|
577
|
+
async def get_queryset(self):
|
|
578
|
+
return Article.objects.all()
|
|
579
|
+
|
|
580
|
+
async def get(self, request, pk: int) -> dict:
|
|
581
|
+
"""Custom retrieve with additional business logic."""
|
|
582
|
+
article = await self.get_object(pk)
|
|
583
|
+
|
|
584
|
+
# Custom logic: count total articles by same author
|
|
585
|
+
author_count = await Article.objects.filter(author=article.author).acount()
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
"id": article.id,
|
|
589
|
+
"title": article.title,
|
|
590
|
+
"content": article.content,
|
|
591
|
+
"author": article.author,
|
|
592
|
+
"author_article_count": author_count,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
article_id = sample_articles[0].id
|
|
596
|
+
|
|
597
|
+
with TestClient(api) as client:
|
|
598
|
+
response = client.get(f"/articles/{article_id}")
|
|
599
|
+
assert response.status_code == 200
|
|
600
|
+
data = response.json()
|
|
601
|
+
|
|
602
|
+
assert data["id"] == article_id
|
|
603
|
+
assert data["title"] == "Article 1"
|
|
604
|
+
# All 3 sample articles have same author
|
|
605
|
+
assert data["author_article_count"] == 3
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# --- Queryset Filtering Tests ---
|
|
609
|
+
|
|
610
|
+
@pytest.mark.django_db(transaction=True)
|
|
611
|
+
def test_viewset_with_filtered_queryset(api, sample_articles):
|
|
612
|
+
"""Test ViewSet with custom queryset filtering."""
|
|
613
|
+
|
|
614
|
+
@api.view("/articles/published")
|
|
615
|
+
class PublishedArticleViewSet(ListMixin, ViewSet):
|
|
616
|
+
serializer_class = ArticleSchema
|
|
617
|
+
|
|
618
|
+
async def get_queryset(self):
|
|
619
|
+
# Override to only return published articles
|
|
620
|
+
return Article.objects.filter(is_published=True).order_by("-created_at")
|
|
621
|
+
|
|
622
|
+
with TestClient(api) as client:
|
|
623
|
+
response = client.get("/articles/published")
|
|
624
|
+
assert response.status_code == 200
|
|
625
|
+
data = response.json()
|
|
626
|
+
|
|
627
|
+
# Should only get published articles
|
|
628
|
+
assert len(data) == 1
|
|
629
|
+
assert all(article["is_published"] for article in data)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# --- Edge Cases ---
|
|
633
|
+
|
|
634
|
+
@pytest.mark.django_db(transaction=True)
|
|
635
|
+
def test_update_nonexistent_article(api):
|
|
636
|
+
"""Test updating a non-existent article returns 404."""
|
|
637
|
+
|
|
638
|
+
@api.view("/articles/{pk}")
|
|
639
|
+
class ArticleUpdateView(ViewSet):
|
|
640
|
+
serializer_class = ArticleSchema
|
|
641
|
+
|
|
642
|
+
async def get_queryset(self):
|
|
643
|
+
return Article.objects.all()
|
|
644
|
+
|
|
645
|
+
async def put(self, request, pk: int, data: ArticleUpdateSchema):
|
|
646
|
+
"""Update an article."""
|
|
647
|
+
article = await self.get_object(pk) # This will raise 404
|
|
648
|
+
article.title = data.title
|
|
649
|
+
article.content = data.content
|
|
650
|
+
article.author = data.author
|
|
651
|
+
article.is_published = data.is_published
|
|
652
|
+
await article.asave()
|
|
653
|
+
return ArticleSchema.from_model(article)
|
|
654
|
+
|
|
655
|
+
with TestClient(api) as client:
|
|
656
|
+
response = client.put(
|
|
657
|
+
"/articles/99999",
|
|
658
|
+
json={
|
|
659
|
+
"title": "Test",
|
|
660
|
+
"content": "Test",
|
|
661
|
+
"author": "Test",
|
|
662
|
+
"is_published": False,
|
|
663
|
+
},
|
|
664
|
+
)
|
|
665
|
+
assert response.status_code == 404
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@pytest.mark.django_db(transaction=True)
|
|
669
|
+
def test_delete_nonexistent_article(api):
|
|
670
|
+
"""Test deleting a non-existent article returns 404."""
|
|
671
|
+
|
|
672
|
+
@api.view("/articles/{pk}")
|
|
673
|
+
class ArticleDestroyView(DestroyMixin, ViewSet):
|
|
674
|
+
async def get_queryset(self):
|
|
675
|
+
return Article.objects.all()
|
|
676
|
+
|
|
677
|
+
with TestClient(api) as client:
|
|
678
|
+
response = client.delete("/articles/99999")
|
|
679
|
+
assert response.status_code == 404
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@pytest.mark.django_db(transaction=True)
|
|
683
|
+
def test_async_queryset_iteration(api, sample_articles):
|
|
684
|
+
"""Test that async queryset iteration works correctly."""
|
|
685
|
+
|
|
686
|
+
@api.view("/articles")
|
|
687
|
+
class ArticleListView(APIView):
|
|
688
|
+
async def get(self, request) -> list:
|
|
689
|
+
articles = []
|
|
690
|
+
# Test async iteration like ListMixin does
|
|
691
|
+
async for article in Article.objects.all():
|
|
692
|
+
articles.append({
|
|
693
|
+
"id": article.id,
|
|
694
|
+
"title": article.title,
|
|
695
|
+
})
|
|
696
|
+
return articles
|
|
697
|
+
|
|
698
|
+
with TestClient(api) as client:
|
|
699
|
+
response = client.get("/articles")
|
|
700
|
+
assert response.status_code == 200
|
|
701
|
+
data = response.json()
|
|
702
|
+
assert len(data) == 3
|
|
703
|
+
assert all("id" in article and "title" in article for article in data)
|