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.
- django_bolt/__init__.py +2 -2
- django_bolt/_core.pyd +0 -0
- django_bolt/_json.py +169 -0
- django_bolt/admin/static_routes.py +15 -21
- django_bolt/api.py +181 -61
- django_bolt/auth/__init__.py +2 -2
- django_bolt/decorators.py +15 -3
- django_bolt/dependencies.py +30 -24
- django_bolt/error_handlers.py +2 -1
- django_bolt/openapi/plugins.py +3 -2
- django_bolt/openapi/schema_generator.py +65 -20
- django_bolt/pagination.py +2 -1
- django_bolt/responses.py +3 -2
- django_bolt/serialization.py +5 -4
- {django_bolt-0.1.0.dist-info → django_bolt-0.1.1.dist-info}/METADATA +179 -197
- {django_bolt-0.1.0.dist-info → django_bolt-0.1.1.dist-info}/RECORD +18 -55
- django_bolt/auth/README.md +0 -464
- django_bolt/auth/REVOCATION_EXAMPLE.md +0 -391
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +0 -1
- django_bolt/tests/admin_tests/conftest.py +0 -6
- django_bolt/tests/admin_tests/test_admin_with_django.py +0 -278
- django_bolt/tests/admin_tests/urls.py +0 -9
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +0 -570
- django_bolt/tests/cbv/test_class_views_django_orm.py +0 -703
- django_bolt/tests/cbv/test_class_views_features.py +0 -1173
- django_bolt/tests/cbv/test_class_views_with_client.py +0 -622
- django_bolt/tests/conftest.py +0 -165
- django_bolt/tests/test_action_decorator.py +0 -399
- django_bolt/tests/test_auth_secret_key.py +0 -83
- django_bolt/tests/test_decorator_syntax.py +0 -159
- django_bolt/tests/test_error_handling.py +0 -481
- django_bolt/tests/test_file_response.py +0 -192
- django_bolt/tests/test_global_cors.py +0 -172
- django_bolt/tests/test_guards_auth.py +0 -441
- django_bolt/tests/test_guards_integration.py +0 -303
- django_bolt/tests/test_health.py +0 -283
- django_bolt/tests/test_integration_validation.py +0 -400
- django_bolt/tests/test_json_validation.py +0 -536
- django_bolt/tests/test_jwt_auth.py +0 -327
- django_bolt/tests/test_jwt_token.py +0 -458
- django_bolt/tests/test_logging.py +0 -837
- django_bolt/tests/test_logging_merge.py +0 -419
- django_bolt/tests/test_middleware.py +0 -492
- django_bolt/tests/test_middleware_server.py +0 -230
- django_bolt/tests/test_model_viewset.py +0 -323
- django_bolt/tests/test_models.py +0 -24
- django_bolt/tests/test_pagination.py +0 -1258
- django_bolt/tests/test_parameter_validation.py +0 -178
- django_bolt/tests/test_syntax.py +0 -626
- django_bolt/tests/test_testing_utilities.py +0 -163
- django_bolt/tests/test_testing_utilities_simple.py +0 -123
- django_bolt/tests/test_viewset_unified.py +0 -346
- {django_bolt-0.1.0.dist-info → django_bolt-0.1.1.dist-info}/WHEEL +0 -0
- {django_bolt-0.1.0.dist-info → django_bolt-0.1.1.dist-info}/entry_points.txt +0 -0
django_bolt/tests/test_syntax.py
DELETED
|
@@ -1,626 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import os
|
|
3
|
-
import json
|
|
4
|
-
|
|
5
|
-
import msgspec
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
from django_bolt import BoltAPI, JSON, StreamingResponse
|
|
9
|
-
from django_bolt.param_functions import Query, Path, Header, Cookie, Depends, Form, File as FileParam
|
|
10
|
-
from django_bolt.responses import PlainText, HTML, Redirect, File, FileResponse
|
|
11
|
-
from django_bolt.exceptions import HTTPException
|
|
12
|
-
from django_bolt.testing import TestClient
|
|
13
|
-
from typing import Annotated
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@pytest.fixture(scope="module")
|
|
17
|
-
def api():
|
|
18
|
-
"""Create the test API with all routes"""
|
|
19
|
-
api = BoltAPI()
|
|
20
|
-
|
|
21
|
-
class Item(msgspec.Struct):
|
|
22
|
-
name: str
|
|
23
|
-
price: float
|
|
24
|
-
is_offer: bool | None = None
|
|
25
|
-
|
|
26
|
-
@api.get("/")
|
|
27
|
-
async def root():
|
|
28
|
-
return {"ok": True}
|
|
29
|
-
|
|
30
|
-
@api.get("/items/{item_id}")
|
|
31
|
-
async def get_item(item_id: int, q: str | None = None):
|
|
32
|
-
return {"item_id": item_id, "q": q}
|
|
33
|
-
|
|
34
|
-
@api.get("/types")
|
|
35
|
-
async def get_types(b: bool | None = None, f: float | None = None):
|
|
36
|
-
return {"b": b, "f": f}
|
|
37
|
-
|
|
38
|
-
@api.put("/items/{item_id}")
|
|
39
|
-
async def put_item(item_id: int, item: Item):
|
|
40
|
-
return {"item_id": item_id, "item_name": item.name, "is_offer": item.is_offer}
|
|
41
|
-
|
|
42
|
-
@api.get("/str")
|
|
43
|
-
async def ret_str():
|
|
44
|
-
return "hello"
|
|
45
|
-
|
|
46
|
-
@api.get("/bytes")
|
|
47
|
-
async def ret_bytes():
|
|
48
|
-
return b"abc"
|
|
49
|
-
|
|
50
|
-
@api.get("/json")
|
|
51
|
-
async def ret_json():
|
|
52
|
-
return JSON({"x": 1}, status_code=201, headers={"X-Test": "1"})
|
|
53
|
-
|
|
54
|
-
@api.get("/req/{x}")
|
|
55
|
-
async def req_only(req):
|
|
56
|
-
return {"p": req["params"].get("x"), "q": req["query"].get("y")}
|
|
57
|
-
|
|
58
|
-
@api.post("/m")
|
|
59
|
-
async def post_m():
|
|
60
|
-
return {"m": "post"}
|
|
61
|
-
|
|
62
|
-
@api.patch("/m")
|
|
63
|
-
async def patch_m():
|
|
64
|
-
return {"m": "patch"}
|
|
65
|
-
|
|
66
|
-
@api.delete("/m")
|
|
67
|
-
async def delete_m():
|
|
68
|
-
return {"m": "delete"}
|
|
69
|
-
|
|
70
|
-
@api.head("/m")
|
|
71
|
-
async def head_m():
|
|
72
|
-
return {"m": "head"}
|
|
73
|
-
|
|
74
|
-
# Test HEAD with query params (should work like GET)
|
|
75
|
-
@api.head("/items/{item_id}")
|
|
76
|
-
async def head_item(item_id: int, q: str | None = None):
|
|
77
|
-
return {"item_id": item_id, "q": q}
|
|
78
|
-
|
|
79
|
-
# Response coercion from objects to msgspec.Struct
|
|
80
|
-
class Mini(msgspec.Struct):
|
|
81
|
-
id: int
|
|
82
|
-
username: str
|
|
83
|
-
|
|
84
|
-
class Model:
|
|
85
|
-
def __init__(self, id: int, username: str | None):
|
|
86
|
-
self.id = id
|
|
87
|
-
self.username = username
|
|
88
|
-
|
|
89
|
-
@api.get("/coerce/mini", response_model=list[Mini])
|
|
90
|
-
async def coerce_mini() -> list[Mini]:
|
|
91
|
-
return [Model(1, "a"), Model(2, "b")]
|
|
92
|
-
|
|
93
|
-
@api.get("/coerce/mini-bad", response_model=list[Mini])
|
|
94
|
-
async def coerce_mini_bad() -> list[Mini]:
|
|
95
|
-
return [Model(1, None)]
|
|
96
|
-
|
|
97
|
-
@api.get("/ok-list", response_model=list[Item])
|
|
98
|
-
async def ok_list():
|
|
99
|
-
return [
|
|
100
|
-
{"name": "a", "price": 1.0, "is_offer": True},
|
|
101
|
-
{"name": "b", "price": 2.0, "is_offer": False},
|
|
102
|
-
]
|
|
103
|
-
|
|
104
|
-
@api.get("/bad-list", response_model=list[Item])
|
|
105
|
-
async def bad_list():
|
|
106
|
-
return [{"name": "x", "is_offer": True}]
|
|
107
|
-
|
|
108
|
-
@api.get("/anno-list")
|
|
109
|
-
async def anno_list() -> list[Item]:
|
|
110
|
-
return [Item(name="c", price=3.0, is_offer=None)]
|
|
111
|
-
|
|
112
|
-
@api.get("/anno-bad")
|
|
113
|
-
async def anno_bad() -> list[Item]:
|
|
114
|
-
return [{"name": "d"}]
|
|
115
|
-
|
|
116
|
-
@api.get("/both-override", response_model=list[Item])
|
|
117
|
-
async def both_override() -> list[str]:
|
|
118
|
-
return [{"name": "o", "price": 1.0, "is_offer": False}]
|
|
119
|
-
|
|
120
|
-
@api.get("/no-validate")
|
|
121
|
-
async def no_validate():
|
|
122
|
-
return [{"anything": 1, "extra": "ok"}]
|
|
123
|
-
|
|
124
|
-
@api.get("/status-default", status_code=201)
|
|
125
|
-
async def status_default():
|
|
126
|
-
return {"ok": True}
|
|
127
|
-
|
|
128
|
-
@api.get("/headers-cookies")
|
|
129
|
-
async def headers_cookies(agent: str = Depends(lambda user_agent: user_agent)):
|
|
130
|
-
return {"ok": True}
|
|
131
|
-
|
|
132
|
-
@api.get("/header")
|
|
133
|
-
async def get_header(x: Annotated[str, Header(alias="x-test")]):
|
|
134
|
-
return PlainText(x)
|
|
135
|
-
|
|
136
|
-
@api.get("/cookie")
|
|
137
|
-
async def get_cookie(val: Annotated[str, Cookie(alias="session")]):
|
|
138
|
-
return PlainText(val)
|
|
139
|
-
|
|
140
|
-
@api.get("/exc")
|
|
141
|
-
async def raise_exc():
|
|
142
|
-
raise HTTPException(418, {"detail": "teapot"}, headers={"X-Err": "1"})
|
|
143
|
-
|
|
144
|
-
@api.get("/html")
|
|
145
|
-
async def get_html():
|
|
146
|
-
return HTML("<h1>Hi</h1>")
|
|
147
|
-
|
|
148
|
-
@api.get("/redirect")
|
|
149
|
-
async def get_redirect():
|
|
150
|
-
return Redirect("/", status_code=302)
|
|
151
|
-
|
|
152
|
-
THIS_FILE = os.path.abspath(__file__)
|
|
153
|
-
|
|
154
|
-
@api.get("/file")
|
|
155
|
-
async def get_file():
|
|
156
|
-
return File(THIS_FILE, filename="test_syntax.py")
|
|
157
|
-
|
|
158
|
-
@api.get("/fileresponse")
|
|
159
|
-
async def get_fileresponse():
|
|
160
|
-
return FileResponse(THIS_FILE, filename="test_syntax.py")
|
|
161
|
-
|
|
162
|
-
@api.get("/stream-plain")
|
|
163
|
-
async def stream_plain():
|
|
164
|
-
def gen():
|
|
165
|
-
for i in range(3):
|
|
166
|
-
yield f"p{i},"
|
|
167
|
-
return StreamingResponse(gen, media_type="text/plain")
|
|
168
|
-
|
|
169
|
-
@api.get("/stream-bytes")
|
|
170
|
-
async def stream_bytes():
|
|
171
|
-
def gen():
|
|
172
|
-
for i in range(2):
|
|
173
|
-
yield str(i).encode()
|
|
174
|
-
return StreamingResponse(gen)
|
|
175
|
-
|
|
176
|
-
@api.get("/sse")
|
|
177
|
-
async def stream_sse():
|
|
178
|
-
def gen():
|
|
179
|
-
yield "event: message\ndata: hello\n\n"
|
|
180
|
-
yield "data: 1\n\n"
|
|
181
|
-
yield ": comment\n\n"
|
|
182
|
-
return StreamingResponse(gen, media_type="text/event-stream")
|
|
183
|
-
|
|
184
|
-
@api.get("/stream-async")
|
|
185
|
-
async def stream_async():
|
|
186
|
-
async def async_gen():
|
|
187
|
-
for i in range(3):
|
|
188
|
-
await asyncio.sleep(0.001)
|
|
189
|
-
yield f"async-{i},".encode()
|
|
190
|
-
return StreamingResponse(async_gen(), media_type="text/plain")
|
|
191
|
-
|
|
192
|
-
@api.get("/stream-async-sse")
|
|
193
|
-
async def stream_async_sse():
|
|
194
|
-
async def async_gen():
|
|
195
|
-
yield "event: start\ndata: beginning\n\n"
|
|
196
|
-
await asyncio.sleep(0.001)
|
|
197
|
-
yield "event: message\ndata: async data\n\n"
|
|
198
|
-
await asyncio.sleep(0.001)
|
|
199
|
-
yield "event: end\ndata: finished\n\n"
|
|
200
|
-
return StreamingResponse(async_gen(), media_type="text/event-stream")
|
|
201
|
-
|
|
202
|
-
@api.get("/stream-async-large")
|
|
203
|
-
async def stream_async_large():
|
|
204
|
-
async def async_gen():
|
|
205
|
-
for i in range(10):
|
|
206
|
-
await asyncio.sleep(0.001)
|
|
207
|
-
chunk = f"chunk-{i:02d}-{'x' * 100}\n".encode()
|
|
208
|
-
yield chunk
|
|
209
|
-
return StreamingResponse(async_gen(), media_type="application/octet-stream")
|
|
210
|
-
|
|
211
|
-
@api.get("/stream-async-mixed-types")
|
|
212
|
-
async def stream_async_mixed_types():
|
|
213
|
-
async def async_gen():
|
|
214
|
-
yield b"bytes-chunk\n"
|
|
215
|
-
await asyncio.sleep(0.001)
|
|
216
|
-
yield "string-chunk\n"
|
|
217
|
-
await asyncio.sleep(0.001)
|
|
218
|
-
yield bytearray(b"bytearray-chunk\n")
|
|
219
|
-
await asyncio.sleep(0.001)
|
|
220
|
-
yield memoryview(b"memoryview-chunk\n")
|
|
221
|
-
return StreamingResponse(async_gen(), media_type="text/plain")
|
|
222
|
-
|
|
223
|
-
@api.get("/stream-async-error")
|
|
224
|
-
async def stream_async_error():
|
|
225
|
-
async def async_gen():
|
|
226
|
-
yield b"chunk1\n"
|
|
227
|
-
await asyncio.sleep(0.001)
|
|
228
|
-
yield b"chunk2\n"
|
|
229
|
-
await asyncio.sleep(0.001)
|
|
230
|
-
raise ValueError("Simulated async error")
|
|
231
|
-
return StreamingResponse(async_gen(), media_type="text/plain")
|
|
232
|
-
|
|
233
|
-
@api.post("/form-urlencoded")
|
|
234
|
-
async def form_urlencoded(a: Annotated[str, Form()], b: Annotated[int, Form()]):
|
|
235
|
-
return {"a": a, "b": b}
|
|
236
|
-
|
|
237
|
-
@api.post("/upload")
|
|
238
|
-
async def upload(files: Annotated[list[dict], FileParam(alias="file")]):
|
|
239
|
-
return {"count": len(files), "names": [f.get("filename") for f in files]}
|
|
240
|
-
|
|
241
|
-
@api.get("/sse-async-test")
|
|
242
|
-
async def sse_async_test():
|
|
243
|
-
async def agen():
|
|
244
|
-
for i in range(3):
|
|
245
|
-
yield f"data: {i}\n\n"
|
|
246
|
-
await asyncio.sleep(0)
|
|
247
|
-
return StreamingResponse(agen(), media_type="text/event-stream")
|
|
248
|
-
|
|
249
|
-
@api.post("/v1/chat/completions-async-test")
|
|
250
|
-
async def chat_completions_async_test(payload: dict):
|
|
251
|
-
if payload.get("stream", True):
|
|
252
|
-
async def agen():
|
|
253
|
-
for i in range(payload.get("n_chunks", 2)):
|
|
254
|
-
data = {"chunk": i, "content": " hello"}
|
|
255
|
-
yield f"data: {json.dumps(data)}\n\n"
|
|
256
|
-
await asyncio.sleep(0)
|
|
257
|
-
yield "data: [DONE]\n\n"
|
|
258
|
-
return StreamingResponse(agen(), media_type="text/event-stream")
|
|
259
|
-
return {"non_streaming": True}
|
|
260
|
-
|
|
261
|
-
return api
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
@pytest.fixture(scope="module")
|
|
265
|
-
def client(api):
|
|
266
|
-
"""Create TestClient for the API"""
|
|
267
|
-
with TestClient(api) as client:
|
|
268
|
-
yield client
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def test_root(client):
|
|
272
|
-
response = client.get("/")
|
|
273
|
-
assert response.status_code == 200
|
|
274
|
-
assert response.json() == {"ok": True}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def test_path_and_query_binding(client):
|
|
278
|
-
response = client.get("/items/42?q=hello")
|
|
279
|
-
assert response.status_code == 200
|
|
280
|
-
assert response.json() == {"item_id": 42, "q": "hello"}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def test_bool_and_float_binding(client):
|
|
284
|
-
response = client.get("/types?b=true&f=1.25")
|
|
285
|
-
assert response.status_code == 200
|
|
286
|
-
assert response.json() == {"b": True, "f": 1.25}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def test_body_decoding(client):
|
|
290
|
-
response = client.put("/items/5", json={"name": "x", "price": 1.5, "is_offer": True})
|
|
291
|
-
assert response.status_code == 200
|
|
292
|
-
assert response.json() == {"item_id": 5, "item_name": "x", "is_offer": True}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def test_response_types(client):
|
|
296
|
-
# str
|
|
297
|
-
response = client.get("/str")
|
|
298
|
-
assert response.status_code == 200
|
|
299
|
-
assert response.content == b"hello"
|
|
300
|
-
assert response.headers.get("content-type", "").startswith("text/plain")
|
|
301
|
-
# bytes
|
|
302
|
-
response = client.get("/bytes")
|
|
303
|
-
assert response.status_code == 200
|
|
304
|
-
assert response.content == b"abc"
|
|
305
|
-
assert response.headers.get("content-type", "").startswith("application/octet-stream")
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def test_json_response_status_and_headers(client):
|
|
309
|
-
response = client.get("/json")
|
|
310
|
-
assert response.status_code == 201
|
|
311
|
-
assert response.headers.get("x-test") == "1"
|
|
312
|
-
assert response.json() == {"x": 1}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def test_request_only_handler(client):
|
|
316
|
-
response = client.get("/req/9?y=z")
|
|
317
|
-
assert response.status_code == 200
|
|
318
|
-
assert response.json() == {"p": "9", "q": "z"}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def test_methods(client):
|
|
322
|
-
response = client.post("/m")
|
|
323
|
-
assert response.status_code == 200 and response.json() == {"m": "post"}
|
|
324
|
-
response = client.patch("/m")
|
|
325
|
-
assert response.status_code == 200 and response.json() == {"m": "patch"}
|
|
326
|
-
response = client.delete("/m")
|
|
327
|
-
assert response.status_code == 200 and response.json() == {"m": "delete"}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def test_response_model_validation_ok(client):
|
|
331
|
-
response = client.get("/ok-list")
|
|
332
|
-
assert response.status_code == 200
|
|
333
|
-
data = response.json()
|
|
334
|
-
assert isinstance(data, list) and len(data) == 2
|
|
335
|
-
assert data[0]["name"] == "a" and data[0]["price"] == 1.0
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def test_response_model_validation_error(client):
|
|
339
|
-
response = client.get("/bad-list")
|
|
340
|
-
# We currently surface server error (500) on validation problems
|
|
341
|
-
assert response.status_code == 500
|
|
342
|
-
assert b"Response validation error" in response.content
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def test_return_annotation_validation_ok(client):
|
|
346
|
-
response = client.get("/anno-list")
|
|
347
|
-
assert response.status_code == 200
|
|
348
|
-
data = response.json()
|
|
349
|
-
assert isinstance(data, list) and data[0]["name"] == "c"
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def test_return_annotation_validation_error(client):
|
|
353
|
-
response = client.get("/anno-bad")
|
|
354
|
-
assert response.status_code == 500
|
|
355
|
-
assert b"Response validation error" in response.content
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
def test_response_coercion_from_objects(client):
|
|
359
|
-
response = client.get("/coerce/mini")
|
|
360
|
-
assert response.status_code == 200
|
|
361
|
-
data = response.json()
|
|
362
|
-
assert data == [{"id": 1, "username": "a"}, {"id": 2, "username": "b"}]
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def test_response_coercion_error_from_objects(client):
|
|
366
|
-
response = client.get("/coerce/mini-bad")
|
|
367
|
-
assert response.status_code == 500
|
|
368
|
-
assert b"Response validation error" in response.content
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
def test_response_model_overrides_return_annotation(client):
|
|
372
|
-
response = client.get("/both-override")
|
|
373
|
-
assert response.status_code == 200
|
|
374
|
-
data = response.json()
|
|
375
|
-
assert isinstance(data, list) and data[0]["name"] == "o"
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
def test_no_validation_without_types(client):
|
|
379
|
-
response = client.get("/no-validate")
|
|
380
|
-
assert response.status_code == 200
|
|
381
|
-
data = response.json()
|
|
382
|
-
# Should return as-is since neither annotation nor response_model provided
|
|
383
|
-
assert data == [{"anything": 1, "extra": "ok"}]
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
def test_status_code_default(client):
|
|
387
|
-
response = client.get("/status-default")
|
|
388
|
-
assert response.status_code == 201
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
def test_header_and_cookie(client):
|
|
392
|
-
response = client.get("/header", headers={"x-test": "val"})
|
|
393
|
-
assert response.status_code == 200 and response.content == b"val"
|
|
394
|
-
# set cookie via header
|
|
395
|
-
response = client.get("/cookie", cookies={"session": "abc"})
|
|
396
|
-
assert response.status_code == 200 and response.content == b"abc"
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def test_http_exception(client):
|
|
400
|
-
response = client.get("/exc")
|
|
401
|
-
assert response.status_code == 418
|
|
402
|
-
assert response.headers.get("x-err") == "1"
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def test_response_helpers(client):
|
|
406
|
-
response = client.get("/html")
|
|
407
|
-
assert response.status_code == 200 and response.headers.get("content-type", "").startswith("text/html")
|
|
408
|
-
response = client.get("/redirect", follow_redirects=False)
|
|
409
|
-
assert response.status_code == 302 and response.headers.get("location") == "/"
|
|
410
|
-
response = client.get("/file")
|
|
411
|
-
assert response.status_code == 200 and response.headers.get("content-type", "").startswith("text/")
|
|
412
|
-
# FileResponse should also succeed and set content-disposition
|
|
413
|
-
response = client.get("/fileresponse")
|
|
414
|
-
assert response.status_code == 200
|
|
415
|
-
assert response.headers.get("content-type", "").startswith("text/")
|
|
416
|
-
assert "attachment;" in (response.headers.get("content-disposition", "").lower())
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
def test_streaming_plain(client):
|
|
420
|
-
response = client.get("/stream-plain")
|
|
421
|
-
assert response.status_code == 200
|
|
422
|
-
assert response.headers.get("content-type", "").startswith("text/plain")
|
|
423
|
-
assert response.content == b"p0,p1,p2,"
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
def test_streaming_bytes_default_content_type(client):
|
|
427
|
-
response = client.get("/stream-bytes")
|
|
428
|
-
assert response.status_code == 200
|
|
429
|
-
assert response.headers.get("content-type", "").startswith("application/octet-stream")
|
|
430
|
-
assert response.content == b"01"
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def test_streaming_sse_headers(client):
|
|
434
|
-
response = client.get("/sse")
|
|
435
|
-
assert response.status_code == 200
|
|
436
|
-
assert response.headers.get("content-type", "").startswith("text/event-stream")
|
|
437
|
-
# SSE-friendly headers are set by the server
|
|
438
|
-
# Note: Connection header may be managed by the HTTP server automatically
|
|
439
|
-
assert response.headers.get("x-accel-buffering", "").lower() == "no"
|
|
440
|
-
# Body should contain multiple well-formed SSE lines
|
|
441
|
-
text = response.content.decode()
|
|
442
|
-
assert "event: message" in text
|
|
443
|
-
assert "data: hello" in text
|
|
444
|
-
assert "data: 1" in text
|
|
445
|
-
assert ": comment" in text
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
def test_streaming_async_large(client):
|
|
449
|
-
"""Test async streaming with larger payloads."""
|
|
450
|
-
response = client.get("/stream-async-large")
|
|
451
|
-
assert response.status_code == 200
|
|
452
|
-
assert response.headers.get("content-type", "").startswith("application/octet-stream")
|
|
453
|
-
|
|
454
|
-
# Should have 10 chunks
|
|
455
|
-
lines = response.content.decode().strip().split('\n')
|
|
456
|
-
assert len(lines) == 10
|
|
457
|
-
|
|
458
|
-
# Check format of chunks
|
|
459
|
-
for i, line in enumerate(lines):
|
|
460
|
-
expected_prefix = f"chunk-{i:02d}-"
|
|
461
|
-
assert line.startswith(expected_prefix)
|
|
462
|
-
assert len(line) >= 109 # ~109 bytes per line (110 bytes per chunk with \n)
|
|
463
|
-
assert line.endswith('x' * 100)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
def test_streaming_async_mixed_types(client):
|
|
467
|
-
"""Test async streaming with different data types."""
|
|
468
|
-
response = client.get("/stream-async-mixed-types")
|
|
469
|
-
assert response.status_code == 200
|
|
470
|
-
assert response.headers.get("content-type", "").startswith("text/plain")
|
|
471
|
-
|
|
472
|
-
# Check all data types are properly converted
|
|
473
|
-
text = response.content.decode()
|
|
474
|
-
expected_chunks = [
|
|
475
|
-
"bytes-chunk\n",
|
|
476
|
-
"string-chunk\n",
|
|
477
|
-
"bytearray-chunk\n",
|
|
478
|
-
"memoryview-chunk\n"
|
|
479
|
-
]
|
|
480
|
-
|
|
481
|
-
for expected in expected_chunks:
|
|
482
|
-
assert expected in text
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def test_streaming_async_vs_sync_compatibility(client):
|
|
486
|
-
"""Test that async and sync streaming produce the same results for equivalent data."""
|
|
487
|
-
|
|
488
|
-
# Get sync streaming result
|
|
489
|
-
sync_response = client.get("/stream-plain")
|
|
490
|
-
|
|
491
|
-
# Get async streaming result
|
|
492
|
-
async_response = client.get("/stream-async")
|
|
493
|
-
|
|
494
|
-
# Both should succeed
|
|
495
|
-
assert sync_response.status_code == 200
|
|
496
|
-
assert async_response.status_code == 200
|
|
497
|
-
|
|
498
|
-
# Both should be text/plain
|
|
499
|
-
assert sync_response.headers.get("content-type", "").startswith("text/plain")
|
|
500
|
-
assert async_response.headers.get("content-type", "").startswith("text/plain")
|
|
501
|
-
|
|
502
|
-
# Content should be similar format (both have 3 items)
|
|
503
|
-
sync_text = sync_response.content.decode()
|
|
504
|
-
async_text = async_response.content.decode()
|
|
505
|
-
|
|
506
|
-
# Both should have 3 comma-separated items
|
|
507
|
-
assert len(sync_text.split(',')) == 4 # "p0,p1,p2," = 4 parts
|
|
508
|
-
assert len(async_text.split(',')) == 4 # "async-0,async-1,async-2," = 4 parts
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
def test_async_bridge_endpoints_work(client):
|
|
512
|
-
"""Test that async SSE streaming works correctly."""
|
|
513
|
-
|
|
514
|
-
# Test the async SSE endpoint - this should expose the real bug
|
|
515
|
-
response = client.get("/sse-async-test")
|
|
516
|
-
assert response.status_code == 200, f"Async SSE endpoint failed with status {response.status_code}"
|
|
517
|
-
assert len(response.content) > 0, f"Async SSE endpoint returned empty body, got {len(response.content)} bytes"
|
|
518
|
-
# Check that we actually get SSE formatted data
|
|
519
|
-
text = response.content.decode()
|
|
520
|
-
assert "data: 0" in text, f"Expected SSE data not found in response: {text[:100]}"
|
|
521
|
-
assert "data: 1" in text, f"Expected SSE data not found in response: {text[:100]}"
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
def test_form_and_file(client):
|
|
525
|
-
response = client.post("/form-urlencoded", data={"a": "x", "b": "3"})
|
|
526
|
-
assert response.status_code == 200 and response.json() == {"a": "x", "b": 3}
|
|
527
|
-
|
|
528
|
-
# Test multipart file upload
|
|
529
|
-
response = client.post(
|
|
530
|
-
"/upload",
|
|
531
|
-
data={"note": "hi"},
|
|
532
|
-
files=[
|
|
533
|
-
("file", ("a.txt", b"abc", "application/octet-stream")),
|
|
534
|
-
("file", ("b.txt", b"def", "application/octet-stream"))
|
|
535
|
-
]
|
|
536
|
-
)
|
|
537
|
-
data = response.json()
|
|
538
|
-
assert response.status_code == 200 and data["count"] == 2 and set(data["names"]) == {"a.txt", "b.txt"}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def test_head_method(client):
|
|
542
|
-
"""Test HEAD method works correctly"""
|
|
543
|
-
response = client.head("/m")
|
|
544
|
-
assert response.status_code == 200
|
|
545
|
-
# HEAD should return headers but empty body
|
|
546
|
-
assert len(response.content) == 0
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
def test_head_with_params(client):
|
|
550
|
-
"""Test HEAD method with path and query params"""
|
|
551
|
-
response = client.head("/items/42?q=test")
|
|
552
|
-
assert response.status_code == 200
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
def test_options_method_automatic(client):
|
|
556
|
-
"""Test automatic OPTIONS handling - returns Allow header with available methods"""
|
|
557
|
-
response = client.options("/m")
|
|
558
|
-
assert response.status_code == 200
|
|
559
|
-
# Check Allow header is present and contains the methods
|
|
560
|
-
assert "allow" in response.headers or "Allow" in response.headers
|
|
561
|
-
allow_header = response.headers.get("allow") or response.headers.get("Allow")
|
|
562
|
-
assert allow_header is not None
|
|
563
|
-
# Should include all methods registered for /m (POST, PATCH, DELETE, HEAD)
|
|
564
|
-
methods = [m.strip() for m in allow_header.split(",")]
|
|
565
|
-
assert "POST" in methods
|
|
566
|
-
assert "PATCH" in methods
|
|
567
|
-
assert "DELETE" in methods
|
|
568
|
-
assert "HEAD" in methods
|
|
569
|
-
assert "OPTIONS" in methods # Always included for automatic OPTIONS
|
|
570
|
-
# Body should be empty JSON object
|
|
571
|
-
assert response.json() == {}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
def test_options_on_nonexistent_route(client):
|
|
575
|
-
"""Test OPTIONS on non-existent route returns 404"""
|
|
576
|
-
response = client.options("/does-not-exist")
|
|
577
|
-
assert response.status_code == 404
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
def test_explicit_options_handler():
|
|
581
|
-
"""Test that explicit OPTIONS handler overrides automatic behavior"""
|
|
582
|
-
from django_bolt import Response
|
|
583
|
-
|
|
584
|
-
api = BoltAPI()
|
|
585
|
-
|
|
586
|
-
@api.get("/custom-options")
|
|
587
|
-
async def get_custom():
|
|
588
|
-
return {"result": "data"}
|
|
589
|
-
|
|
590
|
-
@api.options("/custom-options")
|
|
591
|
-
async def options_custom():
|
|
592
|
-
return Response(
|
|
593
|
-
{"custom": "options", "info": "This is a custom OPTIONS handler"},
|
|
594
|
-
headers={"Allow": "GET, OPTIONS", "X-Custom": "header"}
|
|
595
|
-
)
|
|
596
|
-
|
|
597
|
-
from django_bolt.testing import TestClient
|
|
598
|
-
with TestClient(api) as client:
|
|
599
|
-
response = client.options("/custom-options")
|
|
600
|
-
assert response.status_code == 200
|
|
601
|
-
data = response.json()
|
|
602
|
-
assert data["custom"] == "options"
|
|
603
|
-
assert data["info"] == "This is a custom OPTIONS handler"
|
|
604
|
-
# Check custom headers
|
|
605
|
-
assert "allow" in response.headers or "Allow" in response.headers
|
|
606
|
-
assert "x-custom" in response.headers or "X-Custom" in response.headers
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
def test_method_validation():
|
|
610
|
-
"""Test that HEAD and OPTIONS don't accept body parameters"""
|
|
611
|
-
api = BoltAPI()
|
|
612
|
-
|
|
613
|
-
class Body(msgspec.Struct):
|
|
614
|
-
value: str
|
|
615
|
-
|
|
616
|
-
# HEAD should not accept body
|
|
617
|
-
with pytest.raises(TypeError, match="HEAD.*cannot have body parameters"):
|
|
618
|
-
@api.head("/test-head")
|
|
619
|
-
async def head_with_body(body: Body):
|
|
620
|
-
return {"ok": True}
|
|
621
|
-
|
|
622
|
-
# OPTIONS should not accept body
|
|
623
|
-
with pytest.raises(TypeError, match="OPTIONS.*cannot have body parameters"):
|
|
624
|
-
@api.options("/test-options")
|
|
625
|
-
async def options_with_body(body: Body):
|
|
626
|
-
return {"ok": True}
|