django-bolt 0.1.0__cp310-abi3-win_amd64.whl → 0.1.1__cp310-abi3-win_amd64.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.

Files changed (56) hide show
  1. django_bolt/__init__.py +2 -2
  2. django_bolt/_core.pyd +0 -0
  3. django_bolt/_json.py +169 -0
  4. django_bolt/admin/static_routes.py +15 -21
  5. django_bolt/api.py +181 -61
  6. django_bolt/auth/__init__.py +2 -2
  7. django_bolt/decorators.py +15 -3
  8. django_bolt/dependencies.py +30 -24
  9. django_bolt/error_handlers.py +2 -1
  10. django_bolt/openapi/plugins.py +3 -2
  11. django_bolt/openapi/schema_generator.py +65 -20
  12. django_bolt/pagination.py +2 -1
  13. django_bolt/responses.py +3 -2
  14. django_bolt/serialization.py +5 -4
  15. {django_bolt-0.1.0.dist-info → django_bolt-0.1.1.dist-info}/METADATA +179 -197
  16. {django_bolt-0.1.0.dist-info → django_bolt-0.1.1.dist-info}/RECORD +18 -55
  17. django_bolt/auth/README.md +0 -464
  18. django_bolt/auth/REVOCATION_EXAMPLE.md +0 -391
  19. django_bolt/tests/__init__.py +0 -0
  20. django_bolt/tests/admin_tests/__init__.py +0 -1
  21. django_bolt/tests/admin_tests/conftest.py +0 -6
  22. django_bolt/tests/admin_tests/test_admin_with_django.py +0 -278
  23. django_bolt/tests/admin_tests/urls.py +0 -9
  24. django_bolt/tests/cbv/__init__.py +0 -0
  25. django_bolt/tests/cbv/test_class_views.py +0 -570
  26. django_bolt/tests/cbv/test_class_views_django_orm.py +0 -703
  27. django_bolt/tests/cbv/test_class_views_features.py +0 -1173
  28. django_bolt/tests/cbv/test_class_views_with_client.py +0 -622
  29. django_bolt/tests/conftest.py +0 -165
  30. django_bolt/tests/test_action_decorator.py +0 -399
  31. django_bolt/tests/test_auth_secret_key.py +0 -83
  32. django_bolt/tests/test_decorator_syntax.py +0 -159
  33. django_bolt/tests/test_error_handling.py +0 -481
  34. django_bolt/tests/test_file_response.py +0 -192
  35. django_bolt/tests/test_global_cors.py +0 -172
  36. django_bolt/tests/test_guards_auth.py +0 -441
  37. django_bolt/tests/test_guards_integration.py +0 -303
  38. django_bolt/tests/test_health.py +0 -283
  39. django_bolt/tests/test_integration_validation.py +0 -400
  40. django_bolt/tests/test_json_validation.py +0 -536
  41. django_bolt/tests/test_jwt_auth.py +0 -327
  42. django_bolt/tests/test_jwt_token.py +0 -458
  43. django_bolt/tests/test_logging.py +0 -837
  44. django_bolt/tests/test_logging_merge.py +0 -419
  45. django_bolt/tests/test_middleware.py +0 -492
  46. django_bolt/tests/test_middleware_server.py +0 -230
  47. django_bolt/tests/test_model_viewset.py +0 -323
  48. django_bolt/tests/test_models.py +0 -24
  49. django_bolt/tests/test_pagination.py +0 -1258
  50. django_bolt/tests/test_parameter_validation.py +0 -178
  51. django_bolt/tests/test_syntax.py +0 -626
  52. django_bolt/tests/test_testing_utilities.py +0 -163
  53. django_bolt/tests/test_testing_utilities_simple.py +0 -123
  54. django_bolt/tests/test_viewset_unified.py +0 -346
  55. {django_bolt-0.1.0.dist-info → django_bolt-0.1.1.dist-info}/WHEEL +0 -0
  56. {django_bolt-0.1.0.dist-info → django_bolt-0.1.1.dist-info}/entry_points.txt +0 -0
@@ -1,1173 +0,0 @@
1
- """
2
- Comprehensive feature tests for class-based views.
3
-
4
- This test suite verifies that ALL Django-Bolt features work correctly with
5
- class-based views (APIView, ViewSet, ModelViewSet):
6
-
7
- - Request validation (Body, Query, Path, Header, Cookie, Form, File)
8
- - Response validation and serialization
9
- - Authentication (JWT, APIKey, Session)
10
- - Guards/Permissions (IsAuthenticated, IsAdminUser, HasPermission, etc.)
11
- - Dependency injection (Depends)
12
- - Middleware (CORS, rate limiting)
13
- - Error handling (HTTPException, validation errors)
14
- - Streaming responses
15
- - File uploads/downloads
16
- """
17
- import pytest
18
- import msgspec
19
- from typing import Annotated
20
- from django_bolt import BoltAPI
21
- from django_bolt.testing import TestClient
22
- from django_bolt.views import APIView, ViewSet, ModelViewSet
23
- from django_bolt.params import Query, Path, Body, Header, Cookie, Form, File, Depends
24
- from django_bolt.exceptions import HTTPException
25
- from django_bolt.responses import StreamingResponse, FileResponse
26
- from django_bolt.auth import JWTAuthentication, APIKeyAuthentication
27
- from django_bolt.auth.guards import IsAuthenticated, IsAdminUser, IsStaff, HasPermission
28
-
29
-
30
- # --- Fixtures ---
31
-
32
- @pytest.fixture
33
- def api():
34
- """Create a fresh BoltAPI instance for each test."""
35
- return BoltAPI()
36
-
37
-
38
- # --- Schemas ---
39
-
40
- class UserSchema(msgspec.Struct):
41
- """User response schema."""
42
- id: int
43
- username: str
44
- email: str
45
-
46
-
47
- class UserCreateSchema(msgspec.Struct):
48
- """User creation schema."""
49
- username: str
50
- email: str
51
- password: str
52
-
53
-
54
- class ErrorResponse(msgspec.Struct):
55
- """Error response schema."""
56
- detail: str
57
- code: str | None = None
58
-
59
-
60
- # ============================================================================
61
- # Request Validation Tests
62
- # ============================================================================
63
-
64
- def test_request_body_validation_error(api):
65
- """Test that invalid request body returns proper validation error."""
66
-
67
- @api.view("/users", methods=["POST"])
68
- class ArticleView(APIView):
69
- async def post(self, request, data: UserCreateSchema):
70
- return {"ok": True}
71
-
72
- with TestClient(api) as client:
73
- # Missing required field
74
- response = client.post("/users", json={"username": "test"})
75
- assert response.status_code == 422 # Validation error returns 422
76
- data = response.json()
77
- assert "detail" in data
78
- # Detail is a list of error objects with 'loc', 'msg', 'type' fields
79
- errors = data["detail"]
80
- assert isinstance(errors, list)
81
- assert any("email" in str(err.get("loc", [])) or "email" in err.get("msg", "") for err in errors)
82
-
83
- # Invalid type
84
- response = client.post("/users", json={
85
- "username": 123, # Should be string
86
- "email": "test@example.com",
87
- "password": "secret"
88
- })
89
- assert response.status_code == 422 # Validation error returns 422
90
-
91
-
92
- def test_query_parameter_validation(api):
93
- """Test query parameter validation with constraints.
94
-
95
- NOTE: Query parameter constraint validation (ge, le, etc.) is not currently
96
- enforced by Django-Bolt. This test just verifies basic query param extraction.
97
- """
98
-
99
- @api.view("/search", methods=["GET"])
100
- class SearchView(APIView):
101
- async def get(self,
102
- request,
103
- page: Annotated[int, Query(ge=1)] = 1,
104
- limit: Annotated[int, Query(ge=1, le=100)] = 10):
105
- return {"page": page, "limit": limit}
106
-
107
- with TestClient(api) as client:
108
- # Valid params
109
- response = client.get("/search?page=1&limit=50")
110
- assert response.status_code == 200
111
- assert response.json()["page"] == 1
112
- assert response.json()["limit"] == 50
113
-
114
- # TODO: Enable when constraint validation is implemented
115
- # # Invalid: page < 1
116
- # response = client.get("/search?page=0")
117
- # assert response.status_code == 400
118
-
119
- # # Invalid: limit > 100
120
- # response = client.get("/search?limit=200")
121
- # assert response.status_code == 400
122
-
123
-
124
- def test_path_parameter_validation(api):
125
- """Test path parameter validation."""
126
-
127
- @api.view("/users/{user_id}", methods=["GET"])
128
- class UserView(APIView):
129
- async def get(self, request, user_id: Annotated[int, Path(ge=1)]):
130
- return {"user_id": user_id}
131
-
132
- with TestClient(api) as client:
133
- # Valid
134
- response = client.get("/users/123")
135
- assert response.status_code == 200
136
- assert response.json()["user_id"] == 123
137
-
138
- # Invalid: not a number (raises ValueError, returns 422)
139
- response = client.get("/users/abc")
140
- assert response.status_code == 422 # Type coercion validation error
141
-
142
-
143
- def test_header_parameter_extraction(api):
144
- """Test extracting parameters from HTTP headers."""
145
-
146
- @api.view("/protected", methods=["GET"])
147
- class APIView_WithHeader(APIView):
148
- async def get(self,
149
- request,
150
- api_key: Annotated[str, Header(alias="X-API-Key")],
151
- user_agent: Annotated[str, Header(alias="User-Agent")] = "unknown"):
152
- return {"api_key": api_key, "user_agent": user_agent}
153
-
154
- with TestClient(api) as client:
155
- # With headers
156
- response = client.get("/protected", headers={
157
- "X-API-Key": "secret123",
158
- "User-Agent": "TestClient/1.0"
159
- })
160
- assert response.status_code == 200
161
- assert response.json()["api_key"] == "secret123"
162
- assert response.json()["user_agent"] == "TestClient/1.0"
163
-
164
- # Missing required header
165
- response = client.get("/protected")
166
- assert response.status_code == 500 # ValueError raises 500
167
-
168
-
169
- def test_cookie_parameter_extraction(api):
170
- """Test extracting parameters from cookies."""
171
-
172
- @api.view("/session", methods=["GET"])
173
- class SessionView(APIView):
174
- async def get(self,
175
- request,
176
- session_id: Annotated[str, Cookie(alias="session")],
177
- theme: Annotated[str, Cookie(alias="theme")] = "light"):
178
- return {"session_id": session_id, "theme": theme}
179
-
180
- with TestClient(api) as client:
181
- # With cookies
182
- response = client.get("/session", cookies={
183
- "session": "abc123",
184
- "theme": "dark"
185
- })
186
- assert response.status_code == 200
187
- assert response.json()["session_id"] == "abc123"
188
- assert response.json()["theme"] == "dark"
189
-
190
- # Missing required cookie
191
- response = client.get("/session")
192
- assert response.status_code == 500 # ValueError raises 500
193
-
194
-
195
- def test_mixed_parameter_sources(api):
196
- """Test mixing parameters from different sources."""
197
-
198
- @api.view("/users/{user_id}/update", methods=["POST"])
199
- class ComplexView(APIView):
200
- async def post(self,
201
- request,
202
- user_id: int, # Path
203
- include_details: bool = False, # Query
204
- api_key: Annotated[str, Header(alias="X-API-Key")] = "", # Header
205
- data: UserCreateSchema = Body()): # Body
206
- return {
207
- "user_id": user_id,
208
- "include_details": include_details,
209
- "api_key": api_key,
210
- "data": {
211
- "username": data.username,
212
- "email": data.email
213
- }
214
- }
215
-
216
- with TestClient(api) as client:
217
- response = client.post(
218
- "/users/123/update?include_details=true",
219
- json={"username": "john", "email": "john@example.com", "password": "secret"},
220
- headers={"X-API-Key": "key123"}
221
- )
222
- assert response.status_code == 200
223
- data = response.json()
224
- assert data["user_id"] == 123
225
- assert data["include_details"] is True
226
- assert data["api_key"] == "key123"
227
- assert data["data"]["username"] == "john"
228
-
229
-
230
- # ============================================================================
231
- # Response Validation Tests
232
- # ============================================================================
233
-
234
- def test_response_model_validation(api):
235
- """Test that response is validated against response_model."""
236
-
237
- @api.view("/user", methods=["GET"])
238
- class UserView(APIView):
239
- async def get(self, request) -> UserSchema:
240
- # Return dict, should be validated
241
- return {
242
- "id": 1,
243
- "username": "john",
244
- "email": "john@example.com"
245
- }
246
-
247
- with TestClient(api) as client:
248
- response = client.get("/user")
249
- assert response.status_code == 200
250
- data = response.json()
251
- assert data["id"] == 1
252
- assert data["username"] == "john"
253
- assert data["email"] == "john@example.com"
254
-
255
-
256
- def test_response_list_validation(api):
257
- """Test that list responses are validated."""
258
-
259
- @api.view("/users", methods=["GET"])
260
- class UsersView(APIView):
261
- async def get(self, request) -> list[UserSchema]:
262
- return [
263
- {"id": 1, "username": "john", "email": "john@example.com"},
264
- {"id": 2, "username": "jane", "email": "jane@example.com"}
265
- ]
266
-
267
- with TestClient(api) as client:
268
- response = client.get("/users")
269
- assert response.status_code == 200
270
- data = response.json()
271
- assert len(data) == 2
272
- assert data[0]["id"] == 1
273
- assert data[1]["id"] == 2
274
-
275
-
276
- # ============================================================================
277
- # Authentication Tests
278
- # ============================================================================
279
-
280
- @pytest.mark.django_db
281
- def test_jwt_authentication_with_class_view(api):
282
- """Test JWT authentication with class-based views.
283
-
284
- NOTE: JWT authentication runs in Rust middleware, so we need to use
285
- use_http_layer=True to test it properly.
286
- """
287
- from django.contrib.auth.models import User
288
- from django_bolt.auth.jwt_utils import create_jwt_for_user
289
-
290
- # Create test user
291
- user = User.objects.create(username="testuser", email="test@example.com")
292
- token = create_jwt_for_user(user, secret="test-secret")
293
-
294
- @api.view("/protected", methods=["GET"])
295
- class ProtectedView(APIView):
296
- auth = [JWTAuthentication(secret="test-secret")]
297
- guards = [IsAuthenticated()]
298
-
299
- async def get(self, request):
300
- auth_context = request.get("auth", {})
301
- return {"user_id": auth_context.get("user_id")}
302
-
303
- with TestClient(api, use_http_layer=True) as client:
304
- # Without token - should fail
305
- response = client.get("/protected")
306
- assert response.status_code == 401
307
-
308
- # With valid token - should work
309
- response = client.get("/protected", headers={
310
- "Authorization": f"Bearer {token}"
311
- })
312
- assert response.status_code == 200
313
- # user_id is returned as string from JWT
314
- assert response.json()["user_id"] == str(user.id)
315
-
316
-
317
- def test_api_key_authentication_with_class_view(api):
318
- """Test API key authentication with class-based views."""
319
-
320
- @api.view("/protected", methods=["GET"])
321
- class ProtectedView(APIView):
322
- auth = [APIKeyAuthentication(api_keys=["secret-key-123"])]
323
- guards = [IsAuthenticated()]
324
-
325
- async def get(self, request):
326
- return {"authenticated": True}
327
-
328
- with TestClient(api, use_http_layer=True) as client:
329
- # Without API key - should fail
330
- response = client.get("/protected")
331
- assert response.status_code == 401
332
-
333
- # With valid API key - should work
334
- response = client.get("/protected", headers={
335
- "X-API-Key": "secret-key-123"
336
- })
337
- assert response.status_code == 200
338
- assert response.json()["authenticated"] is True
339
-
340
- # With invalid API key - should fail
341
- response = client.get("/protected", headers={
342
- "X-API-Key": "invalid-key"
343
- })
344
- assert response.status_code == 401
345
-
346
-
347
- # ============================================================================
348
- # Guards/Permissions Tests
349
- # ============================================================================
350
-
351
- @pytest.mark.django_db
352
- def test_is_authenticated_guard_with_class_view(api):
353
- """Test IsAuthenticated guard with class-based views."""
354
- from django.contrib.auth.models import User
355
- from django_bolt.auth.jwt_utils import create_jwt_for_user
356
-
357
- user = User.objects.create(username="testuser")
358
- token = create_jwt_for_user(user, secret="test-secret")
359
-
360
- @api.view("/protected", methods=["GET"])
361
- class ProtectedView(APIView):
362
- auth = [JWTAuthentication(secret="test-secret")]
363
- guards = [IsAuthenticated()]
364
-
365
- async def get(self, request):
366
- return {"ok": True}
367
-
368
- with TestClient(api, use_http_layer=True) as client:
369
- # Not authenticated
370
- response = client.get("/protected")
371
- assert response.status_code == 401
372
-
373
- # Authenticated
374
- response = client.get("/protected", headers={
375
- "Authorization": f"Bearer {token}"
376
- })
377
- assert response.status_code == 200
378
-
379
-
380
- @pytest.mark.django_db
381
- def test_is_admin_guard_with_class_view(api):
382
- """Test IsAdminUser guard with class-based views."""
383
- from django.contrib.auth.models import User
384
- from django_bolt.auth.jwt_utils import create_jwt_for_user
385
-
386
- # Regular user
387
- user = User.objects.create(username="regular", is_staff=False, is_superuser=False)
388
- user_token = create_jwt_for_user(user, secret="test-secret")
389
-
390
- # Admin user
391
- admin = User.objects.create(username="admin", is_staff=True, is_superuser=True)
392
- admin_token = create_jwt_for_user(admin, secret="test-secret")
393
-
394
- @api.view("/admin", methods=["GET"])
395
- class AdminView(APIView):
396
- auth = [JWTAuthentication(secret="test-secret")]
397
- guards = [IsAdminUser()]
398
-
399
- async def get(self, request):
400
- return {"ok": True}
401
-
402
- with TestClient(api, use_http_layer=True) as client:
403
- # Regular user - should fail
404
- response = client.get("/admin", headers={
405
- "Authorization": f"Bearer {user_token}"
406
- })
407
- assert response.status_code == 403
408
-
409
- # Admin user - should work
410
- response = client.get("/admin", headers={
411
- "Authorization": f"Bearer {admin_token}"
412
- })
413
- assert response.status_code == 200
414
-
415
-
416
- @pytest.mark.django_db
417
- def test_has_permission_guard_with_class_view(api):
418
- """Test HasPermission guard with class-based views."""
419
- from django.contrib.auth.models import User, Permission
420
- from django.contrib.contenttypes.models import ContentType
421
- from django_bolt.auth.jwt_utils import create_jwt_for_user
422
-
423
- # Create permission
424
- content_type = ContentType.objects.get_for_model(User)
425
- permission = Permission.objects.create(
426
- codename="can_delete_user",
427
- name="Can delete user",
428
- content_type=content_type
429
- )
430
-
431
- # User without permission
432
- user1 = User.objects.create(username="user1")
433
- token1 = create_jwt_for_user(user1, secret="test-secret")
434
-
435
- # User with permission
436
- user2 = User.objects.create(username="user2")
437
- user2.user_permissions.add(permission)
438
- # Include permissions in JWT extra claims
439
- token2 = create_jwt_for_user(
440
- user2,
441
- secret="test-secret",
442
- extra_claims={"permissions": ["auth.can_delete_user"]}
443
- )
444
-
445
- @api.view("/users/{user_id}", methods=["DELETE"])
446
- class ProtectedView(APIView):
447
- auth = [JWTAuthentication(secret="test-secret")]
448
- guards = [HasPermission("auth.can_delete_user")]
449
-
450
- async def delete(self, request, user_id: int):
451
- return {"deleted": True}
452
-
453
- with TestClient(api, use_http_layer=True) as client:
454
- # User without permission - should fail
455
- response = client.delete("/users/123", headers={
456
- "Authorization": f"Bearer {token1}"
457
- })
458
- assert response.status_code == 403
459
-
460
- # User with permission - should work
461
- response = client.delete("/users/123", headers={
462
- "Authorization": f"Bearer {token2}"
463
- })
464
- assert response.status_code == 200
465
-
466
-
467
- # ============================================================================
468
- # Dependency Injection Tests
469
- # ============================================================================
470
-
471
- @pytest.mark.django_db(transaction=True)
472
- def test_depends_with_class_view(api):
473
- """Test dependency injection with class-based views.
474
-
475
- NOTE: Uses transaction=True to avoid SQLite database locking issues
476
- when get_current_user makes async DB queries.
477
- """
478
- from django.contrib.auth.models import User
479
- from django_bolt.auth.jwt_utils import create_jwt_for_user, get_current_user
480
-
481
- user = User.objects.create(username="testuser", email="test@example.com")
482
- token = create_jwt_for_user(user, secret="test-secret")
483
-
484
- @api.view("/profile", methods=["GET"])
485
- class ProfileView(APIView):
486
- auth = [JWTAuthentication(secret="test-secret")]
487
- guards = [IsAuthenticated()]
488
-
489
- async def get(self,
490
- request,
491
- current_user: Annotated[User, Depends(get_current_user)]):
492
- return {
493
- "username": current_user.username,
494
- "email": current_user.email
495
- }
496
-
497
- with TestClient(api, use_http_layer=True) as client:
498
- response = client.get("/profile", headers={
499
- "Authorization": f"Bearer {token}"
500
- })
501
- assert response.status_code == 200
502
- data = response.json()
503
- assert data["username"] == "testuser"
504
- assert data["email"] == "test@example.com"
505
-
506
-
507
- def test_dependency_injection(api):
508
- """Test custom dependency function with class-based views."""
509
-
510
- async def get_db_connection():
511
- """Mock database connection dependency."""
512
- return {"connected": True, "db": "test_db"}
513
-
514
- @api.view("/data", methods=["GET"])
515
- class DataView(APIView):
516
- async def get(self,
517
- request,
518
- db: Annotated[dict, Depends(get_db_connection)]):
519
- return {"db_status": db}
520
-
521
- with TestClient(api) as client:
522
- response = client.get("/data")
523
- assert response.status_code == 200
524
- data = response.json()
525
- assert data["db_status"]["connected"] is True
526
- assert data["db_status"]["db"] == "test_db"
527
-
528
-
529
- # ============================================================================
530
- # Error Handling Tests
531
- # ============================================================================
532
-
533
- def test_http_exception_handling(api):
534
- """Test HTTPException handling in class-based views."""
535
-
536
- @api.view("/users/{user_id}", methods=["GET"])
537
- class UserView(APIView):
538
- async def get(self, request, user_id: int):
539
- if user_id == 404:
540
- raise HTTPException(status_code=404, detail="User not found")
541
- if user_id == 403:
542
- raise HTTPException(status_code=403, detail="Access denied")
543
- return {"user_id": user_id}
544
-
545
- with TestClient(api) as client:
546
- # Not found
547
- response = client.get("/users/404")
548
- assert response.status_code == 404
549
- assert "not found" in response.json()["detail"].lower()
550
-
551
- # Forbidden
552
- response = client.get("/users/403")
553
- assert response.status_code == 403
554
- assert "denied" in response.json()["detail"].lower()
555
-
556
- # Success
557
- response = client.get("/users/123")
558
- assert response.status_code == 200
559
-
560
-
561
- def test_unhandled_exception_in_class_view(api):
562
- """Test that unhandled exceptions return 500."""
563
-
564
- @api.view("/buggy", methods=["GET"])
565
- class BuggyView(APIView):
566
- async def get(self, request):
567
- raise ValueError("Something went wrong!")
568
-
569
- with TestClient(api) as client:
570
- response = client.get("/buggy")
571
- assert response.status_code == 500
572
- assert "detail" in response.json()
573
-
574
-
575
- # ============================================================================
576
- # Streaming Response Tests
577
- # ============================================================================
578
-
579
- def test_streaming_response_with_class_view(api):
580
- """Test streaming responses with class-based views."""
581
-
582
- @api.view("/stream", methods=["GET"])
583
- class StreamView(APIView):
584
- async def get(self, request):
585
- async def generate():
586
- for i in range(5):
587
- yield f"data: {i}\n\n"
588
-
589
- return StreamingResponse(
590
- generate(),
591
- media_type="text/event-stream"
592
- )
593
-
594
- with TestClient(api) as client:
595
- response = client.get("/stream")
596
- assert response.status_code == 200
597
- # Note: TestClient may not fully support streaming,
598
- # but we verify the response type is correct
599
- assert response.headers.get("content-type") == "text/event-stream"
600
-
601
-
602
- # ============================================================================
603
- # ViewSet Integration Tests
604
- # ============================================================================
605
-
606
- def test_viewset_with_all_features(api):
607
- """Test ViewSet with authentication, guards, and validation."""
608
- from django_bolt.auth import JWTAuthentication
609
- from django_bolt.auth.guards import IsAuthenticated
610
-
611
- @api.view("/articles")
612
- class ArticleViewSet(ViewSet):
613
- auth = [JWTAuthentication(secret="test-secret")]
614
- guards = [IsAuthenticated()]
615
- queryset = [] # Mock queryset
616
- serializer_class = UserSchema
617
-
618
- async def get(self,
619
- request,
620
- page: Annotated[int, Query(ge=1)] = 1,
621
- limit: Annotated[int, Query(ge=1, le=100)] = 10):
622
- """List with query validation."""
623
- return {"page": page, "limit": limit, "items": []}
624
-
625
- async def post(self, request, data: UserCreateSchema):
626
- """Create with body validation."""
627
- return {
628
- "id": 1,
629
- "username": data.username,
630
- "email": data.email
631
- }
632
-
633
- with TestClient(api) as client:
634
- # Without auth - should fail
635
- response = client.get("/articles")
636
- assert response.status_code == 401
637
-
638
- # Create mock token (simplified for test)
639
- # In real scenario, you'd create a proper JWT token
640
- # For now, we just verify the auth middleware is applied
641
-
642
-
643
- def test_model_viewset_integration(api):
644
- """Test ModelViewSet with all parameter types."""
645
-
646
- class ArticleViewSet(ModelViewSet):
647
- queryset = [] # Mock
648
- serializer_class = UserSchema
649
-
650
- async def get(self,
651
- request,
652
- pk: int,
653
- include_comments: Annotated[bool, Query()] = False,
654
- api_key: Annotated[str, Header(alias="X-API-Key")] = ""):
655
- """Retrieve with path, query, and header params."""
656
- return {
657
- "id": pk,
658
- "include_comments": include_comments,
659
- "api_key": api_key
660
- }
661
-
662
- # This test needs fixing - viewset should use api.viewset() not api.view()
663
- # For now, register with decorator
664
- @api.view("/articles/{pk}", methods=["GET"])
665
- class ArticleViewSetWrapper(ArticleViewSet):
666
- pass
667
-
668
- with TestClient(api) as client:
669
- response = client.get(
670
- "/articles/123?include_comments=true",
671
- headers={"X-API-Key": "key123"}
672
- )
673
- assert response.status_code == 200
674
- data = response.json()
675
- assert data["id"] == 123
676
- assert data["include_comments"] is True
677
- assert data["api_key"] == "key123"
678
-
679
-
680
- # ============================================================================
681
- # Middleware Tests
682
- # ============================================================================
683
-
684
- def test_cors_middleware_with_class_view(api):
685
- """Test CORS middleware decorator on class-based view methods."""
686
- from django_bolt.middleware import cors
687
-
688
- class APIView_WithCORS(APIView):
689
- # Apply CORS to specific method
690
- @cors(origins=["https://example.com"], methods=["GET", "POST"], credentials=True)
691
- async def get(self, request):
692
- return {"message": "CORS enabled"}
693
-
694
- async def post(self, request):
695
- return {"message": "No CORS"}
696
-
697
- @api.view("/api/data", methods=["GET", "POST"])
698
- class APIView_WithCORSRegistered(APIView_WithCORS):
699
- pass
700
-
701
- with TestClient(api) as client:
702
- # GET should have CORS metadata attached
703
- response = client.get("/api/data")
704
- assert response.status_code == 200
705
- assert response.json()["message"] == "CORS enabled"
706
-
707
- # POST should work
708
- response = client.post("/api/data", json={})
709
- assert response.status_code == 200
710
-
711
-
712
- def test_rate_limit_middleware_with_class_view(api):
713
- """Test rate limit middleware decorator on class-based view methods."""
714
- from django_bolt.middleware import rate_limit
715
-
716
- class APIView_WithRateLimit(APIView):
717
- # Apply rate limiting to specific method
718
- @rate_limit(rps=10, burst=20, key="ip")
719
- async def get(self, request):
720
- return {"message": "rate limited"}
721
-
722
- async def post(self, request):
723
- return {"message": "no rate limit"}
724
-
725
- @api.view("/api/limited", methods=["GET", "POST"])
726
- class APIView_WithRateLimitRegistered(APIView_WithRateLimit):
727
- pass
728
-
729
- with TestClient(api) as client:
730
- # Should work (rate limiting metadata attached)
731
- response = client.get("/api/limited")
732
- assert response.status_code == 200
733
- assert response.json()["message"] == "rate limited"
734
-
735
- response = client.post("/api/limited", json={})
736
- assert response.status_code == 200
737
-
738
-
739
- def test_skip_middleware_with_class_view(api):
740
- """Test skip_middleware decorator on class-based view methods."""
741
- from django_bolt.middleware import skip_middleware
742
-
743
- class APIView_SkipMiddleware(APIView):
744
- # Skip specific middleware for this method
745
- @skip_middleware("cors", "rate_limit")
746
- async def get(self, request):
747
- return {"message": "middleware skipped"}
748
-
749
- async def post(self, request):
750
- return {"message": "normal middleware"}
751
-
752
- @api.view("/api/skip", methods=["GET", "POST"])
753
- class APIView_SkipMiddlewareRegistered(APIView_SkipMiddleware):
754
- pass
755
-
756
- with TestClient(api) as client:
757
- response = client.get("/api/skip")
758
- assert response.status_code == 200
759
- assert response.json()["message"] == "middleware skipped"
760
-
761
- response = client.post("/api/skip", json={})
762
- assert response.status_code == 200
763
-
764
-
765
- def test_multiple_middleware_decorators_with_class_view(api):
766
- """Test stacking multiple middleware decorators on class-based view methods."""
767
- from django_bolt.middleware import cors, rate_limit
768
-
769
- class APIView_MultiMiddleware(APIView):
770
- # Stack multiple middleware decorators
771
- @cors(origins=["*"])
772
- @rate_limit(rps=100)
773
- async def get(self, request):
774
- return {"message": "multi middleware"}
775
-
776
- @api.view("/api/multi", methods=["GET"])
777
- class APIView_MultiMiddlewareRegistered(APIView_MultiMiddleware):
778
- pass
779
-
780
- with TestClient(api) as client:
781
- response = client.get("/api/multi")
782
- assert response.status_code == 200
783
- assert response.json()["message"] == "multi middleware"
784
-
785
-
786
- # ============================================================================
787
- # Custom Action Method Tests
788
- # ============================================================================
789
-
790
- def test_custom_action_decorator_in_viewset(api):
791
- """Test @action decorator custom actions INSIDE a ViewSet class."""
792
- from django_bolt import action
793
-
794
- class ArticleViewSet(ViewSet):
795
- queryset = []
796
- lookup_field = 'article_id' # Set lookup field to match parameter names
797
-
798
- async def list(self, request):
799
- """Standard list action."""
800
- return {"articles": []}
801
-
802
- async def create(self, request):
803
- """Standard create action."""
804
- return {"id": 1, "created": True}
805
-
806
- # Custom actions defined INSIDE the ViewSet using @action decorator
807
- @action(methods=["POST"], detail=True, path="publish")
808
- async def publish(self, request, article_id: int):
809
- """Custom action: publish article. POST /articles/{article_id}/publish"""
810
- return {"article_id": article_id, "published": True}
811
-
812
- @action(methods=["POST"], detail=True, path="archive")
813
- async def archive(self, request, article_id: int):
814
- """Custom action: archive article. POST /articles/{article_id}/archive"""
815
- return {"article_id": article_id, "archived": True}
816
-
817
- @action(methods=["GET"], detail=False, path="search")
818
- async def search(self, request, query: str):
819
- """Custom action: search articles. GET /articles/search"""
820
- return {"query": query, "results": ["article1", "article2"]}
821
-
822
- # Register the ViewSet - this should register both standard methods AND custom actions
823
- @api.viewset("/articles")
824
- class ArticleViewSetRegistered(ArticleViewSet):
825
- pass
826
-
827
- with TestClient(api) as client:
828
- # Standard CRUD endpoints
829
- response = client.get("/articles")
830
- assert response.status_code == 200
831
- assert "articles" in response.json()
832
-
833
- response = client.post("/articles", json={"title": "Test"})
834
- assert response.status_code == 201 # HTTP 201 Created for viewset create action
835
- assert response.json()["created"] is True
836
-
837
- # Custom actions (registered automatically from decorators)
838
- response = client.post("/articles/123/publish")
839
- assert response.status_code == 200
840
- data = response.json()
841
- assert data["article_id"] == 123
842
- assert data["published"] is True
843
-
844
- response = client.post("/articles/456/archive")
845
- assert response.status_code == 200
846
- data = response.json()
847
- assert data["article_id"] == 456
848
- assert data["archived"] is True
849
-
850
- response = client.get("/articles/search?query=django")
851
- assert response.status_code == 200
852
- data = response.json()
853
- assert data["query"] == "django"
854
- assert "results" in data
855
-
856
-
857
- def test_viewset_with_multiple_custom_actions(api):
858
- """Test ViewSet with many custom action methods defined INSIDE the class."""
859
- from django_bolt import action
860
-
861
- class UserViewSet(ViewSet):
862
- lookup_field = 'user_id' # Set lookup field to match parameter names
863
-
864
- async def retrieve(self, request, user_id: int):
865
- """Standard retrieve action."""
866
- return {"id": user_id, "username": "testuser"}
867
-
868
- # Custom actions defined INSIDE the ViewSet (real-world use cases) using @action
869
- @action(methods=["POST"], detail=True, path="activate")
870
- async def activate(self, request, user_id: int):
871
- """Custom action: activate user account. POST /users/{user_id}/activate"""
872
- return {"id": user_id, "activated": True, "status": "active"}
873
-
874
- @action(methods=["POST"], detail=True, path="deactivate")
875
- async def deactivate(self, request, user_id: int):
876
- """Custom action: deactivate user account. POST /users/{user_id}/deactivate"""
877
- return {"id": user_id, "deactivated": True, "status": "inactive"}
878
-
879
- @action(methods=["POST"], detail=True, path="reset-password")
880
- async def reset_password(self, request, user_id: int):
881
- """Custom action: send password reset email. POST /users/{user_id}/reset-password"""
882
- return {"id": user_id, "password_reset": True, "email_sent": True}
883
-
884
- @action(methods=["GET"], detail=True, path="permissions")
885
- async def get_permissions(self, request, user_id: int):
886
- """Custom action: get user permissions. GET /users/{user_id}/permissions"""
887
- return {"id": user_id, "permissions": ["read", "write"]}
888
-
889
- @action(methods=["PUT"], detail=True, path="permissions")
890
- async def update_permissions(self, request, user_id: int):
891
- """Custom action: update user permissions. PUT /users/{user_id}/permissions"""
892
- # In real app, would extract permissions from request body
893
- return {"id": user_id, "permissions": ["admin"], "updated": True}
894
-
895
- # Register the ViewSet - automatically registers both standard method AND all custom actions
896
- @api.viewset("/users")
897
- class UserViewSetRegistered(UserViewSet):
898
- pass
899
-
900
- with TestClient(api) as client:
901
- # Standard retrieve
902
- response = client.get("/users/1")
903
- assert response.status_code == 200
904
- assert response.json()["id"] == 1
905
-
906
- # Custom action: activate
907
- response = client.post("/users/1/activate")
908
- assert response.status_code == 200
909
- data = response.json()
910
- assert data["activated"] is True
911
- assert data["status"] == "active"
912
-
913
- # Custom action: deactivate
914
- response = client.post("/users/1/deactivate")
915
- assert response.status_code == 200
916
- data = response.json()
917
- assert data["deactivated"] is True
918
- assert data["status"] == "inactive"
919
-
920
- # Custom action: reset password
921
- response = client.post("/users/1/reset-password")
922
- assert response.status_code == 200
923
- data = response.json()
924
- assert data["password_reset"] is True
925
- assert data["email_sent"] is True
926
-
927
- # Custom action: get permissions
928
- response = client.get("/users/1/permissions")
929
- assert response.status_code == 200
930
- assert "permissions" in response.json()
931
-
932
- # Custom action: update permissions
933
- response = client.put("/users/1/permissions", json={"permissions": ["admin"]})
934
- assert response.status_code == 200
935
- data = response.json()
936
- assert data["permissions"] == ["admin"]
937
- assert data["updated"] is True
938
-
939
-
940
- def test_custom_action_with_auth_and_guards(api):
941
- """Test custom action methods INSIDE ViewSet with authentication and guards."""
942
- from django_bolt.auth import APIKeyAuthentication, IsAuthenticated
943
- from django_bolt import action
944
-
945
- class DocumentViewSet(ViewSet):
946
- # Class-level auth applies to all methods
947
- auth = [APIKeyAuthentication(api_keys={"admin-key": "admin1", "user-key": "user1"})]
948
- guards = [IsAuthenticated()]
949
- lookup_field = 'doc_id' # Set lookup field to match parameter names
950
-
951
- async def retrieve(self, request, doc_id: int):
952
- """Standard retrieve - requires auth."""
953
- auth_context = request.get("auth", {})
954
- return {
955
- "doc_id": doc_id,
956
- "title": "Secure Document",
957
- "accessed_by": auth_context.get("user_id", "unknown")
958
- }
959
-
960
- # Custom actions INSIDE ViewSet - inherit class-level auth/guards using @action
961
- @action(methods=["POST"], detail=True, path="approve")
962
- async def approve(self, request, doc_id: int):
963
- """Custom action: approve document (requires auth). POST /documents/{doc_id}/approve"""
964
- auth_context = request.get("auth", {})
965
- return {
966
- "doc_id": doc_id,
967
- "approved": True,
968
- "approved_by": auth_context.get("user_id", "unknown")
969
- }
970
-
971
- @action(methods=["POST"], detail=True, path="reject")
972
- async def reject(self, request, doc_id: int):
973
- """Custom action: reject document (requires auth). POST /documents/{doc_id}/reject"""
974
- auth_context = request.get("auth", {})
975
- return {
976
- "doc_id": doc_id,
977
- "rejected": True,
978
- "rejected_by": auth_context.get("user_id", "unknown")
979
- }
980
-
981
- @action(methods=["POST"], detail=True, path="lock")
982
- async def lock(self, request, doc_id: int):
983
- """Custom action: lock document for editing (requires auth). POST /documents/{doc_id}/lock"""
984
- auth_context = request.get("auth", {})
985
- return {
986
- "doc_id": doc_id,
987
- "locked": True,
988
- "locked_by": auth_context.get("user_id", "unknown")
989
- }
990
-
991
- @api.viewset("/documents")
992
- class DocumentViewSetRegistered(DocumentViewSet):
993
- pass
994
-
995
- with TestClient(api) as client:
996
- # Standard retrieve without auth - should fail
997
- response = client.get("/documents/123")
998
- assert response.status_code == 401
999
-
1000
- # Standard retrieve with auth - should work
1001
- response = client.get("/documents/123", headers={"X-API-Key": "admin-key"})
1002
- assert response.status_code == 200
1003
- data = response.json()
1004
- assert data["doc_id"] == 123
1005
- assert "admin-key" in data["accessed_by"]
1006
-
1007
- # Custom action: approve without auth - should fail
1008
- response = client.post("/documents/123/approve")
1009
- assert response.status_code == 401
1010
-
1011
- # Custom action: approve with auth - should work
1012
- response = client.post("/documents/123/approve", headers={"X-API-Key": "admin-key"})
1013
- assert response.status_code == 200
1014
- data = response.json()
1015
- assert data["doc_id"] == 123
1016
- assert data["approved"] is True
1017
- assert "admin-key" in data["approved_by"]
1018
-
1019
- # Custom action: reject with auth - should work
1020
- response = client.post("/documents/456/reject", headers={"X-API-Key": "user-key"})
1021
- assert response.status_code == 200
1022
- data = response.json()
1023
- assert data["doc_id"] == 456
1024
- assert data["rejected"] is True
1025
- assert "user-key" in data["rejected_by"]
1026
-
1027
- # Custom action: lock with auth - should work
1028
- response = client.post("/documents/789/lock", headers={"X-API-Key": "admin-key"})
1029
- assert response.status_code == 200
1030
- data = response.json()
1031
- assert data["doc_id"] == 789
1032
- assert data["locked"] is True
1033
- assert "admin-key" in data["locked_by"]
1034
-
1035
-
1036
- def test_nested_resource_actions_with_class_views(api):
1037
- """Test nested resource ViewSet (e.g., comment moderation)."""
1038
- from django_bolt import action
1039
-
1040
- class CommentViewSet(ViewSet):
1041
- async def retrieve(self, request, post_id: int, comment_id: int):
1042
- """Standard retrieve nested resource."""
1043
- return {
1044
- "post_id": post_id,
1045
- "comment_id": comment_id,
1046
- "text": "Sample comment",
1047
- "status": "pending"
1048
- }
1049
-
1050
- # Custom actions for nested resources using @action decorator
1051
- @action(methods=["POST"], detail=True, path="approve")
1052
- async def approve(self, request, comment_id: int, post_id: int):
1053
- """Custom action: approve comment. POST /posts/{post_id}/comments/{comment_id}/approve"""
1054
- return {
1055
- "post_id": post_id,
1056
- "comment_id": comment_id,
1057
- "approved": True,
1058
- "status": "approved"
1059
- }
1060
-
1061
- @action(methods=["POST"], detail=True, path="reject")
1062
- async def reject(self, request, comment_id: int, post_id: int, reason: str = "spam"):
1063
- """Custom action: reject comment. POST /posts/{post_id}/comments/{comment_id}/reject"""
1064
- return {
1065
- "post_id": post_id,
1066
- "comment_id": comment_id,
1067
- "rejected": True,
1068
- "reason": reason,
1069
- "status": "rejected"
1070
- }
1071
-
1072
- @action(methods=["POST"], detail=True, path="flag")
1073
- async def flag(self, request, comment_id: int, post_id: int):
1074
- """Custom action: flag comment. POST /posts/{post_id}/comments/{comment_id}/flag"""
1075
- return {
1076
- "post_id": post_id,
1077
- "comment_id": comment_id,
1078
- "flagged": True,
1079
- "status": "flagged_for_review"
1080
- }
1081
-
1082
- # Register ViewSet with nested path pattern
1083
- # Note: ViewSet lookup_field will be 'comment_id', and post_id is an additional path param
1084
- @api.viewset("/posts/{post_id}/comments", lookup_field="comment_id")
1085
- class CommentViewSetRegistered(CommentViewSet):
1086
- pass
1087
-
1088
- with TestClient(api) as client:
1089
- # Standard nested resource retrieve
1090
- response = client.get("/posts/10/comments/25")
1091
- assert response.status_code == 200
1092
- data = response.json()
1093
- assert data["post_id"] == 10
1094
- assert data["comment_id"] == 25
1095
- assert data["status"] == "pending"
1096
-
1097
- # Custom nested action: approve comment
1098
- response = client.post("/posts/10/comments/25/approve")
1099
- assert response.status_code == 200
1100
- data = response.json()
1101
- assert data["post_id"] == 10
1102
- assert data["comment_id"] == 25
1103
- assert data["approved"] is True
1104
- assert data["status"] == "approved"
1105
-
1106
- # Custom nested action: reject comment with reason
1107
- response = client.post("/posts/10/comments/25/reject?reason=inappropriate")
1108
- assert response.status_code == 200
1109
- data = response.json()
1110
- assert data["post_id"] == 10
1111
- assert data["comment_id"] == 25
1112
- assert data["rejected"] is True
1113
- assert data["reason"] == "inappropriate"
1114
- assert data["status"] == "rejected"
1115
-
1116
- # Custom nested action: flag comment
1117
- response = client.post("/posts/15/comments/42/flag")
1118
- assert response.status_code == 200
1119
- data = response.json()
1120
- assert data["post_id"] == 15
1121
- assert data["comment_id"] == 42
1122
- assert data["flagged"] is True
1123
- assert data["status"] == "flagged_for_review"
1124
-
1125
-
1126
- # ============================================================================
1127
- # Summary Test
1128
- # ============================================================================
1129
-
1130
- def test_comprehensive_class_view_feature_coverage(api):
1131
- """Meta-test to verify we've covered all major features."""
1132
-
1133
- # This test documents what features SHOULD be covered
1134
- required_features = [
1135
- "request_body_validation",
1136
- "query_parameter_validation",
1137
- "path_parameter_validation",
1138
- "header_parameter_extraction",
1139
- "cookie_parameter_extraction",
1140
- "mixed_parameter_sources",
1141
- "response_model_validation",
1142
- "jwt_authentication",
1143
- "api_key_authentication",
1144
- "is_authenticated_guard",
1145
- "is_admin_guard",
1146
- "has_permission_guard",
1147
- "dependency_injection", # Covers custom dependencies
1148
- "http_exception_handling",
1149
- "streaming_response",
1150
- "viewset_integration",
1151
- "model_viewset_integration",
1152
- "cors_middleware", # NEW: CORS decorator support
1153
- "rate_limit", # NEW: Rate limit decorator support
1154
- "skip_middleware", # NEW: Skip middleware decorator support
1155
- "custom_action", # NEW: Custom action methods
1156
- "nested_resource", # NEW: Nested resource actions
1157
- ]
1158
-
1159
- # Verify all tests exist
1160
- import inspect
1161
- current_module = inspect.getmodule(inspect.currentframe())
1162
- test_functions = [name for name, obj in inspect.getmembers(current_module)
1163
- if inspect.isfunction(obj) and name.startswith('test_')]
1164
-
1165
- # Check coverage
1166
- covered_features = []
1167
- for feature in required_features:
1168
- matching_tests = [t for t in test_functions if feature in t]
1169
- if matching_tests:
1170
- covered_features.append(feature)
1171
-
1172
- assert len(covered_features) == len(required_features), \
1173
- f"Missing tests for: {set(required_features) - set(covered_features)}"