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,1173 @@
|
|
|
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)}"
|