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.

Files changed (128) hide show
  1. django_bolt/__init__.py +147 -0
  2. django_bolt/_core.abi3.so +0 -0
  3. django_bolt/admin/__init__.py +25 -0
  4. django_bolt/admin/admin_detection.py +179 -0
  5. django_bolt/admin/asgi_bridge.py +267 -0
  6. django_bolt/admin/routes.py +91 -0
  7. django_bolt/admin/static.py +155 -0
  8. django_bolt/admin/static_routes.py +111 -0
  9. django_bolt/api.py +1011 -0
  10. django_bolt/apps.py +7 -0
  11. django_bolt/async_collector.py +228 -0
  12. django_bolt/auth/README.md +464 -0
  13. django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
  14. django_bolt/auth/__init__.py +84 -0
  15. django_bolt/auth/backends.py +236 -0
  16. django_bolt/auth/guards.py +224 -0
  17. django_bolt/auth/jwt_utils.py +212 -0
  18. django_bolt/auth/revocation.py +286 -0
  19. django_bolt/auth/token.py +335 -0
  20. django_bolt/binding.py +363 -0
  21. django_bolt/bootstrap.py +77 -0
  22. django_bolt/cli.py +133 -0
  23. django_bolt/compression.py +104 -0
  24. django_bolt/decorators.py +159 -0
  25. django_bolt/dependencies.py +128 -0
  26. django_bolt/error_handlers.py +305 -0
  27. django_bolt/exceptions.py +294 -0
  28. django_bolt/health.py +129 -0
  29. django_bolt/logging/__init__.py +6 -0
  30. django_bolt/logging/config.py +357 -0
  31. django_bolt/logging/middleware.py +296 -0
  32. django_bolt/management/__init__.py +1 -0
  33. django_bolt/management/commands/__init__.py +0 -0
  34. django_bolt/management/commands/runbolt.py +427 -0
  35. django_bolt/middleware/__init__.py +32 -0
  36. django_bolt/middleware/compiler.py +131 -0
  37. django_bolt/middleware/middleware.py +247 -0
  38. django_bolt/openapi/__init__.py +23 -0
  39. django_bolt/openapi/config.py +196 -0
  40. django_bolt/openapi/plugins.py +439 -0
  41. django_bolt/openapi/routes.py +152 -0
  42. django_bolt/openapi/schema_generator.py +581 -0
  43. django_bolt/openapi/spec/__init__.py +68 -0
  44. django_bolt/openapi/spec/base.py +74 -0
  45. django_bolt/openapi/spec/callback.py +24 -0
  46. django_bolt/openapi/spec/components.py +72 -0
  47. django_bolt/openapi/spec/contact.py +21 -0
  48. django_bolt/openapi/spec/discriminator.py +25 -0
  49. django_bolt/openapi/spec/encoding.py +67 -0
  50. django_bolt/openapi/spec/enums.py +41 -0
  51. django_bolt/openapi/spec/example.py +36 -0
  52. django_bolt/openapi/spec/external_documentation.py +21 -0
  53. django_bolt/openapi/spec/header.py +132 -0
  54. django_bolt/openapi/spec/info.py +50 -0
  55. django_bolt/openapi/spec/license.py +28 -0
  56. django_bolt/openapi/spec/link.py +66 -0
  57. django_bolt/openapi/spec/media_type.py +51 -0
  58. django_bolt/openapi/spec/oauth_flow.py +36 -0
  59. django_bolt/openapi/spec/oauth_flows.py +28 -0
  60. django_bolt/openapi/spec/open_api.py +87 -0
  61. django_bolt/openapi/spec/operation.py +105 -0
  62. django_bolt/openapi/spec/parameter.py +147 -0
  63. django_bolt/openapi/spec/path_item.py +78 -0
  64. django_bolt/openapi/spec/paths.py +27 -0
  65. django_bolt/openapi/spec/reference.py +38 -0
  66. django_bolt/openapi/spec/request_body.py +38 -0
  67. django_bolt/openapi/spec/response.py +48 -0
  68. django_bolt/openapi/spec/responses.py +44 -0
  69. django_bolt/openapi/spec/schema.py +678 -0
  70. django_bolt/openapi/spec/security_requirement.py +28 -0
  71. django_bolt/openapi/spec/security_scheme.py +69 -0
  72. django_bolt/openapi/spec/server.py +34 -0
  73. django_bolt/openapi/spec/server_variable.py +32 -0
  74. django_bolt/openapi/spec/tag.py +32 -0
  75. django_bolt/openapi/spec/xml.py +44 -0
  76. django_bolt/pagination.py +669 -0
  77. django_bolt/param_functions.py +49 -0
  78. django_bolt/params.py +337 -0
  79. django_bolt/request_parsing.py +128 -0
  80. django_bolt/responses.py +214 -0
  81. django_bolt/router.py +48 -0
  82. django_bolt/serialization.py +193 -0
  83. django_bolt/status_codes.py +321 -0
  84. django_bolt/testing/__init__.py +10 -0
  85. django_bolt/testing/client.py +274 -0
  86. django_bolt/testing/helpers.py +93 -0
  87. django_bolt/tests/__init__.py +0 -0
  88. django_bolt/tests/admin_tests/__init__.py +1 -0
  89. django_bolt/tests/admin_tests/conftest.py +6 -0
  90. django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
  91. django_bolt/tests/admin_tests/urls.py +9 -0
  92. django_bolt/tests/cbv/__init__.py +0 -0
  93. django_bolt/tests/cbv/test_class_views.py +570 -0
  94. django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
  95. django_bolt/tests/cbv/test_class_views_features.py +1173 -0
  96. django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
  97. django_bolt/tests/conftest.py +165 -0
  98. django_bolt/tests/test_action_decorator.py +399 -0
  99. django_bolt/tests/test_auth_secret_key.py +83 -0
  100. django_bolt/tests/test_decorator_syntax.py +159 -0
  101. django_bolt/tests/test_error_handling.py +481 -0
  102. django_bolt/tests/test_file_response.py +192 -0
  103. django_bolt/tests/test_global_cors.py +172 -0
  104. django_bolt/tests/test_guards_auth.py +441 -0
  105. django_bolt/tests/test_guards_integration.py +303 -0
  106. django_bolt/tests/test_health.py +283 -0
  107. django_bolt/tests/test_integration_validation.py +400 -0
  108. django_bolt/tests/test_json_validation.py +536 -0
  109. django_bolt/tests/test_jwt_auth.py +327 -0
  110. django_bolt/tests/test_jwt_token.py +458 -0
  111. django_bolt/tests/test_logging.py +837 -0
  112. django_bolt/tests/test_logging_merge.py +419 -0
  113. django_bolt/tests/test_middleware.py +492 -0
  114. django_bolt/tests/test_middleware_server.py +230 -0
  115. django_bolt/tests/test_model_viewset.py +323 -0
  116. django_bolt/tests/test_models.py +24 -0
  117. django_bolt/tests/test_pagination.py +1258 -0
  118. django_bolt/tests/test_parameter_validation.py +178 -0
  119. django_bolt/tests/test_syntax.py +626 -0
  120. django_bolt/tests/test_testing_utilities.py +163 -0
  121. django_bolt/tests/test_testing_utilities_simple.py +123 -0
  122. django_bolt/tests/test_viewset_unified.py +346 -0
  123. django_bolt/typing.py +273 -0
  124. django_bolt/views.py +1110 -0
  125. django_bolt-0.1.0.dist-info/METADATA +629 -0
  126. django_bolt-0.1.0.dist-info/RECORD +128 -0
  127. django_bolt-0.1.0.dist-info/WHEEL +4 -0
  128. django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,837 @@
1
+ """Comprehensive tests for Django-Bolt logging system.
2
+
3
+ These tests validate the behavior documented in docs/LOGGING.md:
4
+ - Queue-based non-blocking logging
5
+ - Production defaults (min_duration_ms, sample_rate)
6
+ - Request/response logging with proper log levels
7
+ - Skip paths and status codes
8
+ - Header/cookie obfuscation
9
+ - Integration with Django's logging system
10
+ """
11
+
12
+ import pytest
13
+ import logging
14
+ import time
15
+ from unittest.mock import Mock, patch, MagicMock
16
+ from logging.handlers import QueueHandler
17
+ from django_bolt.logging import LoggingConfig, LoggingMiddleware, create_logging_middleware
18
+ from django_bolt.logging.config import setup_django_logging, _ensure_queue_logging
19
+
20
+
21
+ class TestLoggingConfig:
22
+ """Test LoggingConfig behavior documented in LOGGING.md."""
23
+
24
+ def test_default_config_uses_django_server_logger(self):
25
+ """Default logger should be 'django.server' as documented."""
26
+ config = LoggingConfig()
27
+ assert config.logger_name == "django.server", "Default logger must be 'django.server' per LOGGING.md"
28
+
29
+ def test_default_request_fields_include_basic_info(self):
30
+ """Default request fields should include method, path, and status_code."""
31
+ config = LoggingConfig()
32
+ assert "method" in config.request_log_fields
33
+ assert "path" in config.request_log_fields
34
+ assert "status_code" in config.request_log_fields
35
+
36
+ def test_default_response_fields_include_status_code(self):
37
+ """Default response fields should include status_code."""
38
+ config = LoggingConfig()
39
+ assert "status_code" in config.response_log_fields
40
+
41
+ def test_default_security_headers_are_obfuscated(self):
42
+ """Security-sensitive headers should be obfuscated by default."""
43
+ config = LoggingConfig()
44
+ assert "authorization" in config.obfuscate_headers
45
+ assert "cookie" in config.obfuscate_headers
46
+ assert "x-api-key" in config.obfuscate_headers
47
+ assert "x-auth-token" in config.obfuscate_headers
48
+
49
+ def test_default_security_cookies_are_obfuscated(self):
50
+ """Security-sensitive cookies should be obfuscated by default."""
51
+ config = LoggingConfig()
52
+ assert "sessionid" in config.obfuscate_cookies
53
+ assert "csrftoken" in config.obfuscate_cookies
54
+
55
+ def test_default_skip_paths_include_health_checks(self):
56
+ """Health check paths should be skipped by default."""
57
+ config = LoggingConfig()
58
+ assert "/health" in config.skip_paths
59
+ assert "/ready" in config.skip_paths
60
+ assert "/metrics" in config.skip_paths
61
+
62
+ def test_custom_logger_name(self):
63
+ """Custom logger name should be configurable."""
64
+ config = LoggingConfig(logger_name="custom.logger")
65
+ assert config.logger_name == "custom.logger"
66
+
67
+ def test_custom_request_fields(self):
68
+ """Custom request fields should be configurable."""
69
+ config = LoggingConfig(
70
+ request_log_fields={"method", "path", "body", "client_ip"}
71
+ )
72
+ assert "body" in config.request_log_fields
73
+ assert "client_ip" in config.request_log_fields
74
+
75
+ def test_custom_response_fields(self):
76
+ """Custom response fields should be configurable."""
77
+ config = LoggingConfig(
78
+ response_log_fields={"status_code", "duration", "size"}
79
+ )
80
+ assert "status_code" in config.response_log_fields
81
+ assert "duration" in config.response_log_fields
82
+ assert "size" in config.response_log_fields
83
+
84
+ def test_sample_rate_configuration(self):
85
+ """Sample rate should be configurable for reducing 2xx/3xx log volume."""
86
+ config = LoggingConfig(sample_rate=0.05)
87
+ assert config.sample_rate == 0.05
88
+
89
+ def test_min_duration_ms_configuration(self):
90
+ """Minimum duration threshold should be configurable for slow-only logging."""
91
+ config = LoggingConfig(min_duration_ms=500)
92
+ assert config.min_duration_ms == 500
93
+
94
+ def test_skip_paths_configuration(self):
95
+ """Skip paths should be fully customizable."""
96
+ config = LoggingConfig(skip_paths={"/admin", "/static"})
97
+ assert "/admin" in config.skip_paths
98
+ assert "/static" in config.skip_paths
99
+
100
+ def test_skip_status_codes_configuration(self):
101
+ """Skip status codes should be configurable."""
102
+ config = LoggingConfig(skip_status_codes={204, 304})
103
+ assert 204 in config.skip_status_codes
104
+ assert 304 in config.skip_status_codes
105
+
106
+ def test_should_log_request_returns_true_for_normal_paths(self):
107
+ """Normal paths should be logged."""
108
+ config = LoggingConfig(skip_paths={"/health"})
109
+ assert config.should_log_request("/api/users") is True
110
+
111
+ def test_should_log_request_returns_false_for_skipped_paths(self):
112
+ """Paths in skip_paths should not be logged."""
113
+ config = LoggingConfig(skip_paths={"/health", "/metrics"})
114
+ assert config.should_log_request("/health") is False
115
+ assert config.should_log_request("/metrics") is False
116
+
117
+ def test_should_log_request_returns_false_for_skipped_status_codes(self):
118
+ """Status codes in skip_status_codes should not be logged."""
119
+ config = LoggingConfig(skip_status_codes={204, 304})
120
+ assert config.should_log_request("/api/users", 204) is False
121
+ assert config.should_log_request("/api/users", 304) is False
122
+
123
+ def test_should_log_request_returns_true_for_non_skipped_status_codes(self):
124
+ """Non-skipped status codes should be logged."""
125
+ config = LoggingConfig(skip_status_codes={204, 304})
126
+ assert config.should_log_request("/api/users", 200) is True
127
+ assert config.should_log_request("/api/users", 500) is True
128
+
129
+ def test_get_logger_returns_logger_instance(self):
130
+ """get_logger() should return a proper Logger instance."""
131
+ config = LoggingConfig(logger_name="test.logger")
132
+ logger = config.get_logger()
133
+ assert isinstance(logger, logging.Logger)
134
+ assert logger.name == "test.logger"
135
+
136
+
137
+ class TestLoggingMiddleware:
138
+ """Test LoggingMiddleware behavior."""
139
+
140
+ def test_middleware_uses_default_config_when_none_provided(self):
141
+ """Middleware should use default config when none is provided."""
142
+ middleware = LoggingMiddleware()
143
+ assert middleware.config is not None
144
+ assert middleware.logger is not None
145
+
146
+ def test_middleware_uses_provided_config(self):
147
+ """Middleware should use provided config."""
148
+ config = LoggingConfig(logger_name="custom.logger")
149
+ middleware = LoggingMiddleware(config)
150
+ assert middleware.config.logger_name == "custom.logger"
151
+
152
+ def test_obfuscate_headers_masks_sensitive_headers(self):
153
+ """Sensitive headers should be obfuscated to '***'."""
154
+ config = LoggingConfig(obfuscate_headers={"authorization", "x-api-key"})
155
+ middleware = LoggingMiddleware(config)
156
+
157
+ headers = {
158
+ "content-type": "application/json",
159
+ "authorization": "Bearer secret-token-12345",
160
+ "x-api-key": "my-secret-key",
161
+ "user-agent": "test-agent",
162
+ }
163
+
164
+ obfuscated = middleware.obfuscate_headers(headers)
165
+ assert obfuscated["content-type"] == "application/json"
166
+ assert obfuscated["authorization"] == "***", "Authorization header must be obfuscated"
167
+ assert obfuscated["x-api-key"] == "***", "X-API-Key header must be obfuscated"
168
+ assert obfuscated["user-agent"] == "test-agent"
169
+
170
+ def test_obfuscate_headers_is_case_insensitive(self):
171
+ """Header obfuscation should be case-insensitive."""
172
+ config = LoggingConfig(obfuscate_headers={"authorization"})
173
+ middleware = LoggingMiddleware(config)
174
+
175
+ headers = {
176
+ "Authorization": "Bearer token",
177
+ "AUTHORIZATION": "Bearer token2",
178
+ }
179
+
180
+ obfuscated = middleware.obfuscate_headers(headers)
181
+ assert obfuscated["Authorization"] == "***"
182
+ assert obfuscated["AUTHORIZATION"] == "***"
183
+
184
+ def test_obfuscate_cookies_masks_sensitive_cookies(self):
185
+ """Sensitive cookies should be obfuscated to '***'."""
186
+ config = LoggingConfig(obfuscate_cookies={"sessionid", "csrftoken"})
187
+ middleware = LoggingMiddleware(config)
188
+
189
+ cookies = {
190
+ "sessionid": "abc123xyz789",
191
+ "csrftoken": "token456def",
192
+ "preferences": "dark-mode",
193
+ }
194
+
195
+ obfuscated = middleware.obfuscate_cookies(cookies)
196
+ assert obfuscated["sessionid"] == "***", "sessionid cookie must be obfuscated"
197
+ assert obfuscated["csrftoken"] == "***", "csrftoken cookie must be obfuscated"
198
+ assert obfuscated["preferences"] == "dark-mode"
199
+
200
+ def test_extract_request_data_includes_configured_fields_only(self):
201
+ """Only configured request fields should be extracted."""
202
+ config = LoggingConfig(request_log_fields={"method", "path"})
203
+ middleware = LoggingMiddleware(config)
204
+
205
+ request = {
206
+ "method": "POST",
207
+ "path": "/api/users",
208
+ "query_params": {"page": "1"},
209
+ "headers": {"content-type": "application/json"},
210
+ }
211
+
212
+ data = middleware.extract_request_data(request)
213
+ assert "method" in data
214
+ assert "path" in data
215
+ assert "query" not in data, "Query should not be included when not configured"
216
+ assert "headers" not in data, "Headers should not be included when not configured"
217
+
218
+ def test_extract_request_data_includes_query_params_when_configured(self):
219
+ """Query params should be included when configured."""
220
+ config = LoggingConfig(request_log_fields={"method", "path", "query"})
221
+ middleware = LoggingMiddleware(config)
222
+
223
+ request = {
224
+ "method": "GET",
225
+ "path": "/api/users",
226
+ "query_params": {"page": "1", "limit": "10"},
227
+ }
228
+
229
+ data = middleware.extract_request_data(request)
230
+ assert "query" in data
231
+ assert data["query"] == {"page": "1", "limit": "10"}
232
+
233
+ def test_extract_request_data_obfuscates_headers(self):
234
+ """Headers should be obfuscated when included."""
235
+ config = LoggingConfig(
236
+ request_log_fields={"method", "path", "headers"},
237
+ obfuscate_headers={"authorization"},
238
+ )
239
+ middleware = LoggingMiddleware(config)
240
+
241
+ request = {
242
+ "method": "POST",
243
+ "path": "/api/users",
244
+ "headers": {
245
+ "content-type": "application/json",
246
+ "authorization": "Bearer secret-token",
247
+ },
248
+ }
249
+
250
+ data = middleware.extract_request_data(request)
251
+ assert "headers" in data
252
+ assert data["headers"]["authorization"] == "***", "Authorization must be obfuscated"
253
+ assert data["headers"]["content-type"] == "application/json"
254
+
255
+ def test_extract_request_data_includes_body_when_configured(self):
256
+ """Request body should be included when log_request_body=True."""
257
+ config = LoggingConfig(
258
+ request_log_fields={"method", "path", "body"},
259
+ log_request_body=True,
260
+ max_body_log_size=100,
261
+ )
262
+ middleware = LoggingMiddleware(config)
263
+
264
+ request = {
265
+ "method": "POST",
266
+ "path": "/api/users",
267
+ "body": b'{"name": "test", "email": "test@example.com"}',
268
+ }
269
+
270
+ data = middleware.extract_request_data(request)
271
+ assert "body" in data
272
+ assert "test@example.com" in data["body"]
273
+
274
+ def test_extract_request_data_skips_body_when_too_large(self):
275
+ """Request body should be skipped when exceeding max_body_log_size."""
276
+ config = LoggingConfig(
277
+ request_log_fields={"method", "path", "body"},
278
+ log_request_body=True,
279
+ max_body_log_size=10,
280
+ )
281
+ middleware = LoggingMiddleware(config)
282
+
283
+ request = {
284
+ "method": "POST",
285
+ "path": "/api/users",
286
+ "body": b'{"name": "test", "email": "test@example.com"}', # >10 bytes
287
+ }
288
+
289
+ data = middleware.extract_request_data(request)
290
+ assert "body" not in data, "Body should not be included when too large"
291
+
292
+ def test_extract_request_data_handles_binary_body(self):
293
+ """Binary body should be logged with size info."""
294
+ config = LoggingConfig(
295
+ request_log_fields={"method", "path", "body"},
296
+ log_request_body=True,
297
+ max_body_log_size=100,
298
+ )
299
+ middleware = LoggingMiddleware(config)
300
+
301
+ request = {
302
+ "method": "POST",
303
+ "path": "/api/upload",
304
+ "body": b'\xff\xfe\x00\x01', # Binary data
305
+ }
306
+
307
+ data = middleware.extract_request_data(request)
308
+ assert "body" in data
309
+ assert "<binary data," in data["body"]
310
+ assert "bytes>" in data["body"]
311
+
312
+ def test_extract_request_data_extracts_client_ip_from_x_forwarded_for(self):
313
+ """Client IP should be extracted from X-Forwarded-For header."""
314
+ config = LoggingConfig(request_log_fields={"client_ip"})
315
+ middleware = LoggingMiddleware(config)
316
+
317
+ request = {
318
+ "method": "GET",
319
+ "path": "/api/users",
320
+ "headers": {"x-forwarded-for": "192.168.1.1, 10.0.0.1"},
321
+ }
322
+
323
+ data = middleware.extract_request_data(request)
324
+ assert data["client_ip"] == "192.168.1.1", "Should extract first IP from X-Forwarded-For"
325
+
326
+ def test_extract_request_data_extracts_client_ip_from_x_real_ip(self):
327
+ """Client IP should be extracted from X-Real-IP header."""
328
+ config = LoggingConfig(request_log_fields={"client_ip"})
329
+ middleware = LoggingMiddleware(config)
330
+
331
+ request = {
332
+ "method": "GET",
333
+ "path": "/api/users",
334
+ "headers": {"x-real-ip": "192.168.1.2"},
335
+ }
336
+
337
+ data = middleware.extract_request_data(request)
338
+ assert data["client_ip"] == "192.168.1.2"
339
+
340
+ def test_extract_request_data_extracts_user_agent(self):
341
+ """User agent should be extracted when configured."""
342
+ config = LoggingConfig(request_log_fields={"user_agent"})
343
+ middleware = LoggingMiddleware(config)
344
+
345
+ request = {
346
+ "method": "GET",
347
+ "path": "/api/users",
348
+ "headers": {"user-agent": "Mozilla/5.0"},
349
+ }
350
+
351
+ data = middleware.extract_request_data(request)
352
+ assert data["user_agent"] == "Mozilla/5.0"
353
+
354
+ def test_log_request_skips_when_path_in_skip_paths(self, caplog):
355
+ """Requests to skip_paths should not be logged."""
356
+ config = LoggingConfig(skip_paths={"/health"})
357
+ middleware = LoggingMiddleware(config)
358
+
359
+ request = {"method": "GET", "path": "/health"}
360
+
361
+ with caplog.at_level(logging.DEBUG):
362
+ middleware.log_request(request)
363
+
364
+ assert len(caplog.records) == 0, "Health checks should not be logged"
365
+
366
+ def test_log_request_logs_when_debug_enabled(self, caplog):
367
+ """Requests should be logged at DEBUG level when logger is enabled for DEBUG."""
368
+ config = LoggingConfig(logger_name="test.logger")
369
+ middleware = LoggingMiddleware(config)
370
+
371
+ request = {"method": "GET", "path": "/api/users"}
372
+
373
+ with caplog.at_level(logging.DEBUG, logger="test.logger"):
374
+ middleware.log_request(request)
375
+
376
+ # Should have logged at DEBUG level
377
+ assert len(caplog.records) > 0, "Request should be logged at DEBUG level"
378
+ assert caplog.records[0].levelno == logging.DEBUG
379
+
380
+ def test_log_request_skips_when_debug_disabled(self, caplog):
381
+ """Requests should not be logged when logger is not enabled for DEBUG."""
382
+ config = LoggingConfig(logger_name="test.logger")
383
+ middleware = LoggingMiddleware(config)
384
+
385
+ request = {"method": "GET", "path": "/api/users"}
386
+
387
+ # Set level to INFO (higher than DEBUG)
388
+ with caplog.at_level(logging.INFO, logger="test.logger"):
389
+ middleware.log_request(request)
390
+
391
+ # Should not log because DEBUG is disabled
392
+ assert len(caplog.records) == 0, "Request should not be logged when DEBUG is disabled"
393
+
394
+ def test_log_response_uses_info_level_for_2xx(self, caplog):
395
+ """Successful 2xx responses should be logged at INFO level."""
396
+ config = LoggingConfig(logger_name="test.logger")
397
+ middleware = LoggingMiddleware(config)
398
+
399
+ request = {"method": "POST", "path": "/api/users"}
400
+
401
+ with caplog.at_level(logging.INFO, logger="test.logger"):
402
+ middleware.log_response(request, 201, 0.1)
403
+
404
+ assert len(caplog.records) > 0, "2xx response should be logged"
405
+ assert caplog.records[0].levelno == logging.INFO
406
+
407
+ def test_log_response_uses_info_level_for_3xx(self, caplog):
408
+ """Redirect 3xx responses should be logged at INFO level."""
409
+ config = LoggingConfig(logger_name="test.logger")
410
+ middleware = LoggingMiddleware(config)
411
+
412
+ request = {"method": "GET", "path": "/api/redirect"}
413
+
414
+ with caplog.at_level(logging.INFO, logger="test.logger"):
415
+ middleware.log_response(request, 302, 0.05)
416
+
417
+ assert len(caplog.records) > 0, "3xx response should be logged"
418
+ assert caplog.records[0].levelno == logging.INFO
419
+
420
+ def test_log_response_uses_warning_level_for_4xx(self, caplog):
421
+ """Client error 4xx responses should be logged at WARNING level."""
422
+ config = LoggingConfig(logger_name="test.logger")
423
+ middleware = LoggingMiddleware(config)
424
+
425
+ request = {"method": "GET", "path": "/api/notfound"}
426
+
427
+ with caplog.at_level(logging.WARNING, logger="test.logger"):
428
+ middleware.log_response(request, 404, 0.05)
429
+
430
+ assert len(caplog.records) > 0, "4xx response should be logged"
431
+ assert caplog.records[0].levelno == logging.WARNING
432
+
433
+ def test_log_response_uses_error_level_for_5xx(self, caplog):
434
+ """Server error 5xx responses should be logged at ERROR level."""
435
+ config = LoggingConfig(logger_name="test.logger")
436
+ middleware = LoggingMiddleware(config)
437
+
438
+ request = {"method": "GET", "path": "/api/error"}
439
+
440
+ with caplog.at_level(logging.ERROR, logger="test.logger"):
441
+ middleware.log_response(request, 500, 0.1)
442
+
443
+ assert len(caplog.records) > 0, "5xx response should be logged"
444
+ assert caplog.records[0].levelno == logging.ERROR
445
+
446
+ def test_log_response_skips_for_skip_paths(self, caplog):
447
+ """Responses for skip_paths should not be logged."""
448
+ config = LoggingConfig(skip_paths={"/metrics"})
449
+ middleware = LoggingMiddleware(config)
450
+
451
+ request = {"method": "GET", "path": "/metrics"}
452
+
453
+ with caplog.at_level(logging.INFO):
454
+ middleware.log_response(request, 200, 0.01)
455
+
456
+ assert len(caplog.records) == 0, "Metrics endpoint should not be logged"
457
+
458
+ def test_log_response_skips_for_skip_status_codes(self, caplog):
459
+ """Responses with skip_status_codes should not be logged."""
460
+ config = LoggingConfig(skip_status_codes={204})
461
+ middleware = LoggingMiddleware(config)
462
+
463
+ request = {"method": "DELETE", "path": "/api/users/1"}
464
+
465
+ with caplog.at_level(logging.INFO):
466
+ middleware.log_response(request, 204, 0.01)
467
+
468
+ assert len(caplog.records) == 0, "204 responses should not be logged when configured"
469
+
470
+ def test_log_response_skips_when_info_disabled_for_2xx(self, caplog):
471
+ """2xx responses should not be logged when logger is not enabled for INFO."""
472
+ config = LoggingConfig(logger_name="test.logger")
473
+ middleware = LoggingMiddleware(config)
474
+
475
+ request = {"method": "GET", "path": "/api/users"}
476
+
477
+ # Set level to ERROR (higher than INFO)
478
+ with caplog.at_level(logging.ERROR, logger="test.logger"):
479
+ middleware.log_response(request, 200, 0.1)
480
+
481
+ # Should not log 2xx when INFO is disabled
482
+ assert len(caplog.records) == 0, "2xx should not be logged when INFO is disabled"
483
+
484
+ def test_log_response_always_logs_4xx_regardless_of_level(self, caplog):
485
+ """4xx errors should always be logged even if WARNING is disabled (they use WARNING level)."""
486
+ config = LoggingConfig(logger_name="test.logger")
487
+ middleware = LoggingMiddleware(config)
488
+
489
+ request = {"method": "GET", "path": "/api/notfound"}
490
+
491
+ # Set level to WARNING to capture WARNING logs
492
+ with caplog.at_level(logging.WARNING, logger="test.logger"):
493
+ middleware.log_response(request, 404, 0.05)
494
+
495
+ # 4xx should be logged at WARNING level
496
+ assert len(caplog.records) > 0, "4xx should be logged at WARNING level"
497
+
498
+ def test_log_response_always_logs_5xx_regardless_of_level(self, caplog):
499
+ """5xx errors should always be logged at ERROR level."""
500
+ config = LoggingConfig(logger_name="test.logger")
501
+ middleware = LoggingMiddleware(config)
502
+
503
+ request = {"method": "GET", "path": "/api/error"}
504
+
505
+ with caplog.at_level(logging.ERROR, logger="test.logger"):
506
+ middleware.log_response(request, 500, 0.1)
507
+
508
+ # 5xx should be logged at ERROR level
509
+ assert len(caplog.records) > 0, "5xx should be logged at ERROR level"
510
+
511
+ def test_log_response_respects_sample_rate_for_2xx(self):
512
+ """Sample rate should reduce 2xx/3xx log volume probabilistically."""
513
+ config = LoggingConfig(sample_rate=0.0, logger_name="test.logger")
514
+ middleware = LoggingMiddleware(config)
515
+
516
+ request = {"method": "GET", "path": "/api/users"}
517
+
518
+ # With sample_rate=0.0, no 2xx responses should be logged
519
+ logged_count = 0
520
+ for _ in range(100):
521
+ logger = logging.getLogger("test.logger")
522
+ logger.handlers.clear()
523
+ handler = logging.Handler()
524
+ handler.handle = Mock()
525
+ logger.addHandler(handler)
526
+ logger.setLevel(logging.INFO)
527
+
528
+ middleware.log_response(request, 200, 0.1)
529
+
530
+ if handler.handle.called:
531
+ logged_count += 1
532
+
533
+ assert logged_count == 0, "With sample_rate=0.0, no 2xx responses should be logged"
534
+
535
+ def test_log_response_does_not_sample_errors(self, caplog):
536
+ """Sample rate should NOT affect 4xx/5xx errors - they should always be logged."""
537
+ config = LoggingConfig(sample_rate=0.0, logger_name="test.logger")
538
+ middleware = LoggingMiddleware(config)
539
+
540
+ request = {"method": "GET", "path": "/api/error"}
541
+
542
+ # Even with sample_rate=0.0, errors should be logged
543
+ with caplog.at_level(logging.ERROR, logger="test.logger"):
544
+ middleware.log_response(request, 500, 0.1)
545
+
546
+ assert len(caplog.records) > 0, "Errors should be logged regardless of sample_rate"
547
+
548
+ def test_log_response_respects_min_duration_ms_for_2xx(self, caplog):
549
+ """Fast 2xx responses should be skipped when below min_duration_ms threshold."""
550
+ config = LoggingConfig(min_duration_ms=250, logger_name="test.logger")
551
+ middleware = LoggingMiddleware(config)
552
+
553
+ request = {"method": "GET", "path": "/api/users"}
554
+
555
+ # Fast response (100ms < 250ms)
556
+ with caplog.at_level(logging.INFO, logger="test.logger"):
557
+ middleware.log_response(request, 200, 0.1)
558
+
559
+ assert len(caplog.records) == 0, "Fast 2xx responses should not be logged with min_duration_ms"
560
+
561
+ def test_log_response_logs_slow_2xx_above_min_duration_ms(self, caplog):
562
+ """Slow 2xx responses should be logged when above min_duration_ms threshold."""
563
+ config = LoggingConfig(min_duration_ms=250, logger_name="test.logger")
564
+ middleware = LoggingMiddleware(config)
565
+
566
+ request = {"method": "GET", "path": "/api/users"}
567
+
568
+ # Slow response (300ms > 250ms)
569
+ with caplog.at_level(logging.INFO, logger="test.logger"):
570
+ middleware.log_response(request, 200, 0.3)
571
+
572
+ assert len(caplog.records) > 0, "Slow 2xx responses should be logged"
573
+
574
+ def test_log_response_ignores_min_duration_ms_for_errors(self, caplog):
575
+ """min_duration_ms should NOT affect 4xx/5xx errors - they should always be logged."""
576
+ config = LoggingConfig(min_duration_ms=250, logger_name="test.logger")
577
+ middleware = LoggingMiddleware(config)
578
+
579
+ request = {"method": "GET", "path": "/api/error"}
580
+
581
+ # Fast error (50ms < 250ms)
582
+ with caplog.at_level(logging.ERROR, logger="test.logger"):
583
+ middleware.log_response(request, 500, 0.05)
584
+
585
+ assert len(caplog.records) > 0, "Errors should be logged regardless of min_duration_ms"
586
+
587
+ def test_log_response_includes_duration_in_milliseconds(self, caplog):
588
+ """Duration should be logged in milliseconds when configured."""
589
+ config = LoggingConfig(
590
+ logger_name="test.logger",
591
+ response_log_fields={"status_code", "duration"}
592
+ )
593
+ middleware = LoggingMiddleware(config)
594
+
595
+ request = {"method": "GET", "path": "/api/users"}
596
+
597
+ with caplog.at_level(logging.INFO, logger="test.logger"):
598
+ middleware.log_response(request, 200, 0.123)
599
+
600
+ assert len(caplog.records) > 0
601
+ log_message = caplog.records[0].message
602
+ assert "123" in log_message, "Duration should be in milliseconds"
603
+ assert "ms" in log_message
604
+
605
+ def test_log_response_includes_response_size_when_configured(self, caplog):
606
+ """Response size should be logged when configured."""
607
+ config = LoggingConfig(
608
+ logger_name="test.logger",
609
+ response_log_fields={"status_code", "size"}
610
+ )
611
+ middleware = LoggingMiddleware(config)
612
+
613
+ request = {"method": "GET", "path": "/api/users"}
614
+
615
+ with caplog.at_level(logging.INFO, logger="test.logger"):
616
+ middleware.log_response(request, 200, 0.1, response_size=1024)
617
+
618
+ assert len(caplog.records) > 0
619
+ assert caplog.records[0].response_size == 1024
620
+
621
+ def test_log_exception_logs_at_error_level(self, caplog):
622
+ """Exceptions should be logged at ERROR level by default."""
623
+ config = LoggingConfig(logger_name="test.logger")
624
+ middleware = LoggingMiddleware(config)
625
+
626
+ request = {"method": "GET", "path": "/api/error"}
627
+ exc = ValueError("Something went wrong")
628
+
629
+ with caplog.at_level(logging.ERROR, logger="test.logger"):
630
+ middleware.log_exception(request, exc, exc_info=False)
631
+
632
+ assert len(caplog.records) > 0, "Exception should be logged"
633
+ assert caplog.records[0].levelno == logging.ERROR
634
+
635
+ def test_log_exception_includes_exception_details(self, caplog):
636
+ """Exception logs should include exception type and message."""
637
+ config = LoggingConfig(logger_name="test.logger")
638
+ middleware = LoggingMiddleware(config)
639
+
640
+ request = {"method": "POST", "path": "/api/users"}
641
+ exc = ValueError("Invalid user data")
642
+
643
+ with caplog.at_level(logging.ERROR, logger="test.logger"):
644
+ middleware.log_exception(request, exc, exc_info=False)
645
+
646
+ assert len(caplog.records) > 0
647
+ record = caplog.records[0]
648
+ assert "ValueError" in record.message
649
+ assert "Invalid user data" in record.message
650
+ assert record.exception_type == "ValueError"
651
+ assert record.exception == "Invalid user data"
652
+
653
+ def test_log_exception_includes_request_context(self, caplog):
654
+ """Exception logs should include request method and path."""
655
+ config = LoggingConfig(logger_name="test.logger")
656
+ middleware = LoggingMiddleware(config)
657
+
658
+ request = {"method": "DELETE", "path": "/api/users/123"}
659
+ exc = RuntimeError("Deletion failed")
660
+
661
+ with caplog.at_level(logging.ERROR, logger="test.logger"):
662
+ middleware.log_exception(request, exc, exc_info=False)
663
+
664
+ assert len(caplog.records) > 0
665
+ record = caplog.records[0]
666
+ assert record.method == "DELETE"
667
+ assert record.path == "/api/users/123"
668
+
669
+ def test_log_exception_uses_custom_handler_when_provided(self):
670
+ """Custom exception handler should be called when provided."""
671
+ custom_handler = Mock()
672
+ config = LoggingConfig(
673
+ logger_name="test.logger",
674
+ exception_logging_handler=custom_handler
675
+ )
676
+ middleware = LoggingMiddleware(config)
677
+
678
+ request = {"method": "GET", "path": "/api/error"}
679
+ exc = Exception("Test error")
680
+
681
+ middleware.log_exception(request, exc, exc_info=True)
682
+
683
+ custom_handler.assert_called_once()
684
+ args = custom_handler.call_args[0]
685
+ assert args[1] == request # request
686
+ assert args[2] == exc # exception
687
+
688
+
689
+ class TestLoggingHelpers:
690
+ """Test logging helper functions."""
691
+
692
+ def test_create_logging_middleware_with_custom_logger_name(self):
693
+ """create_logging_middleware should accept custom logger name."""
694
+ middleware = create_logging_middleware(logger_name="custom.logger")
695
+ assert isinstance(middleware, LoggingMiddleware)
696
+ assert middleware.config.logger_name == "custom.logger"
697
+
698
+ def test_create_logging_middleware_with_custom_log_level(self):
699
+ """create_logging_middleware should accept custom log level."""
700
+ middleware = create_logging_middleware(log_level="DEBUG")
701
+ assert isinstance(middleware, LoggingMiddleware)
702
+ assert middleware.config.log_level == "DEBUG"
703
+
704
+ def test_create_logging_middleware_with_kwargs(self):
705
+ """create_logging_middleware should accept additional kwargs."""
706
+ middleware = create_logging_middleware(
707
+ skip_paths={"/custom"},
708
+ sample_rate=0.1
709
+ )
710
+ assert isinstance(middleware, LoggingMiddleware)
711
+ assert "/custom" in middleware.config.skip_paths
712
+ assert middleware.config.sample_rate == 0.1
713
+
714
+ def test_create_logging_middleware_uses_defaults_when_no_args(self):
715
+ """create_logging_middleware should use defaults when no args provided."""
716
+ middleware = create_logging_middleware()
717
+ assert isinstance(middleware, LoggingMiddleware)
718
+ assert middleware.config is not None
719
+
720
+
721
+ class TestQueueBasedLogging:
722
+ """Test queue-based non-blocking logging setup documented in LOGGING.md."""
723
+
724
+ def test_ensure_queue_logging_returns_queue_handler(self):
725
+ """_ensure_queue_logging should return a QueueHandler."""
726
+ handler = _ensure_queue_logging("INFO")
727
+ assert isinstance(handler, QueueHandler), "Must return QueueHandler for non-blocking logging"
728
+
729
+ def test_ensure_queue_logging_creates_queue_listener(self):
730
+ """_ensure_queue_logging should create and start a QueueListener."""
731
+ from django_bolt.logging.config import _QUEUE_LISTENER
732
+
733
+ # Reset global state for test
734
+ import django_bolt.logging.config as config_module
735
+ config_module._QUEUE_LISTENER = None
736
+ config_module._QUEUE = None
737
+
738
+ handler = _ensure_queue_logging("INFO")
739
+
740
+ # Should have created listener
741
+ assert config_module._QUEUE_LISTENER is not None, "QueueListener should be created"
742
+ assert config_module._QUEUE is not None, "Queue should be created"
743
+
744
+ def test_setup_django_logging_configures_queue_handlers(self):
745
+ """setup_django_logging should configure queue handlers for django loggers."""
746
+ # Configure Django settings for test
747
+ from django.conf import settings
748
+ if not settings.configured:
749
+ settings.configure(
750
+ DEBUG=True,
751
+ SECRET_KEY='test-secret-key',
752
+ )
753
+
754
+ # Reset global state
755
+ import django_bolt.logging.config as config_module
756
+ config_module._LOGGING_CONFIGURED = False
757
+ config_module._QUEUE_LISTENER = None
758
+ config_module._QUEUE = None
759
+
760
+ # Clear existing handlers
761
+ for logger_name in ("django", "django.server", "django_bolt", ""):
762
+ logger = logging.getLogger(logger_name)
763
+ logger.handlers.clear()
764
+
765
+ setup_django_logging(force=True)
766
+
767
+ # Check that loggers have queue handlers
768
+ django_logger = logging.getLogger("django")
769
+ django_server_logger = logging.getLogger("django.server")
770
+ django_bolt_logger = logging.getLogger("django_bolt")
771
+
772
+ # All should have handlers after setup
773
+ assert len(django_logger.handlers) > 0, "django logger should have handlers"
774
+ assert len(django_server_logger.handlers) > 0, "django.server logger should have handlers"
775
+ assert len(django_bolt_logger.handlers) > 0, "django_bolt logger should have handlers"
776
+
777
+ # At least one should be a QueueHandler
778
+ root_logger = logging.getLogger()
779
+ has_queue_handler = any(isinstance(h, QueueHandler) for h in root_logger.handlers)
780
+ assert has_queue_handler, "Root logger should have a QueueHandler"
781
+
782
+ def test_setup_django_logging_respects_explicit_logging_config(self):
783
+ """setup_django_logging should skip setup when LOGGING is explicitly configured."""
784
+ # Configure Django settings with explicit LOGGING
785
+ from django.conf import settings
786
+ if not settings.configured:
787
+ settings.configure(
788
+ DEBUG=True,
789
+ SECRET_KEY='test-secret-key',
790
+ LOGGING={"version": 1, "disable_existing_loggers": False},
791
+ )
792
+
793
+ # Reset global state
794
+ import django_bolt.logging.config as config_module
795
+ config_module._LOGGING_CONFIGURED = False
796
+
797
+ # Count handlers before
798
+ django_logger = logging.getLogger("django")
799
+ handlers_before = len(django_logger.handlers)
800
+
801
+ setup_django_logging(force=True)
802
+
803
+ # Should mark as configured
804
+ assert config_module._LOGGING_CONFIGURED is True
805
+
806
+ # Should not have added handlers (respects explicit LOGGING)
807
+ handlers_after = len(django_logger.handlers)
808
+ # With explicit LOGGING, we don't modify handlers
809
+ assert handlers_after == handlers_before, "Should not modify handlers with explicit LOGGING"
810
+
811
+ def test_setup_django_logging_is_idempotent(self):
812
+ """setup_django_logging should not reconfigure when called multiple times."""
813
+ # Configure Django settings for test
814
+ from django.conf import settings
815
+ if not settings.configured:
816
+ settings.configure(
817
+ DEBUG=True,
818
+ SECRET_KEY='test-secret-key',
819
+ )
820
+
821
+ import django_bolt.logging.config as config_module
822
+
823
+ # First call
824
+ config_module._LOGGING_CONFIGURED = False
825
+ setup_django_logging(force=True)
826
+ first_configured = config_module._LOGGING_CONFIGURED
827
+
828
+ # Second call (should be no-op)
829
+ setup_django_logging()
830
+ second_configured = config_module._LOGGING_CONFIGURED
831
+
832
+ assert first_configured is True, "First call should configure logging"
833
+ assert second_configured is True, "Second call should preserve configured state"
834
+
835
+
836
+ if __name__ == "__main__":
837
+ pytest.main([__file__, "-v", "-s"])