kairo-code 0.1.0__py3-none-any.whl → 0.2.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.
- kairo/backend/api/agents.py +337 -16
- kairo/backend/app.py +84 -4
- kairo/backend/config.py +4 -2
- kairo/backend/models/agent.py +216 -2
- kairo/backend/models/api_key.py +4 -1
- kairo/backend/models/task.py +31 -0
- kairo/backend/models/user_provider_key.py +26 -0
- kairo/backend/schemas/agent.py +249 -2
- kairo/backend/schemas/api_key.py +3 -0
- kairo/backend/services/agent/__init__.py +52 -0
- kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
- kairo/backend/services/agent/agent_alerts_service.py +201 -0
- kairo/backend/services/agent/agent_commands_service.py +142 -0
- kairo/backend/services/agent/agent_crud_service.py +150 -0
- kairo/backend/services/agent/agent_events_service.py +103 -0
- kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
- kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
- kairo/backend/services/agent/agent_metrics_service.py +259 -0
- kairo/backend/services/agent/agent_service.py +315 -0
- kairo/backend/services/agent/agent_setup_service.py +180 -0
- kairo/backend/services/agent/constants.py +28 -0
- kairo/backend/services/agent_service.py +18 -102
- kairo/backend/services/api_key_service.py +23 -3
- kairo/backend/services/byok_service.py +204 -0
- kairo/backend/services/chat_service.py +398 -63
- kairo/backend/services/deep_search_service.py +159 -0
- kairo/backend/services/email_service.py +418 -19
- kairo/backend/services/few_shot_service.py +223 -0
- kairo/backend/services/post_processor.py +261 -0
- kairo/backend/services/rag_service.py +150 -0
- kairo/backend/services/task_service.py +119 -0
- kairo/backend/tests/__init__.py +1 -0
- kairo/backend/tests/e2e/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/conftest.py +389 -0
- kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
- kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
- kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
- kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
- kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
- kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
- kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
- kairo/migrations/versions/010_agent_dashboard.py +246 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
- kairo_migrations/env.py +92 -0
- kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end tests for Agent Alerts functionality.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- Create alert config
|
|
6
|
+
- Update alert config
|
|
7
|
+
- Delete alert config
|
|
8
|
+
- Get alert history
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timedelta, UTC
|
|
14
|
+
from httpx import AsyncClient
|
|
15
|
+
from sqlalchemy import select
|
|
16
|
+
|
|
17
|
+
from backend.models.agent import Agent, AgentAlertConfig, AgentAlertHistory
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestCreateAlertConfig:
|
|
21
|
+
"""Tests for POST /api/agents/{agent_id}/alerts"""
|
|
22
|
+
|
|
23
|
+
@pytest.mark.asyncio
|
|
24
|
+
async def test_create_alert_config_success(
|
|
25
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
26
|
+
):
|
|
27
|
+
"""Should create an alert configuration."""
|
|
28
|
+
payload = {
|
|
29
|
+
"name": "High Error Rate Alert",
|
|
30
|
+
"alert_type": "error_rate",
|
|
31
|
+
"metric": "error_rate",
|
|
32
|
+
"condition": "gt",
|
|
33
|
+
"threshold": 10.0,
|
|
34
|
+
"window_seconds": 300,
|
|
35
|
+
"cooldown_seconds": 3600,
|
|
36
|
+
"severity": "warning",
|
|
37
|
+
"channels": ["email"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
response = await client.post(
|
|
41
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assert response.status_code == 200
|
|
45
|
+
data = response.json()
|
|
46
|
+
assert data["name"] == "High Error Rate Alert"
|
|
47
|
+
assert data["alert_type"] == "error_rate"
|
|
48
|
+
assert data["metric"] == "error_rate"
|
|
49
|
+
assert data["condition"] == "gt"
|
|
50
|
+
assert data["threshold"] == 10.0
|
|
51
|
+
assert data["window_seconds"] == 300
|
|
52
|
+
assert data["cooldown_seconds"] == 3600
|
|
53
|
+
assert data["severity"] == "warning"
|
|
54
|
+
assert data["channels"] == ["email"]
|
|
55
|
+
assert data["is_enabled"] is True
|
|
56
|
+
assert "id" in data
|
|
57
|
+
assert "created_at" in data
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_create_offline_alert(
|
|
61
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
62
|
+
):
|
|
63
|
+
"""Should create an offline detection alert."""
|
|
64
|
+
payload = {
|
|
65
|
+
"name": "Agent Offline Alert",
|
|
66
|
+
"alert_type": "offline",
|
|
67
|
+
"metric": "heartbeat",
|
|
68
|
+
"condition": "gt",
|
|
69
|
+
"threshold": 120, # seconds without heartbeat
|
|
70
|
+
"window_seconds": 60,
|
|
71
|
+
"severity": "critical",
|
|
72
|
+
"channels": ["email", "slack"],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
response = await client.post(
|
|
76
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assert response.status_code == 200
|
|
80
|
+
data = response.json()
|
|
81
|
+
assert data["alert_type"] == "offline"
|
|
82
|
+
assert data["severity"] == "critical"
|
|
83
|
+
assert "slack" in data["channels"]
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_create_latency_alert(
|
|
87
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
88
|
+
):
|
|
89
|
+
"""Should create a latency threshold alert."""
|
|
90
|
+
payload = {
|
|
91
|
+
"name": "High Latency Alert",
|
|
92
|
+
"alert_type": "latency",
|
|
93
|
+
"metric": "avg_latency_ms",
|
|
94
|
+
"condition": "gte",
|
|
95
|
+
"threshold": 5000, # 5 seconds
|
|
96
|
+
"window_seconds": 600, # 10 minutes
|
|
97
|
+
"severity": "warning",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
response = await client.post(
|
|
101
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
assert response.status_code == 200
|
|
105
|
+
data = response.json()
|
|
106
|
+
assert data["alert_type"] == "latency"
|
|
107
|
+
assert data["threshold"] == 5000
|
|
108
|
+
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_create_token_budget_alert(
|
|
111
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
112
|
+
):
|
|
113
|
+
"""Should create a token budget alert."""
|
|
114
|
+
payload = {
|
|
115
|
+
"name": "Daily Token Limit Alert",
|
|
116
|
+
"alert_type": "token_budget",
|
|
117
|
+
"metric": "daily_tokens",
|
|
118
|
+
"condition": "gt",
|
|
119
|
+
"threshold": 100000,
|
|
120
|
+
"window_seconds": 86400, # 24 hours
|
|
121
|
+
"severity": "info",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
response = await client.post(
|
|
125
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert response.status_code == 200
|
|
129
|
+
data = response.json()
|
|
130
|
+
assert data["alert_type"] == "token_budget"
|
|
131
|
+
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_create_alert_validation_errors(
|
|
134
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
135
|
+
):
|
|
136
|
+
"""Should reject invalid alert configuration."""
|
|
137
|
+
# Invalid alert_type
|
|
138
|
+
payload = {
|
|
139
|
+
"name": "Test Alert",
|
|
140
|
+
"alert_type": "invalid_type",
|
|
141
|
+
"metric": "error_rate",
|
|
142
|
+
"condition": "gt",
|
|
143
|
+
"threshold": 10.0,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
response = await client.post(
|
|
147
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
assert response.status_code == 422
|
|
151
|
+
|
|
152
|
+
@pytest.mark.asyncio
|
|
153
|
+
async def test_create_alert_invalid_condition(
|
|
154
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
155
|
+
):
|
|
156
|
+
"""Should reject invalid condition operator."""
|
|
157
|
+
payload = {
|
|
158
|
+
"name": "Test Alert",
|
|
159
|
+
"alert_type": "error_rate",
|
|
160
|
+
"metric": "error_rate",
|
|
161
|
+
"condition": "invalid",
|
|
162
|
+
"threshold": 10.0,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
response = await client.post(
|
|
166
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
assert response.status_code == 422
|
|
170
|
+
|
|
171
|
+
@pytest.mark.asyncio
|
|
172
|
+
async def test_create_alert_agent_not_found(
|
|
173
|
+
self, client: AsyncClient, auth_headers: dict
|
|
174
|
+
):
|
|
175
|
+
"""Should return 404 for non-existent agent."""
|
|
176
|
+
fake_id = str(uuid.uuid4())
|
|
177
|
+
payload = {
|
|
178
|
+
"name": "Test Alert",
|
|
179
|
+
"alert_type": "error_rate",
|
|
180
|
+
"metric": "error_rate",
|
|
181
|
+
"condition": "gt",
|
|
182
|
+
"threshold": 10.0,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
response = await client.post(
|
|
186
|
+
f"/api/agents/{fake_id}/alerts", json=payload, headers=auth_headers
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
assert response.status_code == 404
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TestGetAlertConfigs:
|
|
193
|
+
"""Tests for GET /api/agents/{agent_id}/alerts"""
|
|
194
|
+
|
|
195
|
+
@pytest.mark.asyncio
|
|
196
|
+
async def test_get_alerts_empty(
|
|
197
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
198
|
+
):
|
|
199
|
+
"""Should return empty list when no alerts configured."""
|
|
200
|
+
response = await client.get(
|
|
201
|
+
f"/api/agents/{test_agent.id}/alerts", headers=auth_headers
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
assert response.status_code == 200
|
|
205
|
+
data = response.json()
|
|
206
|
+
assert data == []
|
|
207
|
+
|
|
208
|
+
@pytest.mark.asyncio
|
|
209
|
+
async def test_get_alerts_with_configs(
|
|
210
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
211
|
+
):
|
|
212
|
+
"""Should return list of alert configurations."""
|
|
213
|
+
await agent_factory.create_alert_config(
|
|
214
|
+
test_agent.id, name="Error Rate Alert", alert_type="error_rate"
|
|
215
|
+
)
|
|
216
|
+
await agent_factory.create_alert_config(
|
|
217
|
+
test_agent.id, name="Latency Alert", alert_type="latency"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
response = await client.get(
|
|
221
|
+
f"/api/agents/{test_agent.id}/alerts", headers=auth_headers
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
assert response.status_code == 200
|
|
225
|
+
data = response.json()
|
|
226
|
+
assert len(data) == 2
|
|
227
|
+
|
|
228
|
+
@pytest.mark.asyncio
|
|
229
|
+
async def test_get_alerts_agent_not_found(
|
|
230
|
+
self, client: AsyncClient, auth_headers: dict
|
|
231
|
+
):
|
|
232
|
+
"""Should return 404 for non-existent agent."""
|
|
233
|
+
fake_id = str(uuid.uuid4())
|
|
234
|
+
|
|
235
|
+
response = await client.get(
|
|
236
|
+
f"/api/agents/{fake_id}/alerts", headers=auth_headers
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
assert response.status_code == 404
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TestUpdateAlertConfig:
|
|
243
|
+
"""Tests for PATCH /api/agents/{agent_id}/alerts/{config_id}"""
|
|
244
|
+
|
|
245
|
+
@pytest.mark.asyncio
|
|
246
|
+
async def test_update_alert_threshold(
|
|
247
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
248
|
+
):
|
|
249
|
+
"""Should update alert threshold."""
|
|
250
|
+
config = await agent_factory.create_alert_config(
|
|
251
|
+
test_agent.id, threshold=10.0
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
payload = {"threshold": 20.0}
|
|
255
|
+
|
|
256
|
+
response = await client.patch(
|
|
257
|
+
f"/api/agents/{test_agent.id}/alerts/{config.id}",
|
|
258
|
+
json=payload,
|
|
259
|
+
headers=auth_headers,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
assert response.status_code == 200
|
|
263
|
+
data = response.json()
|
|
264
|
+
assert data["threshold"] == 20.0
|
|
265
|
+
|
|
266
|
+
@pytest.mark.asyncio
|
|
267
|
+
async def test_update_alert_name(
|
|
268
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
269
|
+
):
|
|
270
|
+
"""Should update alert name."""
|
|
271
|
+
config = await agent_factory.create_alert_config(
|
|
272
|
+
test_agent.id, name="Original Name"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
payload = {"name": "Updated Name"}
|
|
276
|
+
|
|
277
|
+
response = await client.patch(
|
|
278
|
+
f"/api/agents/{test_agent.id}/alerts/{config.id}",
|
|
279
|
+
json=payload,
|
|
280
|
+
headers=auth_headers,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
assert response.status_code == 200
|
|
284
|
+
assert response.json()["name"] == "Updated Name"
|
|
285
|
+
|
|
286
|
+
@pytest.mark.asyncio
|
|
287
|
+
async def test_update_alert_disable(
|
|
288
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
289
|
+
):
|
|
290
|
+
"""Should disable an alert."""
|
|
291
|
+
config = await agent_factory.create_alert_config(
|
|
292
|
+
test_agent.id, is_enabled=True
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
payload = {"is_enabled": False}
|
|
296
|
+
|
|
297
|
+
response = await client.patch(
|
|
298
|
+
f"/api/agents/{test_agent.id}/alerts/{config.id}",
|
|
299
|
+
json=payload,
|
|
300
|
+
headers=auth_headers,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
assert response.status_code == 200
|
|
304
|
+
assert response.json()["is_enabled"] is False
|
|
305
|
+
|
|
306
|
+
@pytest.mark.asyncio
|
|
307
|
+
async def test_update_alert_channels(
|
|
308
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
309
|
+
):
|
|
310
|
+
"""Should update alert notification channels."""
|
|
311
|
+
config = await agent_factory.create_alert_config(
|
|
312
|
+
test_agent.id, channels=["email"]
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
payload = {"channels": ["email", "slack", "webhook"]}
|
|
316
|
+
|
|
317
|
+
response = await client.patch(
|
|
318
|
+
f"/api/agents/{test_agent.id}/alerts/{config.id}",
|
|
319
|
+
json=payload,
|
|
320
|
+
headers=auth_headers,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
assert response.status_code == 200
|
|
324
|
+
assert set(response.json()["channels"]) == {"email", "slack", "webhook"}
|
|
325
|
+
|
|
326
|
+
@pytest.mark.asyncio
|
|
327
|
+
async def test_update_alert_severity(
|
|
328
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
329
|
+
):
|
|
330
|
+
"""Should update alert severity."""
|
|
331
|
+
config = await agent_factory.create_alert_config(
|
|
332
|
+
test_agent.id, severity="warning"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
payload = {"severity": "critical"}
|
|
336
|
+
|
|
337
|
+
response = await client.patch(
|
|
338
|
+
f"/api/agents/{test_agent.id}/alerts/{config.id}",
|
|
339
|
+
json=payload,
|
|
340
|
+
headers=auth_headers,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
assert response.status_code == 200
|
|
344
|
+
assert response.json()["severity"] == "critical"
|
|
345
|
+
|
|
346
|
+
@pytest.mark.asyncio
|
|
347
|
+
async def test_update_alert_multiple_fields(
|
|
348
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
349
|
+
):
|
|
350
|
+
"""Should update multiple fields at once."""
|
|
351
|
+
config = await agent_factory.create_alert_config(test_agent.id)
|
|
352
|
+
|
|
353
|
+
payload = {
|
|
354
|
+
"name": "Updated Alert",
|
|
355
|
+
"threshold": 50.0,
|
|
356
|
+
"window_seconds": 600,
|
|
357
|
+
"severity": "info",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
response = await client.patch(
|
|
361
|
+
f"/api/agents/{test_agent.id}/alerts/{config.id}",
|
|
362
|
+
json=payload,
|
|
363
|
+
headers=auth_headers,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
assert response.status_code == 200
|
|
367
|
+
data = response.json()
|
|
368
|
+
assert data["name"] == "Updated Alert"
|
|
369
|
+
assert data["threshold"] == 50.0
|
|
370
|
+
assert data["window_seconds"] == 600
|
|
371
|
+
assert data["severity"] == "info"
|
|
372
|
+
|
|
373
|
+
@pytest.mark.asyncio
|
|
374
|
+
async def test_update_alert_not_found(
|
|
375
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
376
|
+
):
|
|
377
|
+
"""Should return 404 for non-existent alert config."""
|
|
378
|
+
fake_id = str(uuid.uuid4())
|
|
379
|
+
payload = {"threshold": 20.0}
|
|
380
|
+
|
|
381
|
+
response = await client.patch(
|
|
382
|
+
f"/api/agents/{test_agent.id}/alerts/{fake_id}",
|
|
383
|
+
json=payload,
|
|
384
|
+
headers=auth_headers,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
assert response.status_code == 404
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class TestDeleteAlertConfig:
|
|
391
|
+
"""Tests for DELETE /api/agents/{agent_id}/alerts/{config_id}"""
|
|
392
|
+
|
|
393
|
+
@pytest.mark.asyncio
|
|
394
|
+
async def test_delete_alert_success(
|
|
395
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory, db_session
|
|
396
|
+
):
|
|
397
|
+
"""Should delete an alert configuration."""
|
|
398
|
+
config = await agent_factory.create_alert_config(test_agent.id)
|
|
399
|
+
config_id = config.id
|
|
400
|
+
|
|
401
|
+
response = await client.delete(
|
|
402
|
+
f"/api/agents/{test_agent.id}/alerts/{config_id}",
|
|
403
|
+
headers=auth_headers,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
assert response.status_code == 200
|
|
407
|
+
assert response.json()["deleted"] is True
|
|
408
|
+
|
|
409
|
+
# Verify deleted
|
|
410
|
+
stmt = select(AgentAlertConfig).where(AgentAlertConfig.id == config_id)
|
|
411
|
+
result = await db_session.execute(stmt)
|
|
412
|
+
assert result.scalar_one_or_none() is None
|
|
413
|
+
|
|
414
|
+
@pytest.mark.asyncio
|
|
415
|
+
async def test_delete_alert_not_found(
|
|
416
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
417
|
+
):
|
|
418
|
+
"""Should return 404 for non-existent alert config."""
|
|
419
|
+
fake_id = str(uuid.uuid4())
|
|
420
|
+
|
|
421
|
+
response = await client.delete(
|
|
422
|
+
f"/api/agents/{test_agent.id}/alerts/{fake_id}",
|
|
423
|
+
headers=auth_headers,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
assert response.status_code == 404
|
|
427
|
+
|
|
428
|
+
@pytest.mark.asyncio
|
|
429
|
+
async def test_delete_alert_wrong_agent(
|
|
430
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory, db_session, test_user
|
|
431
|
+
):
|
|
432
|
+
"""Should return 404 when config belongs to different agent."""
|
|
433
|
+
# Create another agent
|
|
434
|
+
other_agent = Agent(
|
|
435
|
+
id=str(uuid.uuid4()),
|
|
436
|
+
user_id=test_user.id,
|
|
437
|
+
name="Other Agent",
|
|
438
|
+
)
|
|
439
|
+
db_session.add(other_agent)
|
|
440
|
+
await db_session.commit()
|
|
441
|
+
|
|
442
|
+
# Create config for other agent
|
|
443
|
+
other_config = AgentAlertConfig(
|
|
444
|
+
id=str(uuid.uuid4()),
|
|
445
|
+
agent_id=other_agent.id,
|
|
446
|
+
user_id=test_user.id,
|
|
447
|
+
name="Other Alert",
|
|
448
|
+
alert_type="error_rate",
|
|
449
|
+
metric="error_rate",
|
|
450
|
+
condition="gt",
|
|
451
|
+
threshold=10.0,
|
|
452
|
+
)
|
|
453
|
+
db_session.add(other_config)
|
|
454
|
+
await db_session.commit()
|
|
455
|
+
|
|
456
|
+
# Try to delete using test_agent's endpoint
|
|
457
|
+
response = await client.delete(
|
|
458
|
+
f"/api/agents/{test_agent.id}/alerts/{other_config.id}",
|
|
459
|
+
headers=auth_headers,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
assert response.status_code == 404
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class TestAlertHistory:
|
|
466
|
+
"""Tests for GET /api/agents/{agent_id}/alerts/history"""
|
|
467
|
+
|
|
468
|
+
@pytest.mark.asyncio
|
|
469
|
+
async def test_get_alert_history_empty(
|
|
470
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
471
|
+
):
|
|
472
|
+
"""Should return empty list when no alerts have triggered."""
|
|
473
|
+
response = await client.get(
|
|
474
|
+
f"/api/agents/{test_agent.id}/alerts/history", headers=auth_headers
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
assert response.status_code == 200
|
|
478
|
+
data = response.json()
|
|
479
|
+
assert data == []
|
|
480
|
+
|
|
481
|
+
@pytest.mark.asyncio
|
|
482
|
+
async def test_get_alert_history_with_entries(
|
|
483
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
484
|
+
):
|
|
485
|
+
"""Should return alert history entries."""
|
|
486
|
+
config = await agent_factory.create_alert_config(test_agent.id)
|
|
487
|
+
|
|
488
|
+
# Create history entries
|
|
489
|
+
await agent_factory.create_alert_history(
|
|
490
|
+
config.id,
|
|
491
|
+
test_agent.id,
|
|
492
|
+
alert_type="error_rate",
|
|
493
|
+
severity="warning",
|
|
494
|
+
message="Error rate exceeded 10%",
|
|
495
|
+
)
|
|
496
|
+
await agent_factory.create_alert_history(
|
|
497
|
+
config.id,
|
|
498
|
+
test_agent.id,
|
|
499
|
+
alert_type="error_rate",
|
|
500
|
+
severity="warning",
|
|
501
|
+
status="resolved",
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
response = await client.get(
|
|
505
|
+
f"/api/agents/{test_agent.id}/alerts/history", headers=auth_headers
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
assert response.status_code == 200
|
|
509
|
+
data = response.json()
|
|
510
|
+
assert len(data) == 2
|
|
511
|
+
|
|
512
|
+
@pytest.mark.asyncio
|
|
513
|
+
async def test_alert_history_fields(
|
|
514
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
515
|
+
):
|
|
516
|
+
"""Should return expected fields in history entry."""
|
|
517
|
+
config = await agent_factory.create_alert_config(test_agent.id)
|
|
518
|
+
|
|
519
|
+
await agent_factory.create_alert_history(
|
|
520
|
+
config.id,
|
|
521
|
+
test_agent.id,
|
|
522
|
+
alert_type="latency",
|
|
523
|
+
severity="critical",
|
|
524
|
+
status="triggered",
|
|
525
|
+
message="Latency exceeded 5000ms",
|
|
526
|
+
trigger_value=6500.0,
|
|
527
|
+
threshold_value=5000.0,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
response = await client.get(
|
|
531
|
+
f"/api/agents/{test_agent.id}/alerts/history", headers=auth_headers
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
data = response.json()
|
|
535
|
+
entry = data[0]
|
|
536
|
+
|
|
537
|
+
assert "id" in entry
|
|
538
|
+
assert entry["alert_type"] == "latency"
|
|
539
|
+
assert entry["severity"] == "critical"
|
|
540
|
+
assert entry["status"] == "triggered"
|
|
541
|
+
assert entry["message"] == "Latency exceeded 5000ms"
|
|
542
|
+
assert entry["trigger_value"] == 6500.0
|
|
543
|
+
assert entry["threshold_value"] == 5000.0
|
|
544
|
+
assert "created_at" in entry
|
|
545
|
+
|
|
546
|
+
@pytest.mark.asyncio
|
|
547
|
+
async def test_alert_history_ordered_desc(
|
|
548
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory, db_session
|
|
549
|
+
):
|
|
550
|
+
"""Should return history in descending time order."""
|
|
551
|
+
config = await agent_factory.create_alert_config(test_agent.id)
|
|
552
|
+
now = datetime.now(UTC)
|
|
553
|
+
|
|
554
|
+
# Create entries with different times
|
|
555
|
+
for i in range(3):
|
|
556
|
+
history = AgentAlertHistory(
|
|
557
|
+
id=str(uuid.uuid4()),
|
|
558
|
+
config_id=config.id,
|
|
559
|
+
agent_id=test_agent.id,
|
|
560
|
+
alert_type="error_rate",
|
|
561
|
+
severity="warning",
|
|
562
|
+
status="triggered",
|
|
563
|
+
)
|
|
564
|
+
db_session.add(history)
|
|
565
|
+
await db_session.commit()
|
|
566
|
+
history.created_at = now - timedelta(hours=i)
|
|
567
|
+
await db_session.commit()
|
|
568
|
+
|
|
569
|
+
response = await client.get(
|
|
570
|
+
f"/api/agents/{test_agent.id}/alerts/history", headers=auth_headers
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
data = response.json()
|
|
574
|
+
timestamps = [entry["created_at"] for entry in data]
|
|
575
|
+
|
|
576
|
+
# Should be in descending order (newest first)
|
|
577
|
+
assert timestamps == sorted(timestamps, reverse=True)
|
|
578
|
+
|
|
579
|
+
@pytest.mark.asyncio
|
|
580
|
+
async def test_alert_history_limit(
|
|
581
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
582
|
+
):
|
|
583
|
+
"""Should respect limit parameter."""
|
|
584
|
+
config = await agent_factory.create_alert_config(test_agent.id)
|
|
585
|
+
|
|
586
|
+
# Create 20 history entries
|
|
587
|
+
for _ in range(20):
|
|
588
|
+
await agent_factory.create_alert_history(
|
|
589
|
+
config.id, test_agent.id
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
response = await client.get(
|
|
593
|
+
f"/api/agents/{test_agent.id}/alerts/history?limit=5",
|
|
594
|
+
headers=auth_headers,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
assert response.status_code == 200
|
|
598
|
+
assert len(response.json()) == 5
|
|
599
|
+
|
|
600
|
+
@pytest.mark.asyncio
|
|
601
|
+
async def test_alert_history_agent_not_found(
|
|
602
|
+
self, client: AsyncClient, auth_headers: dict
|
|
603
|
+
):
|
|
604
|
+
"""Should return 404 for non-existent agent."""
|
|
605
|
+
fake_id = str(uuid.uuid4())
|
|
606
|
+
|
|
607
|
+
response = await client.get(
|
|
608
|
+
f"/api/agents/{fake_id}/alerts/history", headers=auth_headers
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
assert response.status_code == 404
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
class TestAlertConditions:
|
|
615
|
+
"""Tests for different alert condition operators."""
|
|
616
|
+
|
|
617
|
+
@pytest.mark.asyncio
|
|
618
|
+
async def test_create_alert_condition_gt(
|
|
619
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
620
|
+
):
|
|
621
|
+
"""Should accept 'gt' (greater than) condition."""
|
|
622
|
+
payload = {
|
|
623
|
+
"name": "Test",
|
|
624
|
+
"alert_type": "error_rate",
|
|
625
|
+
"metric": "error_rate",
|
|
626
|
+
"condition": "gt",
|
|
627
|
+
"threshold": 10.0,
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
response = await client.post(
|
|
631
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
assert response.status_code == 200
|
|
635
|
+
assert response.json()["condition"] == "gt"
|
|
636
|
+
|
|
637
|
+
@pytest.mark.asyncio
|
|
638
|
+
async def test_create_alert_condition_lt(
|
|
639
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
640
|
+
):
|
|
641
|
+
"""Should accept 'lt' (less than) condition."""
|
|
642
|
+
payload = {
|
|
643
|
+
"name": "Test",
|
|
644
|
+
"alert_type": "error_rate",
|
|
645
|
+
"metric": "uptime_percent",
|
|
646
|
+
"condition": "lt",
|
|
647
|
+
"threshold": 99.0,
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
response = await client.post(
|
|
651
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
assert response.status_code == 200
|
|
655
|
+
assert response.json()["condition"] == "lt"
|
|
656
|
+
|
|
657
|
+
@pytest.mark.asyncio
|
|
658
|
+
async def test_create_alert_condition_gte(
|
|
659
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
660
|
+
):
|
|
661
|
+
"""Should accept 'gte' (greater than or equal) condition."""
|
|
662
|
+
payload = {
|
|
663
|
+
"name": "Test",
|
|
664
|
+
"alert_type": "latency",
|
|
665
|
+
"metric": "avg_latency_ms",
|
|
666
|
+
"condition": "gte",
|
|
667
|
+
"threshold": 1000.0,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
response = await client.post(
|
|
671
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
assert response.status_code == 200
|
|
675
|
+
assert response.json()["condition"] == "gte"
|
|
676
|
+
|
|
677
|
+
@pytest.mark.asyncio
|
|
678
|
+
async def test_create_alert_condition_lte(
|
|
679
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
680
|
+
):
|
|
681
|
+
"""Should accept 'lte' (less than or equal) condition."""
|
|
682
|
+
payload = {
|
|
683
|
+
"name": "Test",
|
|
684
|
+
"alert_type": "error_rate",
|
|
685
|
+
"metric": "requests_per_minute",
|
|
686
|
+
"condition": "lte",
|
|
687
|
+
"threshold": 5.0,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
response = await client.post(
|
|
691
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
assert response.status_code == 200
|
|
695
|
+
assert response.json()["condition"] == "lte"
|
|
696
|
+
|
|
697
|
+
@pytest.mark.asyncio
|
|
698
|
+
async def test_create_alert_condition_eq(
|
|
699
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
700
|
+
):
|
|
701
|
+
"""Should accept 'eq' (equal) condition."""
|
|
702
|
+
payload = {
|
|
703
|
+
"name": "Test",
|
|
704
|
+
"alert_type": "offline",
|
|
705
|
+
"metric": "state",
|
|
706
|
+
"condition": "eq",
|
|
707
|
+
"threshold": 0.0, # offline = 0
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
response = await client.post(
|
|
711
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
assert response.status_code == 200
|
|
715
|
+
assert response.json()["condition"] == "eq"
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class TestAlertSeverityLevels:
|
|
719
|
+
"""Tests for alert severity levels."""
|
|
720
|
+
|
|
721
|
+
@pytest.mark.asyncio
|
|
722
|
+
async def test_create_alert_severity_info(
|
|
723
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
724
|
+
):
|
|
725
|
+
"""Should accept 'info' severity."""
|
|
726
|
+
payload = {
|
|
727
|
+
"name": "Test",
|
|
728
|
+
"alert_type": "token_budget",
|
|
729
|
+
"metric": "tokens",
|
|
730
|
+
"condition": "gt",
|
|
731
|
+
"threshold": 50000,
|
|
732
|
+
"severity": "info",
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
response = await client.post(
|
|
736
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
assert response.status_code == 200
|
|
740
|
+
assert response.json()["severity"] == "info"
|
|
741
|
+
|
|
742
|
+
@pytest.mark.asyncio
|
|
743
|
+
async def test_create_alert_severity_warning(
|
|
744
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
745
|
+
):
|
|
746
|
+
"""Should accept 'warning' severity."""
|
|
747
|
+
payload = {
|
|
748
|
+
"name": "Test",
|
|
749
|
+
"alert_type": "error_rate",
|
|
750
|
+
"metric": "error_rate",
|
|
751
|
+
"condition": "gt",
|
|
752
|
+
"threshold": 10.0,
|
|
753
|
+
"severity": "warning",
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
response = await client.post(
|
|
757
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
assert response.status_code == 200
|
|
761
|
+
assert response.json()["severity"] == "warning"
|
|
762
|
+
|
|
763
|
+
@pytest.mark.asyncio
|
|
764
|
+
async def test_create_alert_severity_critical(
|
|
765
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
766
|
+
):
|
|
767
|
+
"""Should accept 'critical' severity."""
|
|
768
|
+
payload = {
|
|
769
|
+
"name": "Test",
|
|
770
|
+
"alert_type": "offline",
|
|
771
|
+
"metric": "heartbeat",
|
|
772
|
+
"condition": "gt",
|
|
773
|
+
"threshold": 300,
|
|
774
|
+
"severity": "critical",
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
response = await client.post(
|
|
778
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
assert response.status_code == 200
|
|
782
|
+
assert response.json()["severity"] == "critical"
|
|
783
|
+
|
|
784
|
+
@pytest.mark.asyncio
|
|
785
|
+
async def test_create_alert_invalid_severity(
|
|
786
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
787
|
+
):
|
|
788
|
+
"""Should reject invalid severity level."""
|
|
789
|
+
payload = {
|
|
790
|
+
"name": "Test",
|
|
791
|
+
"alert_type": "error_rate",
|
|
792
|
+
"metric": "error_rate",
|
|
793
|
+
"condition": "gt",
|
|
794
|
+
"threshold": 10.0,
|
|
795
|
+
"severity": "emergency", # invalid
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
response = await client.post(
|
|
799
|
+
f"/api/agents/{test_agent.id}/alerts", json=payload, headers=auth_headers
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
assert response.status_code == 422
|