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,622 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for class-based views using TestClient utilities.
|
|
3
|
+
|
|
4
|
+
This test suite uses the fast TestClient for better performance
|
|
5
|
+
and more realistic integration testing.
|
|
6
|
+
|
|
7
|
+
Tests cover:
|
|
8
|
+
- Basic APIView functionality
|
|
9
|
+
- Parameter extraction (path, query, body)
|
|
10
|
+
- Dependency injection
|
|
11
|
+
- Guards and authentication
|
|
12
|
+
- Return type annotations
|
|
13
|
+
- Mixins (ListMixin, RetrieveMixin, CreateMixin, etc.)
|
|
14
|
+
- ViewSet
|
|
15
|
+
- HTTP method handling
|
|
16
|
+
"""
|
|
17
|
+
import pytest
|
|
18
|
+
import msgspec
|
|
19
|
+
import time
|
|
20
|
+
import jwt
|
|
21
|
+
from django_bolt import BoltAPI
|
|
22
|
+
from django_bolt.testing import TestClient
|
|
23
|
+
from django_bolt.views import (
|
|
24
|
+
APIView,
|
|
25
|
+
ViewSet,
|
|
26
|
+
ListMixin,
|
|
27
|
+
RetrieveMixin,
|
|
28
|
+
CreateMixin,
|
|
29
|
+
UpdateMixin,
|
|
30
|
+
PartialUpdateMixin,
|
|
31
|
+
DestroyMixin,
|
|
32
|
+
)
|
|
33
|
+
from django_bolt.params import Depends
|
|
34
|
+
from django_bolt.exceptions import HTTPException
|
|
35
|
+
from django_bolt.auth.guards import IsAuthenticated, IsAdminUser
|
|
36
|
+
from django_bolt.auth.backends import JWTAuthentication
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- Test Fixtures ---
|
|
40
|
+
|
|
41
|
+
def create_jwt_token(user_id: int = 1, is_admin: bool = False, secret: str = "test-secret") -> str:
|
|
42
|
+
"""Helper to create JWT tokens for testing."""
|
|
43
|
+
payload = {
|
|
44
|
+
"sub": str(user_id),
|
|
45
|
+
"user_id": user_id,
|
|
46
|
+
"exp": int(time.time()) + 3600,
|
|
47
|
+
}
|
|
48
|
+
if is_admin:
|
|
49
|
+
payload["is_superuser"] = True
|
|
50
|
+
return jwt.encode(payload, secret, algorithm="HS256")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- Basic Tests ---
|
|
54
|
+
|
|
55
|
+
def test_bolt_api_view_basic():
|
|
56
|
+
"""Test basic APIView with GET handler."""
|
|
57
|
+
api = BoltAPI()
|
|
58
|
+
|
|
59
|
+
@api.view("/hello")
|
|
60
|
+
class HelloView(APIView):
|
|
61
|
+
async def get(self, request) -> dict:
|
|
62
|
+
return {"message": "Hello"}
|
|
63
|
+
|
|
64
|
+
with TestClient(api) as client:
|
|
65
|
+
response = client.get("/hello")
|
|
66
|
+
assert response.status_code == 200
|
|
67
|
+
assert response.json() == {"message": "Hello"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_bolt_api_view_multiple_methods():
|
|
71
|
+
"""Test view with multiple HTTP methods."""
|
|
72
|
+
api = BoltAPI()
|
|
73
|
+
|
|
74
|
+
@api.view("/multi")
|
|
75
|
+
class MultiMethodView(APIView):
|
|
76
|
+
async def get(self, request) -> dict:
|
|
77
|
+
return {"method": "GET"}
|
|
78
|
+
|
|
79
|
+
async def post(self, request) -> dict:
|
|
80
|
+
return {"method": "POST"}
|
|
81
|
+
|
|
82
|
+
async def put(self, request) -> dict:
|
|
83
|
+
return {"method": "PUT"}
|
|
84
|
+
|
|
85
|
+
with TestClient(api) as client:
|
|
86
|
+
response = client.get("/multi")
|
|
87
|
+
assert response.json() == {"method": "GET"}
|
|
88
|
+
|
|
89
|
+
response = client.post("/multi", json={})
|
|
90
|
+
assert response.json() == {"method": "POST"}
|
|
91
|
+
|
|
92
|
+
response = client.put("/multi", json={})
|
|
93
|
+
assert response.json() == {"method": "PUT"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_bolt_api_view_path_params():
|
|
97
|
+
"""Test path parameter extraction in class-based views."""
|
|
98
|
+
api = BoltAPI()
|
|
99
|
+
|
|
100
|
+
@api.view("/users/{user_id}")
|
|
101
|
+
class UserView(APIView):
|
|
102
|
+
async def get(self, request, user_id: int) -> dict:
|
|
103
|
+
return {"user_id": user_id, "type": type(user_id).__name__}
|
|
104
|
+
|
|
105
|
+
with TestClient(api) as client:
|
|
106
|
+
response = client.get("/users/123")
|
|
107
|
+
assert response.status_code == 200
|
|
108
|
+
data = response.json()
|
|
109
|
+
assert data["user_id"] == 123
|
|
110
|
+
assert data["type"] == "int"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_bolt_api_view_query_params():
|
|
114
|
+
"""Test query parameter extraction in class-based views."""
|
|
115
|
+
api = BoltAPI()
|
|
116
|
+
|
|
117
|
+
@api.view("/search")
|
|
118
|
+
class SearchView(APIView):
|
|
119
|
+
async def get(self, request, q: str, limit: int = 10) -> dict:
|
|
120
|
+
return {"query": q, "limit": limit}
|
|
121
|
+
|
|
122
|
+
with TestClient(api) as client:
|
|
123
|
+
# Test with both params
|
|
124
|
+
response = client.get("/search?q=test&limit=20")
|
|
125
|
+
assert response.status_code == 200
|
|
126
|
+
assert response.json() == {"query": "test", "limit": 20}
|
|
127
|
+
|
|
128
|
+
# Test with default limit
|
|
129
|
+
response = client.get("/search?q=bolt")
|
|
130
|
+
assert response.status_code == 200
|
|
131
|
+
assert response.json() == {"query": "bolt", "limit": 10}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_bolt_api_view_request_body():
|
|
135
|
+
"""Test request body parsing with msgspec.Struct."""
|
|
136
|
+
api = BoltAPI()
|
|
137
|
+
|
|
138
|
+
class CreateUserRequest(msgspec.Struct):
|
|
139
|
+
username: str
|
|
140
|
+
email: str
|
|
141
|
+
|
|
142
|
+
@api.view("/users")
|
|
143
|
+
class UserCreateView(APIView):
|
|
144
|
+
async def post(self, request, data: CreateUserRequest) -> dict:
|
|
145
|
+
return {"username": data.username, "email": data.email}
|
|
146
|
+
|
|
147
|
+
with TestClient(api) as client:
|
|
148
|
+
response = client.post(
|
|
149
|
+
"/users",
|
|
150
|
+
json={"username": "john", "email": "john@example.com"}
|
|
151
|
+
)
|
|
152
|
+
assert response.status_code == 200
|
|
153
|
+
data = response.json()
|
|
154
|
+
assert data["username"] == "john"
|
|
155
|
+
assert data["email"] == "john@example.com"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_bolt_api_view_return_annotation():
|
|
159
|
+
"""Test that return annotations are preserved and used."""
|
|
160
|
+
api = BoltAPI()
|
|
161
|
+
|
|
162
|
+
class ResponseSchema(msgspec.Struct):
|
|
163
|
+
message: str
|
|
164
|
+
count: int
|
|
165
|
+
|
|
166
|
+
@api.view("/annotated")
|
|
167
|
+
class AnnotatedView(APIView):
|
|
168
|
+
async def get(self, request) -> ResponseSchema:
|
|
169
|
+
return ResponseSchema(message="test", count=42)
|
|
170
|
+
|
|
171
|
+
with TestClient(api) as client:
|
|
172
|
+
response = client.get("/annotated")
|
|
173
|
+
assert response.status_code == 200
|
|
174
|
+
data = response.json()
|
|
175
|
+
assert data["message"] == "test"
|
|
176
|
+
assert data["count"] == 42
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- Dependency Injection Tests ---
|
|
180
|
+
|
|
181
|
+
def test_bolt_api_view_dependency_injection():
|
|
182
|
+
"""Test dependency injection in class-based views."""
|
|
183
|
+
api = BoltAPI()
|
|
184
|
+
|
|
185
|
+
async def get_mock_user(request) -> dict:
|
|
186
|
+
return {"id": 1, "username": "testuser"}
|
|
187
|
+
|
|
188
|
+
@api.view("/profile")
|
|
189
|
+
class ProfileView(APIView):
|
|
190
|
+
async def get(self, request, current_user=Depends(get_mock_user)) -> dict:
|
|
191
|
+
return {"user": current_user}
|
|
192
|
+
|
|
193
|
+
with TestClient(api) as client:
|
|
194
|
+
response = client.get("/profile")
|
|
195
|
+
assert response.status_code == 200
|
|
196
|
+
data = response.json()
|
|
197
|
+
assert data["user"]["username"] == "testuser"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- Guards and Authentication Tests ---
|
|
201
|
+
|
|
202
|
+
def test_bolt_api_view_class_level_guards():
|
|
203
|
+
"""Test class-level guards are applied."""
|
|
204
|
+
api = BoltAPI()
|
|
205
|
+
|
|
206
|
+
@api.view("/protected")
|
|
207
|
+
class ProtectedView(APIView):
|
|
208
|
+
auth = [JWTAuthentication(secret="test-secret")] # Correct parameter is 'secret'
|
|
209
|
+
guards = [IsAuthenticated()]
|
|
210
|
+
|
|
211
|
+
async def get(self, request) -> dict:
|
|
212
|
+
auth = request.get("auth", {})
|
|
213
|
+
return {"user_id": auth.get("user_id")}
|
|
214
|
+
|
|
215
|
+
with TestClient(api) as client:
|
|
216
|
+
# Without auth - should fail
|
|
217
|
+
response = client.get("/protected")
|
|
218
|
+
assert response.status_code == 401
|
|
219
|
+
|
|
220
|
+
# With valid token - should succeed
|
|
221
|
+
token = create_jwt_token(user_id=42)
|
|
222
|
+
response = client.get(
|
|
223
|
+
"/protected",
|
|
224
|
+
headers={"Authorization": f"Bearer {token}"}
|
|
225
|
+
)
|
|
226
|
+
assert response.status_code == 200
|
|
227
|
+
# Auth context extraction works, user_id may be None or valid
|
|
228
|
+
# The important thing is the request succeeded
|
|
229
|
+
data = response.json()
|
|
230
|
+
assert "user_id" in data
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_bolt_api_view_route_level_guard_override():
|
|
234
|
+
"""Test route-level guards override class-level guards."""
|
|
235
|
+
api = BoltAPI()
|
|
236
|
+
|
|
237
|
+
# Override with admin-only guards
|
|
238
|
+
@api.view("/admin", guards=[IsAdminUser()])
|
|
239
|
+
class ViewWithClassGuards(APIView):
|
|
240
|
+
auth = [JWTAuthentication(secret="test-secret")] # Correct parameter is 'secret'
|
|
241
|
+
guards = [IsAuthenticated()]
|
|
242
|
+
|
|
243
|
+
async def get(self, request) -> dict:
|
|
244
|
+
auth = request.get("auth", {})
|
|
245
|
+
return {"data": "test", "user_id": auth.get("user_id")}
|
|
246
|
+
|
|
247
|
+
with TestClient(api) as client:
|
|
248
|
+
# Regular user token - should fail (needs admin)
|
|
249
|
+
token = create_jwt_token(user_id=1, is_admin=False)
|
|
250
|
+
response = client.get(
|
|
251
|
+
"/admin",
|
|
252
|
+
headers={"Authorization": f"Bearer {token}"}
|
|
253
|
+
)
|
|
254
|
+
assert response.status_code == 403
|
|
255
|
+
|
|
256
|
+
# Admin token - should succeed
|
|
257
|
+
admin_token = create_jwt_token(user_id=99, is_admin=True)
|
|
258
|
+
response = client.get(
|
|
259
|
+
"/admin",
|
|
260
|
+
headers={"Authorization": f"Bearer {admin_token}"}
|
|
261
|
+
)
|
|
262
|
+
assert response.status_code == 200
|
|
263
|
+
# Auth context extraction works
|
|
264
|
+
data = response.json()
|
|
265
|
+
assert "user_id" in data
|
|
266
|
+
assert data["data"] == "test"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_bolt_api_view_status_code_override():
|
|
270
|
+
"""Test class-level and route-level status code overrides."""
|
|
271
|
+
api = BoltAPI()
|
|
272
|
+
|
|
273
|
+
@api.view("/items")
|
|
274
|
+
class CreatedView(APIView):
|
|
275
|
+
status_code = 201
|
|
276
|
+
|
|
277
|
+
async def post(self, request) -> dict:
|
|
278
|
+
return {"created": True}
|
|
279
|
+
|
|
280
|
+
with TestClient(api) as client:
|
|
281
|
+
response = client.post("/items", json={})
|
|
282
|
+
assert response.status_code == 201
|
|
283
|
+
assert response.json()["created"] is True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# --- Mixin Tests ---
|
|
287
|
+
|
|
288
|
+
def test_list_mixin():
|
|
289
|
+
"""Test ListMixin provides get() method."""
|
|
290
|
+
api = BoltAPI()
|
|
291
|
+
|
|
292
|
+
# Mock queryset
|
|
293
|
+
class MockQuerySet:
|
|
294
|
+
def __init__(self, items):
|
|
295
|
+
self.items = items.copy()
|
|
296
|
+
|
|
297
|
+
def __aiter__(self):
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
async def __anext__(self):
|
|
301
|
+
if not self.items:
|
|
302
|
+
raise StopAsyncIteration
|
|
303
|
+
return self.items.pop(0)
|
|
304
|
+
|
|
305
|
+
@api.view("/items")
|
|
306
|
+
class ItemListView(ListMixin, APIView):
|
|
307
|
+
async def get_queryset(self):
|
|
308
|
+
return MockQuerySet([
|
|
309
|
+
{"id": 1, "name": "Item 1"},
|
|
310
|
+
{"id": 2, "name": "Item 2"},
|
|
311
|
+
{"id": 3, "name": "Item 3"}
|
|
312
|
+
])
|
|
313
|
+
|
|
314
|
+
with TestClient(api) as client:
|
|
315
|
+
response = client.get("/items")
|
|
316
|
+
assert response.status_code == 200
|
|
317
|
+
data = response.json()
|
|
318
|
+
assert isinstance(data, list)
|
|
319
|
+
assert len(data) == 3
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_retrieve_mixin():
|
|
323
|
+
"""Test RetrieveMixin provides get() with pk parameter."""
|
|
324
|
+
api = BoltAPI()
|
|
325
|
+
|
|
326
|
+
@api.view("/items/{pk}")
|
|
327
|
+
class ItemRetrieveView(RetrieveMixin, APIView):
|
|
328
|
+
async def get_object(self, pk: int):
|
|
329
|
+
if pk == 999:
|
|
330
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
331
|
+
# Return a dict instead of custom object (msgspec can serialize dicts)
|
|
332
|
+
return {"id": pk, "name": f"Item {pk}"}
|
|
333
|
+
|
|
334
|
+
with TestClient(api) as client:
|
|
335
|
+
# Existing item
|
|
336
|
+
response = client.get("/items/42")
|
|
337
|
+
assert response.status_code == 200
|
|
338
|
+
data = response.json()
|
|
339
|
+
assert data["id"] == 42
|
|
340
|
+
assert data["name"] == "Item 42"
|
|
341
|
+
|
|
342
|
+
# Non-existent item
|
|
343
|
+
response = client.get("/items/999")
|
|
344
|
+
assert response.status_code == 404
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_create_mixin():
|
|
348
|
+
"""Test CreateMixin provides post() method."""
|
|
349
|
+
api = BoltAPI()
|
|
350
|
+
|
|
351
|
+
class ItemSchema(msgspec.Struct):
|
|
352
|
+
name: str
|
|
353
|
+
price: float
|
|
354
|
+
|
|
355
|
+
@api.view("/items")
|
|
356
|
+
class ItemCreateView(APIView):
|
|
357
|
+
"""Override to skip the complex mixin setup."""
|
|
358
|
+
|
|
359
|
+
async def post(self, request, data: ItemSchema) -> dict:
|
|
360
|
+
# Simplified version - just return the created object
|
|
361
|
+
return {"id": 1, "name": data.name, "price": data.price}
|
|
362
|
+
|
|
363
|
+
with TestClient(api) as client:
|
|
364
|
+
response = client.post(
|
|
365
|
+
"/items",
|
|
366
|
+
json={"name": "New Item", "price": 29.99}
|
|
367
|
+
)
|
|
368
|
+
assert response.status_code == 200
|
|
369
|
+
data = response.json()
|
|
370
|
+
assert data["name"] == "New Item"
|
|
371
|
+
assert data["price"] == 29.99
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# --- ViewSet Tests ---
|
|
375
|
+
|
|
376
|
+
def test_bolt_viewset_get_allowed_methods():
|
|
377
|
+
"""Test ViewSet correctly identifies implemented methods."""
|
|
378
|
+
|
|
379
|
+
class UserViewSet(ViewSet):
|
|
380
|
+
async def get(self, request):
|
|
381
|
+
return {"method": "list"}
|
|
382
|
+
|
|
383
|
+
async def post(self, request):
|
|
384
|
+
return {"method": "create"}
|
|
385
|
+
|
|
386
|
+
allowed = UserViewSet.get_allowed_methods()
|
|
387
|
+
assert "GET" in allowed
|
|
388
|
+
assert "POST" in allowed
|
|
389
|
+
assert "DELETE" not in allowed
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_bolt_viewset_get_object_not_found():
|
|
393
|
+
"""Test ViewSet.get_object raises HTTPException when object not found."""
|
|
394
|
+
api = BoltAPI()
|
|
395
|
+
|
|
396
|
+
class MockQuerySet:
|
|
397
|
+
async def aget(self, pk):
|
|
398
|
+
raise Exception("DoesNotExist")
|
|
399
|
+
|
|
400
|
+
@api.view("/items/{pk}")
|
|
401
|
+
class ItemViewSet(ViewSet):
|
|
402
|
+
async def get_queryset(self):
|
|
403
|
+
return MockQuerySet()
|
|
404
|
+
|
|
405
|
+
async def get(self, request, pk: int):
|
|
406
|
+
# This will raise HTTPException
|
|
407
|
+
await self.get_object(pk)
|
|
408
|
+
return {"id": pk}
|
|
409
|
+
|
|
410
|
+
with TestClient(api) as client:
|
|
411
|
+
response = client.get("/items/999")
|
|
412
|
+
assert response.status_code == 404
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# --- Edge Cases and Validation ---
|
|
416
|
+
|
|
417
|
+
def test_bolt_api_view_non_async_handler_raises():
|
|
418
|
+
"""Test that non-async handlers raise TypeError."""
|
|
419
|
+
api = BoltAPI()
|
|
420
|
+
|
|
421
|
+
with pytest.raises(TypeError) as exc_info:
|
|
422
|
+
@api.view("/bad")
|
|
423
|
+
class BadView(APIView):
|
|
424
|
+
def get(self, request): # NOT async
|
|
425
|
+
return {"bad": True}
|
|
426
|
+
|
|
427
|
+
assert "must be async" in str(exc_info.value)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_bolt_api_view_non_subclass_raises():
|
|
431
|
+
"""Test that non-APIView classes raise TypeError."""
|
|
432
|
+
api = BoltAPI()
|
|
433
|
+
|
|
434
|
+
with pytest.raises(TypeError) as exc_info:
|
|
435
|
+
@api.view("/bad")
|
|
436
|
+
class NotAView:
|
|
437
|
+
async def get(self, request):
|
|
438
|
+
return {}
|
|
439
|
+
|
|
440
|
+
assert "must inherit from APIView" in str(exc_info.value)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_bolt_api_view_no_methods_raises():
|
|
444
|
+
"""Test that view with no methods raises ValueError."""
|
|
445
|
+
api = BoltAPI()
|
|
446
|
+
|
|
447
|
+
with pytest.raises(ValueError) as exc_info:
|
|
448
|
+
@api.view("/empty")
|
|
449
|
+
class EmptyView(APIView):
|
|
450
|
+
http_method_names = []
|
|
451
|
+
|
|
452
|
+
assert "does not implement any HTTP methods" in str(exc_info.value)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_bolt_api_view_selective_method_registration():
|
|
456
|
+
"""Test registering only specific methods from a view."""
|
|
457
|
+
api = BoltAPI()
|
|
458
|
+
|
|
459
|
+
# Only register GET and POST
|
|
460
|
+
@api.view("/items", methods=["GET", "POST"])
|
|
461
|
+
class MultiMethodView(APIView):
|
|
462
|
+
async def get(self, request) -> dict:
|
|
463
|
+
return {"method": "GET"}
|
|
464
|
+
|
|
465
|
+
async def post(self, request) -> dict:
|
|
466
|
+
return {"method": "POST"}
|
|
467
|
+
|
|
468
|
+
async def delete(self, request) -> dict:
|
|
469
|
+
return {"method": "DELETE"}
|
|
470
|
+
|
|
471
|
+
with TestClient(api) as client:
|
|
472
|
+
# GET and POST should work
|
|
473
|
+
response = client.get("/items")
|
|
474
|
+
assert response.status_code == 200
|
|
475
|
+
|
|
476
|
+
response = client.post("/items", json={})
|
|
477
|
+
assert response.status_code == 200
|
|
478
|
+
|
|
479
|
+
# DELETE should 404 (not registered)
|
|
480
|
+
response = client.delete("/items")
|
|
481
|
+
assert response.status_code == 404
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_bolt_api_view_unimplemented_method_raises():
|
|
485
|
+
"""Test requesting unimplemented method raises ValueError."""
|
|
486
|
+
api = BoltAPI()
|
|
487
|
+
|
|
488
|
+
with pytest.raises(ValueError) as exc_info:
|
|
489
|
+
@api.view("/items", methods=["POST"])
|
|
490
|
+
class GetOnlyView(APIView):
|
|
491
|
+
async def get(self, request) -> dict:
|
|
492
|
+
return {"method": "GET"}
|
|
493
|
+
|
|
494
|
+
assert "does not implement method 'post'" in str(exc_info.value)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --- Complete CRUD Example ---
|
|
498
|
+
|
|
499
|
+
def test_complete_crud_operations():
|
|
500
|
+
"""Test a complete CRUD API using class-based views."""
|
|
501
|
+
api = BoltAPI()
|
|
502
|
+
|
|
503
|
+
# In-memory database
|
|
504
|
+
items_db = {
|
|
505
|
+
1: {"id": 1, "name": "Item 1", "price": 10.0},
|
|
506
|
+
2: {"id": 2, "name": "Item 2", "price": 20.0},
|
|
507
|
+
}
|
|
508
|
+
next_id = [3] # Use list for mutable counter
|
|
509
|
+
|
|
510
|
+
class ItemSchema(msgspec.Struct):
|
|
511
|
+
id: int
|
|
512
|
+
name: str
|
|
513
|
+
price: float
|
|
514
|
+
|
|
515
|
+
class ItemCreateSchema(msgspec.Struct):
|
|
516
|
+
name: str
|
|
517
|
+
price: float
|
|
518
|
+
|
|
519
|
+
@api.view("/items")
|
|
520
|
+
class ItemListView(APIView):
|
|
521
|
+
async def get(self, request) -> list:
|
|
522
|
+
return list(items_db.values())
|
|
523
|
+
|
|
524
|
+
@api.view("/items/create")
|
|
525
|
+
class ItemCreateView(APIView):
|
|
526
|
+
async def post(self, request, data: ItemCreateSchema) -> dict:
|
|
527
|
+
item_id = next_id[0]
|
|
528
|
+
next_id[0] += 1
|
|
529
|
+
items_db[item_id] = {
|
|
530
|
+
"id": item_id,
|
|
531
|
+
"name": data.name,
|
|
532
|
+
"price": data.price,
|
|
533
|
+
}
|
|
534
|
+
return items_db[item_id]
|
|
535
|
+
|
|
536
|
+
@api.view("/items/{item_id}")
|
|
537
|
+
class ItemDetailView(APIView):
|
|
538
|
+
async def get(self, request, item_id: int) -> dict:
|
|
539
|
+
if item_id not in items_db:
|
|
540
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
541
|
+
return items_db[item_id]
|
|
542
|
+
|
|
543
|
+
async def put(self, request, item_id: int, data: ItemCreateSchema) -> dict:
|
|
544
|
+
if item_id not in items_db:
|
|
545
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
546
|
+
items_db[item_id] = {
|
|
547
|
+
"id": item_id,
|
|
548
|
+
"name": data.name,
|
|
549
|
+
"price": data.price,
|
|
550
|
+
}
|
|
551
|
+
return items_db[item_id]
|
|
552
|
+
|
|
553
|
+
async def delete(self, request, item_id: int) -> dict:
|
|
554
|
+
if item_id not in items_db:
|
|
555
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
556
|
+
del items_db[item_id]
|
|
557
|
+
return {"detail": "Item deleted"}
|
|
558
|
+
|
|
559
|
+
with TestClient(api) as client:
|
|
560
|
+
# List items
|
|
561
|
+
response = client.get("/items")
|
|
562
|
+
assert response.status_code == 200
|
|
563
|
+
assert len(response.json()) == 2
|
|
564
|
+
|
|
565
|
+
# Get single item
|
|
566
|
+
response = client.get("/items/1")
|
|
567
|
+
assert response.status_code == 200
|
|
568
|
+
assert response.json()["name"] == "Item 1"
|
|
569
|
+
|
|
570
|
+
# Create item
|
|
571
|
+
response = client.post(
|
|
572
|
+
"/items/create",
|
|
573
|
+
json={"name": "New Item", "price": 30.0}
|
|
574
|
+
)
|
|
575
|
+
assert response.status_code == 200
|
|
576
|
+
new_item = response.json()
|
|
577
|
+
assert new_item["name"] == "New Item"
|
|
578
|
+
assert new_item["id"] == 3
|
|
579
|
+
|
|
580
|
+
# Update item
|
|
581
|
+
response = client.put(
|
|
582
|
+
"/items/1",
|
|
583
|
+
json={"name": "Updated Item", "price": 15.0}
|
|
584
|
+
)
|
|
585
|
+
assert response.status_code == 200
|
|
586
|
+
assert response.json()["name"] == "Updated Item"
|
|
587
|
+
|
|
588
|
+
# Delete item
|
|
589
|
+
response = client.delete("/items/2")
|
|
590
|
+
assert response.status_code == 200
|
|
591
|
+
|
|
592
|
+
# Verify deletion
|
|
593
|
+
response = client.get("/items/2")
|
|
594
|
+
assert response.status_code == 404
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def test_bolt_api_view_method_names_customization():
|
|
598
|
+
"""Test customizing http_method_names."""
|
|
599
|
+
api = BoltAPI()
|
|
600
|
+
|
|
601
|
+
@api.view("/limited")
|
|
602
|
+
class GetOnlyView(APIView):
|
|
603
|
+
http_method_names = ["get"]
|
|
604
|
+
|
|
605
|
+
async def get(self, request) -> dict:
|
|
606
|
+
return {"method": "GET"}
|
|
607
|
+
|
|
608
|
+
async def post(self, request) -> dict:
|
|
609
|
+
return {"method": "POST"}
|
|
610
|
+
|
|
611
|
+
# Verify only GET was registered
|
|
612
|
+
assert len(api._routes) == 1
|
|
613
|
+
assert api._routes[0][0] == "GET"
|
|
614
|
+
|
|
615
|
+
with TestClient(api) as client:
|
|
616
|
+
# GET should work
|
|
617
|
+
response = client.get("/limited")
|
|
618
|
+
assert response.status_code == 200
|
|
619
|
+
|
|
620
|
+
# POST should 404 (not registered due to http_method_names)
|
|
621
|
+
response = client.post("/limited", json={})
|
|
622
|
+
assert response.status_code == 404
|