aegis-stack 0.1.0__py3-none-any.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 aegis-stack might be problematic. Click here for more details.

Files changed (103) hide show
  1. aegis/__init__.py +5 -0
  2. aegis/__main__.py +374 -0
  3. aegis/core/CLAUDE.md +365 -0
  4. aegis/core/__init__.py +6 -0
  5. aegis/core/components.py +115 -0
  6. aegis/core/dependency_resolver.py +119 -0
  7. aegis/core/template_generator.py +163 -0
  8. aegis/templates/CLAUDE.md +306 -0
  9. aegis/templates/cookiecutter-aegis-project/cookiecutter.json +27 -0
  10. aegis/templates/cookiecutter-aegis-project/hooks/post_gen_project.py +172 -0
  11. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.dockerignore +71 -0
  12. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.env.example.j2 +70 -0
  13. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.gitignore +127 -0
  14. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  15. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/Makefile +211 -0
  16. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/README.md.j2 +196 -0
  17. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/__init__.py +5 -0
  18. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/__init__.py +6 -0
  19. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/health.py +321 -0
  20. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/load_test.py +638 -0
  21. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/main.py +41 -0
  22. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/__init__.py +0 -0
  23. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/__init__.py +0 -0
  24. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/health.py +134 -0
  25. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/models.py.j2 +247 -0
  26. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/routing.py.j2 +14 -0
  27. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/tasks.py.j2 +596 -0
  28. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/hooks.py +133 -0
  29. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/main.py +16 -0
  30. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/middleware/__init__.py +1 -0
  31. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/middleware/cors.py +20 -0
  32. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/shutdown/__init__.py +1 -0
  33. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/shutdown/cleanup.py +14 -0
  34. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/startup/__init__.py +1 -0
  35. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/startup/component_health.py.j2 +190 -0
  36. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/__init__.py +0 -0
  37. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/core/__init__.py +1 -0
  38. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/core/theme.py +46 -0
  39. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/main.py +687 -0
  40. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/scheduler/__init__.py +1 -0
  41. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/scheduler/main.py +138 -0
  42. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/CLAUDE.md +213 -0
  43. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/__init__.py +6 -0
  44. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/constants.py.j2 +30 -0
  45. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/pools.py +78 -0
  46. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/__init__.py +1 -0
  47. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/load_test.py +48 -0
  48. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/media.py +41 -0
  49. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/system.py +36 -0
  50. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/registry.py +139 -0
  51. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/__init__.py +119 -0
  52. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/load_tasks.py +526 -0
  53. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/simple_system_tasks.py +32 -0
  54. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/system_tasks.py +279 -0
  55. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/config.py.j2 +119 -0
  56. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/constants.py +60 -0
  57. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/db.py +67 -0
  58. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/log.py +85 -0
  59. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/__init__.py +1 -0
  60. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/webserver.py +40 -0
  61. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/{% if cookiecutter.include_scheduler == /"yes/" %}scheduler.py{% endif %}" +21 -0
  62. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/integrations/__init__.py +0 -0
  63. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/integrations/main.py +61 -0
  64. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/py.typed +0 -0
  65. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/__init__.py +1 -0
  66. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/load_test.py +661 -0
  67. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/load_test_models.py +269 -0
  68. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/shared/__init__.py +15 -0
  69. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/shared/models.py +26 -0
  70. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/__init__.py +52 -0
  71. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/alerts.py +94 -0
  72. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/health.py.j2 +1105 -0
  73. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/models.py +169 -0
  74. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/ui.py +52 -0
  75. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docker-compose.yml.j2 +195 -0
  76. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/api.md +191 -0
  77. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/components/scheduler.md +414 -0
  78. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/development.md +215 -0
  79. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/health.md +240 -0
  80. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/javascripts/mermaid-config.js +62 -0
  81. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/stylesheets/mermaid.css +95 -0
  82. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/mkdocs.yml.j2 +62 -0
  83. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/pyproject.toml.j2 +156 -0
  84. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/entrypoint.sh +87 -0
  85. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/entrypoint.sh.j2 +104 -0
  86. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/gen_docs.py +16 -0
  87. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/api/__init__.py +1 -0
  88. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/api/test_health_endpoints.py.j2 +239 -0
  89. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/components/test_scheduler.py +76 -0
  90. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/conftest.py.j2 +81 -0
  91. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/__init__.py +1 -0
  92. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_component_integration.py.j2 +376 -0
  93. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_health_logic.py.j2 +633 -0
  94. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_load_test_models.py +665 -0
  95. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_load_test_service.py +602 -0
  96. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_system_service.py +96 -0
  97. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_worker_health_registration.py.j2 +224 -0
  98. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/test_core.py +50 -0
  99. aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/uv.lock +1673 -0
  100. aegis_stack-0.1.0.dist-info/METADATA +114 -0
  101. aegis_stack-0.1.0.dist-info/RECORD +103 -0
  102. aegis_stack-0.1.0.dist-info/WHEEL +4 -0
  103. aegis_stack-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,633 @@
1
+ """
2
+ Tests for core health logic and component status propagation.
3
+
4
+ These tests focus on the pure logic of health checking, warning propagation,
5
+ and component hierarchy without external dependencies like Redis or system metrics.
6
+ """
7
+
8
+ from datetime import UTC, datetime
9
+ from unittest.mock import AsyncMock, MagicMock, patch
10
+ from typing import Any
11
+
12
+ import pytest
13
+
14
+ from app.services.system import ComponentStatus, ComponentStatusType
15
+ from app.services.system.health import (
16
+ get_system_status,
17
+ )
18
+
19
+
20
+
21
+ class TestHealthUtilityFunctions:
22
+ """Test utility functions used in health checks."""
23
+
24
+ def test_format_bytes(self) -> None:
25
+ """Test format_bytes utility function."""
26
+ from app.services.system.health import format_bytes
27
+
28
+ # Test various byte sizes
29
+ assert format_bytes(0) == "0 B"
30
+ assert format_bytes(512) == "512 B"
31
+ assert format_bytes(1024) == "1.0 KB"
32
+ assert format_bytes(1536) == "1.5 KB"
33
+ assert format_bytes(2048) == "2.0 KB"
34
+ assert format_bytes(1048576) == "1.0 MB"
35
+ assert format_bytes(1572864) == "1.5 MB"
36
+ assert format_bytes(1073741824) == "1.0 GB"
37
+ assert format_bytes(1099511627776) == "1.0 TB"
38
+
39
+ # Test edge cases
40
+ assert format_bytes(1) == "1 B"
41
+ assert format_bytes(1023) == "1023 B"
42
+ assert format_bytes(8192) == "8.0 KB"
43
+
44
+
45
+ class TestComponentStatusPropagation:
46
+ """Test warning status propagation through component hierarchies."""
47
+
48
+ def test_component_status_creation_with_warning(self) -> None:
49
+ """Test creating ComponentStatus with warning status."""
50
+ status = ComponentStatus(
51
+ name="test_component",
52
+ status=ComponentStatusType.WARNING,
53
+ message="Has warnings but still healthy",
54
+ response_time_ms=100.0,
55
+ )
56
+
57
+ assert status.name == "test_component"
58
+ assert status.healthy is True
59
+ assert status.status == ComponentStatusType.WARNING
60
+ assert status.message == "Has warnings but still healthy"
61
+
62
+ def test_component_status_defaults_to_healthy(self) -> None:
63
+ """Test that ComponentStatus defaults to HEALTHY status."""
64
+ status = ComponentStatus(
65
+ name="test_component",
66
+ message="All good",
67
+ )
68
+
69
+ assert status.status == ComponentStatusType.HEALTHY
70
+
71
+ def test_unhealthy_component_with_unhealthy_status(self) -> None:
72
+ """Test that unhealthy components get UNHEALTHY status."""
73
+ status = ComponentStatus(
74
+ name="test_component",
75
+ status=ComponentStatusType.UNHEALTHY,
76
+ message="Something is broken",
77
+ )
78
+
79
+ assert status.healthy is False
80
+ assert status.status == ComponentStatusType.UNHEALTHY
81
+
82
+ def test_sub_component_hierarchy(self) -> None:
83
+ """Test component with sub-components for hierarchy testing."""
84
+ # Create sub-components with different statuses
85
+ sub_component_healthy = ComponentStatus(
86
+ name="sub_healthy",
87
+ status=ComponentStatusType.HEALTHY,
88
+ message="Sub-component is healthy",
89
+ )
90
+
91
+ sub_component_warning = ComponentStatus(
92
+ name="sub_warning",
93
+ status=ComponentStatusType.WARNING,
94
+ message="Sub-component has warnings",
95
+ )
96
+
97
+ # Create parent component
98
+ parent_component = ComponentStatus(
99
+ name="parent",
100
+ status=ComponentStatusType.WARNING, # Should propagate from sub-components
101
+ message="Parent has sub-component warnings",
102
+ sub_components={
103
+ "sub_healthy": sub_component_healthy,
104
+ "sub_warning": sub_component_warning,
105
+ }
106
+ )
107
+
108
+ assert parent_component.healthy is True
109
+ assert parent_component.status == ComponentStatusType.WARNING
110
+ assert len(parent_component.sub_components) == 2
111
+ assert (
112
+ parent_component.sub_components["sub_warning"].status
113
+ == ComponentStatusType.WARNING
114
+ )
115
+
116
+
117
+ class TestSystemStatusWarningPropagation:
118
+ """Test warning propagation in real system status scenarios."""
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_system_status_with_mixed_component_health(self) -> None:
122
+ """Test system status calculation with components having different states."""
123
+
124
+ # Mock the health check registry to have controlled components
125
+ mock_healthy_component = AsyncMock(return_value=ComponentStatus(
126
+ name="healthy_service",
127
+ status=ComponentStatusType.HEALTHY,
128
+ message="Service is running well",
129
+ ))
130
+
131
+ mock_warning_component = AsyncMock(return_value=ComponentStatus(
132
+ name="warning_service",
133
+ status=ComponentStatusType.WARNING,
134
+ message="Service has warnings",
135
+ ))
136
+
137
+ mock_unhealthy_component = AsyncMock(return_value=ComponentStatus(
138
+ name="unhealthy_service",
139
+ status=ComponentStatusType.UNHEALTHY,
140
+ message="Service is down",
141
+ ))
142
+
143
+ # Mock the system metrics to avoid actual system calls
144
+ mock_system_metrics = {
145
+ "memory": ComponentStatus(
146
+ name="memory",
147
+ status=ComponentStatusType.HEALTHY,
148
+ message="Memory usage: 50%",
149
+ ),
150
+ "cpu": ComponentStatus(
151
+ name="cpu",
152
+ status=ComponentStatusType.HEALTHY,
153
+ message="CPU usage: 10%",
154
+ ),
155
+ "disk": ComponentStatus(
156
+ name="disk",
157
+ status=ComponentStatusType.HEALTHY,
158
+ message="Disk usage: 30%",
159
+ ),
160
+ }
161
+
162
+ with patch('app.services.system.health._health_checks', {
163
+ 'healthy_service': mock_healthy_component,
164
+ 'warning_service': mock_warning_component,
165
+ 'unhealthy_service': mock_unhealthy_component,
166
+ }), patch(
167
+ 'app.services.system.health._get_cached_system_metrics',
168
+ return_value=mock_system_metrics
169
+ ), patch(
170
+ 'app.services.system.health._get_system_info',
171
+ return_value={"test": "info"}
172
+ ):
173
+
174
+ system_status = await get_system_status()
175
+
176
+ # System should be unhealthy due to unhealthy_service
177
+ assert system_status.overall_healthy is False
178
+
179
+ # Check that components are present in aegis structure
180
+ assert "aegis" in system_status.components
181
+ aegis_component = system_status.components["aegis"]
182
+
183
+ # Check that our test components are included
184
+ assert "healthy_service" in aegis_component.sub_components
185
+ assert "warning_service" in aegis_component.sub_components
186
+ assert "unhealthy_service" in aegis_component.sub_components
187
+ # System metrics grouped under backend
188
+ assert "backend" in aegis_component.sub_components
189
+
190
+ # Verify component statuses
191
+ assert (
192
+ aegis_component.sub_components["healthy_service"].status
193
+ == ComponentStatusType.HEALTHY
194
+ )
195
+ assert (
196
+ aegis_component.sub_components["warning_service"].status
197
+ == ComponentStatusType.WARNING
198
+ )
199
+ assert (
200
+ aegis_component.sub_components["unhealthy_service"].status
201
+ == ComponentStatusType.UNHEALTHY
202
+ )
203
+
204
+ @pytest.mark.asyncio
205
+ async def test_system_status_with_only_warnings_stays_healthy(self) -> None:
206
+ """Test that system with only warnings remains overall healthy."""
207
+
208
+ mock_warning_component = AsyncMock(return_value=ComponentStatus(
209
+ name="warning_service",
210
+ status=ComponentStatusType.WARNING,
211
+ message="Service has warnings but functional",
212
+ ))
213
+
214
+ mock_system_metrics = {
215
+ "memory": ComponentStatus(
216
+ name="memory",
217
+ status=ComponentStatusType.HEALTHY,
218
+ message="Memory usage: 50%",
219
+ ),
220
+ }
221
+
222
+ with patch('app.services.system.health._health_checks', {
223
+ 'warning_service': mock_warning_component,
224
+ }), patch(
225
+ 'app.services.system.health._get_cached_system_metrics',
226
+ return_value=mock_system_metrics
227
+ ), patch(
228
+ 'app.services.system.health._get_system_info',
229
+ return_value={"test": "info"}
230
+ ):
231
+
232
+ system_status = await get_system_status()
233
+
234
+ # System should remain healthy since warnings don't affect overall health
235
+ assert system_status.overall_healthy is True
236
+
237
+ # But aegis component should propagate warning status
238
+ aegis_component = system_status.components["aegis"]
239
+ assert aegis_component.status == ComponentStatusType.WARNING
240
+ assert aegis_component.healthy is True
241
+
242
+
243
+ class TestWorkerHealthLogic:
244
+ """Test the specific worker health check logic and warning propagation."""
245
+
246
+ def test_queue_status_determination_logic(self) -> None:
247
+ """Test the logic for determining queue component status."""
248
+
249
+ # Test case 1: Worker with no functions should be WARNING but healthy
250
+ def check_empty_worker_status(
251
+ queue_type: str,
252
+ has_functions: bool,
253
+ worker_alive: bool,
254
+ failure_rate: float,
255
+ ) -> tuple[bool, ComponentStatusType]:
256
+ """Simulate the queue status logic from worker health check."""
257
+ if not has_functions:
258
+ queue_healthy = True # Empty workers don't affect overall health
259
+ queue_status = ComponentStatusType.WARNING # But show as warning
260
+ else:
261
+ queue_healthy = worker_alive and failure_rate < 25
262
+ queue_status = (
263
+ ComponentStatusType.HEALTHY
264
+ if queue_healthy
265
+ else ComponentStatusType.UNHEALTHY
266
+ )
267
+
268
+ return queue_healthy, queue_status
269
+
270
+ # Empty worker (media/system queues)
271
+ healthy, status = check_empty_worker_status("media", False, False, 100)
272
+ assert healthy is True # Doesn't affect system health
273
+ assert status == ComponentStatusType.WARNING # But shows warning
274
+
275
+ # Active worker with good performance
276
+ healthy, status = check_empty_worker_status("load_test", True, True, 5)
277
+ assert healthy is True
278
+ assert status == ComponentStatusType.HEALTHY
279
+
280
+ # Active worker with high failure rate
281
+ healthy, status = check_empty_worker_status("load_test", True, True, 50)
282
+ assert healthy is False
283
+ assert status == ComponentStatusType.UNHEALTHY
284
+
285
+ # Active worker that's offline
286
+ healthy, status = check_empty_worker_status("load_test", True, False, 0)
287
+ assert healthy is False
288
+ assert status == ComponentStatusType.UNHEALTHY
289
+
290
+ def test_warning_propagation_to_parent_components(self) -> None:
291
+ """Test warning propagation from queue -> queues -> worker."""
292
+
293
+ # Simulate the propagation logic used in worker health check
294
+ def check_warning_propagation(
295
+ sub_components: dict[str, ComponentStatus]
296
+ ) -> ComponentStatusType:
297
+ """Simulate queues component status determination."""
298
+ queues_healthy = all(
299
+ queue.healthy for queue in sub_components.values()
300
+ )
301
+
302
+ has_warnings = any(
303
+ queue.status == ComponentStatusType.WARNING
304
+ for queue in sub_components.values()
305
+ )
306
+
307
+ if has_warnings and queues_healthy:
308
+ return ComponentStatusType.WARNING
309
+ elif queues_healthy:
310
+ return ComponentStatusType.HEALTHY
311
+ else:
312
+ return ComponentStatusType.UNHEALTHY
313
+
314
+ # Test case: Some queues have warnings, all are healthy
315
+ sub_components = {
316
+ "media": ComponentStatus(
317
+ name="media",
318
+ status=ComponentStatusType.WARNING,
319
+ message="No tasks configured",
320
+ ),
321
+ "system": ComponentStatus(
322
+ name="system",
323
+ status=ComponentStatusType.WARNING,
324
+ message="No tasks configured",
325
+ ),
326
+ "load_test": ComponentStatus(
327
+ name="load_test",
328
+ status=ComponentStatusType.HEALTHY,
329
+ message="Active with completed tasks",
330
+ ),
331
+ }
332
+
333
+ queues_status = check_warning_propagation(sub_components)
334
+ assert queues_status == ComponentStatusType.WARNING
335
+
336
+ # Test case: All components healthy
337
+ for component in sub_components.values():
338
+ component.status = ComponentStatusType.HEALTHY
339
+
340
+ queues_status = check_warning_propagation(sub_components)
341
+ assert queues_status == ComponentStatusType.HEALTHY
342
+
343
+ # Test case: One component unhealthy
344
+ sub_components["load_test"].status = ComponentStatusType.UNHEALTHY
345
+
346
+ queues_status = check_warning_propagation(sub_components)
347
+ assert queues_status == ComponentStatusType.UNHEALTHY
348
+
349
+
350
+ class TestComponentMetadata:
351
+ """Test component metadata handling and serialization."""
352
+
353
+ def test_component_status_with_complex_metadata(self) -> None:
354
+ """Test ComponentStatus with complex metadata for different component types."""
355
+
356
+ # Worker component metadata
357
+ worker_metadata = {
358
+ "total_queued": 5,
359
+ "total_completed": 1000,
360
+ "total_failed": 50,
361
+ "overall_failure_rate_percent": 4.8,
362
+ "redis_url": "redis://localhost:6379",
363
+ "queue_configuration": {
364
+ "load_test": {
365
+ "description": "Load testing tasks",
366
+ "max_jobs": 50,
367
+ "timeout_seconds": 300,
368
+ }
369
+ }
370
+ }
371
+
372
+ worker_status = ComponentStatus(
373
+ name="worker",
374
+ status=ComponentStatusType.WARNING,
375
+ message="arq worker infrastructure: 1/3 workers active",
376
+ metadata=worker_metadata,
377
+ )
378
+
379
+ # Verify metadata is preserved
380
+ assert worker_status.metadata["total_completed"] == 1000
381
+ assert worker_status.metadata["overall_failure_rate_percent"] == 4.8
382
+ assert "queue_configuration" in worker_status.metadata
383
+
384
+ # Cache component metadata
385
+ cache_metadata = {
386
+ "implementation": "redis",
387
+ "version": "7.0.0",
388
+ "connected_clients": 2,
389
+ "used_memory_human": "1.5M",
390
+ "uptime_in_seconds": 3600,
391
+ }
392
+
393
+ cache_status = ComponentStatus(
394
+ name="cache",
395
+ status=ComponentStatusType.HEALTHY,
396
+ message="Redis cache connection successful",
397
+ metadata=cache_metadata,
398
+ )
399
+
400
+ assert cache_status.metadata["implementation"] == "redis"
401
+ assert cache_status.metadata["uptime_in_seconds"] == 3600
402
+
403
+ def test_component_status_serialization(self) -> None:
404
+ """Test that ComponentStatus can be properly serialized (for API responses)."""
405
+
406
+ status = ComponentStatus(
407
+ name="test_component",
408
+ status=ComponentStatusType.WARNING,
409
+ message="Component with warning",
410
+ response_time_ms=123.45,
411
+ metadata={"key": "value", "number": 42},
412
+ sub_components={
413
+ "sub1": ComponentStatus(
414
+ name="sub1",
415
+ status=ComponentStatusType.HEALTHY,
416
+ message="Sub-component OK",
417
+ )
418
+ }
419
+ )
420
+
421
+ # Convert to dict (simulates JSON serialization)
422
+ status_dict = status.model_dump()
423
+
424
+ # Verify structure
425
+ assert status_dict["name"] == "test_component"
426
+ assert status_dict["healthy"] is True
427
+ assert status_dict["status"] == "warning"
428
+ assert status_dict["message"] == "Component with warning"
429
+ assert status_dict["response_time_ms"] == 123.45
430
+ assert status_dict["metadata"]["key"] == "value"
431
+ assert "sub1" in status_dict["sub_components"]
432
+ assert status_dict["sub_components"]["sub1"]["status"] == "healthy"
433
+
434
+
435
+ {% if cookiecutter.include_database == "yes" %}
436
+ class TestDatabaseHealthCheck:
437
+ """Test database health check functionality."""
438
+
439
+ @pytest.mark.asyncio
440
+ async def test_database_health_check_success(self, db_session) -> None:
441
+ """Test successful database health check with mocked database."""
442
+ from app.services.system.health import check_database_health
443
+
444
+ # Mock everything to simulate successful database connection with
445
+ # enhanced metadata
446
+ with patch('app.services.system.health.settings') as mock_settings, \
447
+ patch('pathlib.Path.exists', return_value=True), \
448
+ patch('pathlib.Path.stat') as mock_stat, \
449
+ patch('app.core.db.db_session') as mock_db_session, \
450
+ patch('app.core.db.engine') as mock_engine, \
451
+ patch('app.services.system.health.sqlite3') as mock_sqlite3:
452
+
453
+ mock_settings.DATABASE_URL = "sqlite:///./data/test.db"
454
+ mock_settings.DATABASE_ENGINE_ECHO = False
455
+
456
+ # Mock SQLite version
457
+ mock_sqlite3.sqlite_version = "3.43.2"
458
+
459
+ # Mock file size
460
+ mock_stat_result = MagicMock()
461
+ mock_stat_result.st_size = 8192
462
+ mock_stat.return_value = mock_stat_result
463
+
464
+ # Mock engine pool
465
+ mock_engine.pool.size.return_value = 5
466
+
467
+ # Mock successful db_session with PRAGMA queries
468
+ mock_session = MagicMock()
469
+
470
+ # Mock PRAGMA query results
471
+ def mock_execute(query):
472
+ query_str = str(query).lower()
473
+ if "pragma foreign_keys" in query_str:
474
+ result = MagicMock()
475
+ result.fetchone.return_value = [1] # foreign_keys = ON
476
+ return result
477
+ elif "pragma journal_mode" in query_str:
478
+ result = MagicMock()
479
+ result.fetchone.return_value = ["delete"] # journal_mode = delete
480
+ return result
481
+ elif "pragma cache_size" in query_str:
482
+ result = MagicMock()
483
+ result.fetchone.return_value = [2000] # cache_size = 2000
484
+ return result
485
+ else:
486
+ # For "SELECT 1" query
487
+ return None
488
+
489
+ mock_session.execute.side_effect = mock_execute
490
+ mock_db_session.return_value.__enter__ = MagicMock(
491
+ return_value=mock_session
492
+ )
493
+ mock_db_session.return_value.__exit__ = MagicMock(return_value=None)
494
+
495
+ result = await check_database_health()
496
+
497
+ # Test basic health status
498
+ assert result.name == "database"
499
+ assert result.status == ComponentStatusType.HEALTHY
500
+ assert result.message == "Database connection successful"
501
+
502
+ # Test existing metadata fields
503
+ assert result.metadata["implementation"] == "sqlite"
504
+ assert result.metadata["database_exists"] is True
505
+ assert result.metadata["engine_echo"] is False
506
+ assert result.metadata["url"] == "sqlite:///./data/test.db"
507
+
508
+ # Test enhanced metadata fields
509
+ assert result.metadata["version"] == "3.43.2"
510
+ assert result.metadata["file_size_bytes"] == 8192
511
+ assert result.metadata["file_size_human"] == "8.0 KB"
512
+ assert result.metadata["connection_pool_size"] == 5
513
+
514
+ # Test PRAGMA settings
515
+ assert "pragma_settings" in result.metadata
516
+ pragma_settings = result.metadata["pragma_settings"]
517
+ assert pragma_settings["foreign_keys"] is True
518
+ assert pragma_settings["journal_mode"] == "delete"
519
+ assert pragma_settings["cache_size"] == 2000
520
+ assert result.metadata["wal_enabled"] is False
521
+
522
+ @pytest.mark.asyncio
523
+ async def test_database_health_check_import_error(self) -> None:
524
+ """Test database health check when db module not available."""
525
+ from app.services.system.health import check_database_health
526
+
527
+ # Mock ImportError when trying to import db_session from app.core.db
528
+ import builtins
529
+ real_import = builtins.__import__
530
+
531
+ def mock_import(name, *args, **kwargs):
532
+ if name == 'app.core.db':
533
+ raise ImportError("No db module")
534
+ return real_import(name, *args, **kwargs)
535
+
536
+ with patch('builtins.__import__', side_effect=mock_import):
537
+ result = await check_database_health()
538
+
539
+ assert result.name == "database"
540
+ assert result.status == ComponentStatusType.UNHEALTHY
541
+ assert result.message == "Database module not available"
542
+ assert result.metadata["error"] == (
543
+ "Database module not imported or configured"
544
+ )
545
+
546
+ @pytest.mark.asyncio
547
+ async def test_database_health_check_missing_file(self) -> None:
548
+ """Test database health check when database file doesn't exist."""
549
+ from app.services.system.health import check_database_health
550
+
551
+ # Patch settings.DATABASE_URL to point to non-existent file
552
+ with patch('app.services.system.health.settings') as mock_settings:
553
+ mock_settings.DATABASE_URL = "sqlite:///./nonexistent/test.db"
554
+
555
+ result = await check_database_health()
556
+
557
+ assert result.name == "database"
558
+ assert result.status == ComponentStatusType.WARNING
559
+ assert "Database not initialized" in result.message
560
+ assert result.metadata["database_exists"] is False
561
+ assert "nonexistent/test.db" in result.metadata["expected_path"]
562
+
563
+ @pytest.mark.asyncio
564
+ async def test_database_health_check_connection_failure(self) -> None:
565
+ """Test database health check when connection fails due to permissions."""
566
+ from app.services.system.health import check_database_health
567
+
568
+ # Mock settings and Path.exists to simulate file exists but connection fails
569
+ with patch('app.services.system.health.settings') as mock_settings, \
570
+ patch('pathlib.Path.exists', return_value=True), \
571
+ patch('app.core.db.db_session') as mock_db_session:
572
+
573
+ # File exists but connection fails
574
+ mock_settings.DATABASE_URL = "sqlite:///./data/test.db"
575
+
576
+ # Mock db_session to simulate connection error
577
+ mock_session = MagicMock()
578
+ mock_session.execute.side_effect = Exception("unable to open database file")
579
+ mock_db_session.return_value.__enter__ = MagicMock(
580
+ return_value=mock_session
581
+ )
582
+ mock_db_session.return_value.__exit__ = MagicMock(return_value=None)
583
+
584
+ result = await check_database_health()
585
+
586
+ assert result.name == "database"
587
+ assert result.status == ComponentStatusType.WARNING
588
+ assert result.message == "Database file not accessible"
589
+ assert "unable to open database file" in result.metadata["error"]
590
+
591
+ def test_database_status_metadata_structure(self) -> None:
592
+ """Test that database health check includes proper metadata."""
593
+ from app.services.system.models import ComponentStatus, ComponentStatusType
594
+
595
+ # Test successful database component metadata with enhanced fields
596
+ database_metadata = {
597
+ "implementation": "sqlite",
598
+ "url": "sqlite:///:memory:",
599
+ "database_exists": True,
600
+ "engine_echo": False,
601
+ "version": "3.43.2",
602
+ "file_size_bytes": 8192,
603
+ "file_size_human": "8.0 KB",
604
+ "connection_pool_size": 1,
605
+ "pragma_settings": {
606
+ "foreign_keys": True,
607
+ "journal_mode": "delete",
608
+ "cache_size": 2000
609
+ },
610
+ "wal_enabled": False,
611
+ }
612
+
613
+ database_status = ComponentStatus(
614
+ name="database",
615
+ status=ComponentStatusType.HEALTHY,
616
+ message="Database connection successful",
617
+ metadata=database_metadata,
618
+ )
619
+
620
+ # Test existing fields
621
+ assert database_status.metadata["implementation"] == "sqlite"
622
+ assert "sqlite://" in database_status.metadata["url"]
623
+ assert database_status.metadata["database_exists"] is True
624
+ assert database_status.metadata["engine_echo"] is False
625
+
626
+ # Test enhanced metadata fields
627
+ assert database_status.metadata["version"] == "3.43.2"
628
+ assert database_status.metadata["file_size_bytes"] == 8192
629
+ assert database_status.metadata["file_size_human"] == "8.0 KB"
630
+ assert database_status.metadata["connection_pool_size"] == 1
631
+ assert isinstance(database_status.metadata["pragma_settings"], dict)
632
+ assert database_status.metadata["wal_enabled"] is False
633
+ {% endif %}