feat: Node Self-Healing, DAGI Audit, Agent Prompts, Infra Invariants
### Backend (city-service) - Node Registry + Self-Healing API (migration 039) - Improved get_all_nodes() with robust fallback for node_registry/node_cache - Agent Prompts Runtime API for DAGI Router integration - DAGI Router Audit endpoints (phantom/stale detection) - Node Agents API (Guardian/Steward) - Node metrics extended (CPU/GPU/RAM/Disk) ### Frontend (apps/web) - Node Directory with improved error handling - Node Cabinet with metrics cards - DAGI Router Card component - Node Metrics Card component - useDAGIAudit hook ### Scripts - check-invariants.py - deploy verification - node-bootstrap.sh - node self-registration - node-guardian-loop.py - continuous self-healing - dagi_agent_audit.py - DAGI audit utility ### Migrations - 034: Agent prompts seed - 035: Agent DAGI audit - 036: Node metrics extended - 037: Node agents complete - 038: Agent prompts full coverage - 039: Node registry self-healing ### Tests - test_infra_smoke.py - test_agent_prompts_runtime.py - test_dagi_router_api.py ### Documentation - DEPLOY_CHECKLIST_2024_11_30.md - Multiple TASK_PHASE docs
This commit is contained in:
326
tests/test_agent_prompts_runtime.py
Normal file
326
tests/test_agent_prompts_runtime.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Tests for Agent System Prompts Runtime API
|
||||
|
||||
Тести для Agent System Prompts MVP v2:
|
||||
- Runtime prompts API
|
||||
- build_system_prompt function
|
||||
- Prompts status check API
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
|
||||
# Mock functions for testing without database
|
||||
def build_system_prompt_from_parts(
|
||||
prompts: Dict[str, str],
|
||||
agent_info: Dict[str, Any] = None,
|
||||
context: Dict[str, Any] = None
|
||||
) -> str:
|
||||
"""Build system prompt from parts (mock implementation for testing)"""
|
||||
parts = []
|
||||
|
||||
# Core prompt (required)
|
||||
if prompts.get("core"):
|
||||
parts.append(prompts["core"])
|
||||
elif agent_info:
|
||||
agent_name = agent_info.get("display_name") or agent_info.get("name") or "Agent"
|
||||
agent_kind = agent_info.get("kind") or "assistant"
|
||||
parts.append(
|
||||
f"You are {agent_name}, an AI {agent_kind} in DAARION.city ecosystem. "
|
||||
f"Be helpful, accurate, and follow ethical guidelines."
|
||||
)
|
||||
else:
|
||||
parts.append("You are an AI assistant. Be helpful and accurate.")
|
||||
|
||||
# Governance rules
|
||||
if prompts.get("governance"):
|
||||
parts.append("\n\n## Governance\n" + prompts["governance"])
|
||||
|
||||
# Safety guidelines
|
||||
if prompts.get("safety"):
|
||||
parts.append("\n\n## Safety Guidelines\n" + prompts["safety"])
|
||||
|
||||
# Tools instructions
|
||||
if prompts.get("tools"):
|
||||
parts.append("\n\n## Tools & Capabilities\n" + prompts["tools"])
|
||||
|
||||
# Context additions
|
||||
if context:
|
||||
context_lines = []
|
||||
|
||||
if context.get("node"):
|
||||
node = context["node"]
|
||||
context_lines.append(f"- **Node**: {node.get('name', 'Unknown')}")
|
||||
|
||||
if context.get("district"):
|
||||
district = context["district"]
|
||||
context_lines.append(f"- **District**: {district.get('name', 'Unknown')}")
|
||||
|
||||
if context.get("microdao"):
|
||||
microdao = context["microdao"]
|
||||
context_lines.append(f"- **MicroDAO**: {microdao.get('name', 'Unknown')}")
|
||||
|
||||
if context_lines:
|
||||
parts.append("\n\n## Current Context\n" + "\n".join(context_lines))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
class TestBuildSystemPrompt:
|
||||
"""Tests for build_system_prompt function"""
|
||||
|
||||
def test_core_only(self):
|
||||
"""Test with only core prompt"""
|
||||
prompts = {
|
||||
"core": "You are DAARWIZZ, the global orchestrator.",
|
||||
"safety": None,
|
||||
"governance": None,
|
||||
"tools": None
|
||||
}
|
||||
|
||||
result = build_system_prompt_from_parts(prompts)
|
||||
|
||||
assert "DAARWIZZ" in result
|
||||
assert "orchestrator" in result
|
||||
assert "## Safety" not in result
|
||||
assert "## Governance" not in result
|
||||
|
||||
def test_full_prompts(self):
|
||||
"""Test with all prompt types"""
|
||||
prompts = {
|
||||
"core": "You are DAARWIZZ, the global orchestrator of DAARION.city.",
|
||||
"safety": "Never execute irreversible actions without confirmation.",
|
||||
"governance": "Coordinate with district leads for resource allocation.",
|
||||
"tools": "Use agent_delegate to delegate tasks."
|
||||
}
|
||||
|
||||
result = build_system_prompt_from_parts(prompts)
|
||||
|
||||
assert "DAARWIZZ" in result
|
||||
assert "## Safety Guidelines" in result
|
||||
assert "irreversible" in result
|
||||
assert "## Governance" in result
|
||||
assert "district leads" in result
|
||||
assert "## Tools" in result
|
||||
assert "agent_delegate" in result
|
||||
|
||||
def test_fallback_without_core(self):
|
||||
"""Test fallback when no core prompt provided"""
|
||||
prompts = {
|
||||
"core": None,
|
||||
"safety": "Be safe",
|
||||
"governance": None,
|
||||
"tools": None
|
||||
}
|
||||
agent_info = {
|
||||
"name": "TestAgent",
|
||||
"display_name": "Test Agent",
|
||||
"kind": "coordinator"
|
||||
}
|
||||
|
||||
result = build_system_prompt_from_parts(prompts, agent_info)
|
||||
|
||||
assert "Test Agent" in result
|
||||
assert "coordinator" in result
|
||||
assert "## Safety Guidelines" in result
|
||||
assert "Be safe" in result
|
||||
|
||||
def test_with_context(self):
|
||||
"""Test prompt with runtime context"""
|
||||
prompts = {
|
||||
"core": "You are a node agent.",
|
||||
"safety": None,
|
||||
"governance": None,
|
||||
"tools": None
|
||||
}
|
||||
context = {
|
||||
"node": {"name": "NODE1", "environment": "production"},
|
||||
"district": {"name": "ENERGYUNION"},
|
||||
"microdao": {"name": "DAARION"}
|
||||
}
|
||||
|
||||
result = build_system_prompt_from_parts(prompts, context=context)
|
||||
|
||||
assert "node agent" in result
|
||||
assert "## Current Context" in result
|
||||
assert "NODE1" in result
|
||||
assert "ENERGYUNION" in result
|
||||
assert "DAARION" in result
|
||||
|
||||
def test_prompt_order(self):
|
||||
"""Test that prompts are assembled in correct order"""
|
||||
prompts = {
|
||||
"core": "CORE_MARKER",
|
||||
"safety": "SAFETY_MARKER",
|
||||
"governance": "GOVERNANCE_MARKER",
|
||||
"tools": "TOOLS_MARKER"
|
||||
}
|
||||
|
||||
result = build_system_prompt_from_parts(prompts)
|
||||
|
||||
# Check order: core → governance → safety → tools
|
||||
core_pos = result.find("CORE_MARKER")
|
||||
gov_pos = result.find("GOVERNANCE_MARKER")
|
||||
safety_pos = result.find("SAFETY_MARKER")
|
||||
tools_pos = result.find("TOOLS_MARKER")
|
||||
|
||||
assert core_pos < gov_pos < safety_pos < tools_pos
|
||||
|
||||
|
||||
class TestRuntimePromptsFormat:
|
||||
"""Tests for runtime prompts response format"""
|
||||
|
||||
def test_response_structure(self):
|
||||
"""Test expected response structure"""
|
||||
expected_keys = {"agent_id", "has_prompts", "prompts"}
|
||||
|
||||
# Mock response
|
||||
response = {
|
||||
"agent_id": "agent-daarwizz",
|
||||
"has_prompts": True,
|
||||
"prompts": {
|
||||
"core": "You are DAARWIZZ...",
|
||||
"safety": "Safety rules...",
|
||||
"governance": None,
|
||||
"tools": None
|
||||
}
|
||||
}
|
||||
|
||||
assert set(response.keys()) == expected_keys
|
||||
assert response["has_prompts"] is True
|
||||
assert "core" in response["prompts"]
|
||||
assert "safety" in response["prompts"]
|
||||
assert "governance" in response["prompts"]
|
||||
assert "tools" in response["prompts"]
|
||||
|
||||
def test_has_prompts_when_core_exists(self):
|
||||
"""Test has_prompts is True when core exists"""
|
||||
prompts = {"core": "Some core prompt", "safety": None, "governance": None, "tools": None}
|
||||
has_prompts = prompts.get("core") is not None
|
||||
assert has_prompts is True
|
||||
|
||||
def test_has_prompts_when_core_missing(self):
|
||||
"""Test has_prompts is False when core is None"""
|
||||
prompts = {"core": None, "safety": "Safety only", "governance": None, "tools": None}
|
||||
has_prompts = prompts.get("core") is not None
|
||||
assert has_prompts is False
|
||||
|
||||
|
||||
class TestPromptsStatusBatch:
|
||||
"""Tests for batch prompts status check"""
|
||||
|
||||
def test_status_response_format(self):
|
||||
"""Test batch status response format"""
|
||||
agent_ids = ["agent-daarwizz", "agent-devtools", "agent-unknown"]
|
||||
|
||||
# Mock response
|
||||
response = {
|
||||
"status": {
|
||||
"agent-daarwizz": True,
|
||||
"agent-devtools": True,
|
||||
"agent-unknown": False
|
||||
}
|
||||
}
|
||||
|
||||
assert "status" in response
|
||||
assert isinstance(response["status"], dict)
|
||||
assert all(aid in response["status"] for aid in agent_ids)
|
||||
assert all(isinstance(v, bool) for v in response["status"].values())
|
||||
|
||||
|
||||
class TestNodeAgentPrompts:
|
||||
"""Tests for Node Agent specific prompts"""
|
||||
|
||||
def test_node_guardian_prompt_content(self):
|
||||
"""Test Node Guardian has appropriate content markers"""
|
||||
guardian_core = """Ти — Node Guardian для НОДА1 (Hetzner GEX44 Production).
|
||||
Твоя місія: забезпечувати стабільну роботу продакшн-інфраструктури DAARION.city."""
|
||||
|
||||
assert "Node Guardian" in guardian_core
|
||||
assert "НОДА1" in guardian_core
|
||||
assert "Production" in guardian_core or "production" in guardian_core.lower()
|
||||
|
||||
def test_node_guardian_safety_rules(self):
|
||||
"""Test Node Guardian safety rules"""
|
||||
guardian_safety = """Ніколи не виконуй деструктивні команди без підтвердження.
|
||||
Не розкривай чутливу інформацію (паролі, API ключі).
|
||||
При невизначеності — ескалюй до людини."""
|
||||
|
||||
assert "деструктивні" in guardian_safety
|
||||
assert "підтвердження" in guardian_safety
|
||||
assert "ескалюй" in guardian_safety
|
||||
|
||||
|
||||
class TestAgentCoverage:
|
||||
"""Tests for agent prompts coverage requirements"""
|
||||
|
||||
REQUIRED_AGENTS = [
|
||||
# City / Core
|
||||
"agent-daarwizz",
|
||||
"agent-microdao-orchestrator",
|
||||
"agent-devtools",
|
||||
# District / MicroDAO
|
||||
"agent-greenfood",
|
||||
"agent-helion",
|
||||
"agent-soul",
|
||||
"agent-druid",
|
||||
"agent-nutra",
|
||||
"agent-eonarch",
|
||||
"agent-clan",
|
||||
"agent-yaromir",
|
||||
"agent-monitor",
|
||||
# Node Agents
|
||||
"monitor-node1",
|
||||
"monitor-node2",
|
||||
"node-steward-node1",
|
||||
"node-steward-node2"
|
||||
]
|
||||
|
||||
def test_required_agents_list(self):
|
||||
"""Test required agents are defined"""
|
||||
assert len(self.REQUIRED_AGENTS) == 16
|
||||
assert "agent-daarwizz" in self.REQUIRED_AGENTS
|
||||
assert "monitor-node1" in self.REQUIRED_AGENTS
|
||||
assert "monitor-node2" in self.REQUIRED_AGENTS
|
||||
|
||||
|
||||
# Integration tests (require running services)
|
||||
class TestIntegration:
|
||||
"""Integration tests - skip if services not available"""
|
||||
|
||||
@pytest.mark.skip(reason="Requires running services")
|
||||
async def test_fetch_runtime_prompts(self):
|
||||
"""Test fetching runtime prompts from API"""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"http://localhost:7001/internal/agents/agent-daarwizz/prompts/runtime"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["agent_id"] == "agent-daarwizz"
|
||||
assert "prompts" in data
|
||||
|
||||
@pytest.mark.skip(reason="Requires running services")
|
||||
async def test_fetch_system_prompt(self):
|
||||
"""Test fetching full system prompt from API"""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"http://localhost:7001/internal/agents/agent-daarwizz/system-prompt"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["agent_id"] == "agent-daarwizz"
|
||||
assert "system_prompt" in data
|
||||
assert len(data["system_prompt"]) > 100
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
280
tests/test_dagi_router_api.py
Normal file
280
tests/test_dagi_router_api.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
DAGI Router API Tests
|
||||
|
||||
Тести для endpoints:
|
||||
- GET /internal/node/{node_id}/dagi-router/agents
|
||||
- GET /internal/node/{node_id}/metrics/current
|
||||
- POST /internal/node/{node_id}/dagi-audit/run
|
||||
- POST /internal/node/{node_id}/dagi-router/phantom/sync
|
||||
- POST /internal/node/{node_id}/dagi-router/stale/mark
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from typing import Any, Dict
|
||||
|
||||
# Test configuration
|
||||
CITY_SERVICE_URL = "http://localhost:7001"
|
||||
NODE1_ID = "node-1-hetzner-gex44"
|
||||
NODE2_ID = "node-2-macbook-m4max"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""HTTP client для тестування"""
|
||||
return httpx.Client(base_url=CITY_SERVICE_URL, timeout=30.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def node_ids():
|
||||
"""Node IDs для тестування"""
|
||||
return [NODE1_ID, NODE2_ID]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DAGI Router Agents Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestDAGIRouterAgents:
|
||||
"""Тести для GET /internal/node/{node_id}/dagi-router/agents"""
|
||||
|
||||
def test_get_agents_returns_valid_response(self, client):
|
||||
"""Endpoint повертає валідну структуру"""
|
||||
response = client.get(f"/city/internal/node/{NODE1_ID}/dagi-router/agents")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Перевірка структури
|
||||
assert "node_id" in data
|
||||
assert "summary" in data
|
||||
assert "agents" in data
|
||||
|
||||
# Перевірка summary
|
||||
summary = data["summary"]
|
||||
assert "active" in summary
|
||||
assert "phantom" in summary
|
||||
assert "stale" in summary
|
||||
assert "router_total" in summary
|
||||
assert "system_total" in summary
|
||||
|
||||
# Types
|
||||
assert isinstance(summary["active"], int)
|
||||
assert isinstance(summary["phantom"], int)
|
||||
assert isinstance(data["agents"], list)
|
||||
|
||||
def test_get_agents_for_unknown_node(self, client):
|
||||
"""Endpoint повертає пустий response для невідомої ноди"""
|
||||
response = client.get("/city/internal/node/unknown-node-id/dagi-router/agents")
|
||||
|
||||
# Має повернути 200 з пустим списком, не 404
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["agents"] == []
|
||||
assert data["summary"]["active"] == 0
|
||||
|
||||
def test_agents_have_required_fields(self, client):
|
||||
"""Агенти мають всі необхідні поля"""
|
||||
response = client.get(f"/city/internal/node/{NODE1_ID}/dagi-router/agents")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
if data["agents"]:
|
||||
agent = data["agents"][0]
|
||||
|
||||
# Required fields
|
||||
assert "id" in agent
|
||||
assert "name" in agent
|
||||
assert "status" in agent
|
||||
|
||||
# Status must be valid
|
||||
assert agent["status"] in ["active", "phantom", "stale", "error"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Node Metrics Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestNodeMetrics:
|
||||
"""Тести для GET /internal/node/{node_id}/metrics/current"""
|
||||
|
||||
def test_get_metrics_returns_valid_response(self, client):
|
||||
"""Endpoint повертає валідну структуру"""
|
||||
response = client.get(f"/city/internal/node/{NODE1_ID}/metrics/current")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Required fields
|
||||
assert "node_id" in data
|
||||
assert data["node_id"] == NODE1_ID
|
||||
|
||||
# Metric fields
|
||||
assert "cpu_cores" in data
|
||||
assert "cpu_usage" in data
|
||||
assert "gpu_model" in data
|
||||
assert "gpu_memory_total" in data
|
||||
assert "gpu_memory_used" in data
|
||||
assert "ram_total" in data
|
||||
assert "ram_used" in data
|
||||
assert "disk_total" in data
|
||||
assert "disk_used" in data
|
||||
assert "agent_count_router" in data
|
||||
assert "agent_count_system" in data
|
||||
|
||||
def test_get_metrics_for_unknown_node(self, client):
|
||||
"""Endpoint повертає minimal response для невідомої ноди"""
|
||||
response = client.get("/city/internal/node/unknown-node-id/metrics/current")
|
||||
|
||||
# Має повернути 200 з мінімальним response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["node_id"] == "unknown-node-id"
|
||||
|
||||
def test_metrics_have_numeric_values(self, client):
|
||||
"""Метрики мають числові значення"""
|
||||
response = client.get(f"/city/internal/node/{NODE1_ID}/metrics/current")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# All numeric fields should be numbers
|
||||
numeric_fields = [
|
||||
"cpu_cores", "cpu_usage",
|
||||
"gpu_memory_total", "gpu_memory_used",
|
||||
"ram_total", "ram_used",
|
||||
"disk_total", "disk_used",
|
||||
"agent_count_router", "agent_count_system"
|
||||
]
|
||||
|
||||
for field in numeric_fields:
|
||||
assert isinstance(data[field], (int, float)), f"{field} should be numeric"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DAGI Audit Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestDAGIAudit:
|
||||
"""Тести для POST /internal/node/{node_id}/dagi-audit/run"""
|
||||
|
||||
def test_run_audit_returns_valid_response(self, client):
|
||||
"""POST audit повертає валідну структуру"""
|
||||
response = client.post(f"/city/internal/node/{NODE1_ID}/dagi-audit/run")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert data["status"] == "completed"
|
||||
assert "summary" in data
|
||||
assert "message" in data
|
||||
|
||||
# Summary fields
|
||||
summary = data["summary"]
|
||||
assert "router_total" in summary
|
||||
assert "db_total" in summary
|
||||
assert "active_count" in summary
|
||||
assert "phantom_count" in summary
|
||||
assert "stale_count" in summary
|
||||
|
||||
def test_get_audit_summary(self, client):
|
||||
"""GET audit summary повертає дані"""
|
||||
response = client.get(f"/city/internal/node/{NODE1_ID}/dagi-audit")
|
||||
|
||||
# Може бути 200 з даними або null
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
if data:
|
||||
assert "node_id" in data
|
||||
assert "timestamp" in data
|
||||
assert "active_count" in data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Phantom/Stale Sync Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestPhantomStaleSync:
|
||||
"""Тести для phantom/stale sync endpoints"""
|
||||
|
||||
def test_phantom_sync_empty_list(self, client):
|
||||
"""Sync з пустим списком не падає"""
|
||||
response = client.post(
|
||||
f"/city/internal/node/{NODE1_ID}/dagi-router/phantom/sync",
|
||||
json={"agent_ids": []}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "completed"
|
||||
assert data["created_count"] == 0
|
||||
|
||||
def test_stale_mark_empty_list(self, client):
|
||||
"""Mark stale з пустим списком не падає"""
|
||||
response = client.post(
|
||||
f"/city/internal/node/{NODE1_ID}/dagi-router/stale/mark",
|
||||
json={"agent_ids": []}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "completed"
|
||||
assert data["marked_count"] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestIntegration:
|
||||
"""Інтеграційні тести"""
|
||||
|
||||
def test_full_audit_flow(self, client):
|
||||
"""Повний цикл: audit → get agents → get metrics"""
|
||||
# 1. Run audit
|
||||
audit_response = client.post(f"/city/internal/node/{NODE1_ID}/dagi-audit/run")
|
||||
assert audit_response.status_code == 200
|
||||
|
||||
# 2. Get agents
|
||||
agents_response = client.get(f"/city/internal/node/{NODE1_ID}/dagi-router/agents")
|
||||
assert agents_response.status_code == 200
|
||||
agents_data = agents_response.json()
|
||||
|
||||
# 3. Get metrics
|
||||
metrics_response = client.get(f"/city/internal/node/{NODE1_ID}/metrics/current")
|
||||
assert metrics_response.status_code == 200
|
||||
|
||||
# 4. Verify consistency
|
||||
audit_data = audit_response.json()
|
||||
|
||||
# Agent counts should match
|
||||
assert agents_data["summary"]["active"] + agents_data["summary"]["phantom"] + agents_data["summary"]["stale"] >= 0
|
||||
|
||||
def test_both_nodes_accessible(self, client, node_ids):
|
||||
"""Обидві ноди доступні через API"""
|
||||
for node_id in node_ids:
|
||||
response = client.get(f"/city/internal/node/{node_id}/metrics/current")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["node_id"] == node_id
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Run tests
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
|
||||
336
tests/test_infra_smoke.py
Normal file
336
tests/test_infra_smoke.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Infrastructure Smoke Tests
|
||||
|
||||
Базові API тести для перевірки після деплою.
|
||||
Запускаються як частина deploy pipeline або вручну.
|
||||
|
||||
Використання:
|
||||
pytest tests/test_infra_smoke.py -v
|
||||
pytest tests/test_infra_smoke.py -v --base-url http://localhost:7001
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import requests
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
# Configuration
|
||||
BASE_URL = os.getenv("CITY_SERVICE_URL", "http://daarion-city-service:7001")
|
||||
TIMEOUT = 10
|
||||
|
||||
# Node IDs
|
||||
NODE1_ID = "node-1-hetzner-gex44"
|
||||
NODE2_ID = "node-2-macbook-m4max"
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""Add command line options"""
|
||||
parser.addoption(
|
||||
"--base-url",
|
||||
action="store",
|
||||
default=BASE_URL,
|
||||
help="Base URL of city-service API"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_url(request):
|
||||
"""Get base URL from command line or environment"""
|
||||
return request.config.getoption("--base-url") or BASE_URL
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client(base_url):
|
||||
"""Create API client session"""
|
||||
session = requests.Session()
|
||||
session.timeout = TIMEOUT
|
||||
|
||||
class Client:
|
||||
def __init__(self, base_url: str, session: requests.Session):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = session
|
||||
|
||||
def get(self, path: str) -> requests.Response:
|
||||
return self.session.get(f"{self.base_url}{path}", timeout=TIMEOUT)
|
||||
|
||||
def post(self, path: str, json: dict) -> requests.Response:
|
||||
return self.session.post(f"{self.base_url}{path}", json=json, timeout=TIMEOUT)
|
||||
|
||||
return Client(base_url, session)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Health Checks
|
||||
# ==============================================================================
|
||||
|
||||
class TestHealthChecks:
|
||||
"""Basic health check tests"""
|
||||
|
||||
def test_healthz_endpoint(self, api_client):
|
||||
"""Test /healthz returns 200 and status ok"""
|
||||
response = api_client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200, f"Health check failed: {response.text}"
|
||||
data = response.json()
|
||||
assert data.get("status") == "ok", f"Unhealthy status: {data}"
|
||||
|
||||
def test_public_nodes_endpoint(self, api_client):
|
||||
"""Test /public/nodes returns node list"""
|
||||
response = api_client.get("/public/nodes")
|
||||
|
||||
assert response.status_code == 200, f"Nodes endpoint failed: {response.text}"
|
||||
data = response.json()
|
||||
assert "items" in data, "Response missing 'items' key"
|
||||
assert "total" in data, "Response missing 'total' key"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Node Metrics Tests
|
||||
# ==============================================================================
|
||||
|
||||
class TestNodeMetrics:
|
||||
"""Node metrics tests"""
|
||||
|
||||
@pytest.mark.parametrize("node_id", [NODE1_ID, NODE2_ID])
|
||||
def test_node_metrics_endpoint(self, api_client, node_id):
|
||||
"""Test node metrics endpoint returns data"""
|
||||
response = api_client.get(f"/internal/node/{node_id}/metrics/current")
|
||||
|
||||
assert response.status_code == 200, f"Node metrics failed for {node_id}: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
# Check required fields
|
||||
assert "node_id" in data, "Missing node_id"
|
||||
assert "agent_count_router" in data, "Missing agent_count_router"
|
||||
assert "agent_count_system" in data, "Missing agent_count_system"
|
||||
|
||||
def test_node1_has_agents(self, api_client):
|
||||
"""Test NODE1 has at least 1 agent in router"""
|
||||
response = api_client.get(f"/internal/node/{NODE1_ID}/metrics/current")
|
||||
|
||||
if response.status_code != 200:
|
||||
pytest.skip(f"NODE1 metrics not available: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
agent_count = data.get("agent_count_router", 0)
|
||||
|
||||
assert agent_count >= 1, f"NODE1 has {agent_count} agents in router, expected >= 1"
|
||||
|
||||
def test_node2_has_agents(self, api_client):
|
||||
"""Test NODE2 has at least 1 agent in system"""
|
||||
response = api_client.get(f"/internal/node/{NODE2_ID}/metrics/current")
|
||||
|
||||
if response.status_code != 200:
|
||||
pytest.skip(f"NODE2 metrics not available: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
agent_count = data.get("agent_count_system", 0)
|
||||
|
||||
assert agent_count >= 1, f"NODE2 has {agent_count} agents in system, expected >= 1"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Node Agents Tests
|
||||
# ==============================================================================
|
||||
|
||||
class TestNodeAgents:
|
||||
"""Node agents (Guardian/Steward) tests"""
|
||||
|
||||
@pytest.mark.parametrize("node_id", [NODE1_ID, NODE2_ID])
|
||||
def test_node_agents_endpoint(self, api_client, node_id):
|
||||
"""Test node agents endpoint returns data"""
|
||||
response = api_client.get(f"/internal/node/{node_id}/agents")
|
||||
|
||||
assert response.status_code == 200, f"Node agents failed for {node_id}: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert "node_id" in data, "Missing node_id"
|
||||
assert "total" in data, "Missing total"
|
||||
assert "agents" in data, "Missing agents list"
|
||||
|
||||
def test_node1_has_guardian(self, api_client):
|
||||
"""Test NODE1 has Node Guardian"""
|
||||
response = api_client.get(f"/internal/node/{NODE1_ID}/agents")
|
||||
|
||||
if response.status_code != 200:
|
||||
pytest.skip(f"NODE1 agents not available: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
guardian = data.get("guardian")
|
||||
|
||||
assert guardian is not None, "NODE1 missing Node Guardian"
|
||||
assert guardian.get("id"), "Guardian has no ID"
|
||||
|
||||
def test_node1_has_steward(self, api_client):
|
||||
"""Test NODE1 has Node Steward"""
|
||||
response = api_client.get(f"/internal/node/{NODE1_ID}/agents")
|
||||
|
||||
if response.status_code != 200:
|
||||
pytest.skip(f"NODE1 agents not available: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
steward = data.get("steward")
|
||||
|
||||
assert steward is not None, "NODE1 missing Node Steward"
|
||||
assert steward.get("id"), "Steward has no ID"
|
||||
|
||||
def test_node2_has_guardian(self, api_client):
|
||||
"""Test NODE2 has Node Guardian"""
|
||||
response = api_client.get(f"/internal/node/{NODE2_ID}/agents")
|
||||
|
||||
if response.status_code != 200:
|
||||
pytest.skip(f"NODE2 agents not available: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
guardian = data.get("guardian")
|
||||
|
||||
assert guardian is not None, "NODE2 missing Node Guardian"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# DAGI Router Tests
|
||||
# ==============================================================================
|
||||
|
||||
class TestDAGIRouter:
|
||||
"""DAGI Router tests"""
|
||||
|
||||
@pytest.mark.parametrize("node_id", [NODE1_ID, NODE2_ID])
|
||||
def test_dagi_router_agents_endpoint(self, api_client, node_id):
|
||||
"""Test DAGI Router agents endpoint returns data"""
|
||||
response = api_client.get(f"/internal/node/{node_id}/dagi-router/agents")
|
||||
|
||||
# May return empty if no audit yet
|
||||
if response.status_code == 404:
|
||||
pytest.skip(f"DAGI Router not configured for {node_id}")
|
||||
|
||||
assert response.status_code == 200, f"DAGI Router failed for {node_id}: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert "node_id" in data, "Missing node_id"
|
||||
assert "summary" in data, "Missing summary"
|
||||
assert "agents" in data, "Missing agents list"
|
||||
|
||||
def test_node1_router_has_agents(self, api_client):
|
||||
"""Test NODE1 DAGI Router has agents"""
|
||||
response = api_client.get(f"/internal/node/{NODE1_ID}/dagi-router/agents")
|
||||
|
||||
if response.status_code != 200:
|
||||
pytest.skip(f"NODE1 DAGI Router not available: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
summary = data.get("summary", {})
|
||||
router_total = summary.get("router_total", 0)
|
||||
|
||||
# Warn but don't fail - router may not be configured
|
||||
if router_total == 0:
|
||||
pytest.skip("NODE1 DAGI Router has 0 agents (may not be configured)")
|
||||
|
||||
assert router_total >= 1, f"DAGI Router has {router_total} agents, expected >= 1"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Core Agents Tests
|
||||
# ==============================================================================
|
||||
|
||||
class TestCoreAgents:
|
||||
"""Core agents tests"""
|
||||
|
||||
def test_prompts_status_endpoint(self, api_client):
|
||||
"""Test prompts status batch endpoint"""
|
||||
agent_ids = ["agent-daarwizz", "agent-devtools", "agent-soul"]
|
||||
|
||||
response = api_client.post("/internal/agents/prompts/status", {"agent_ids": agent_ids})
|
||||
|
||||
assert response.status_code == 200, f"Prompts status failed: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data, "Missing status in response"
|
||||
assert isinstance(data["status"], dict), "Status should be a dict"
|
||||
|
||||
def test_daarwizz_runtime_prompt(self, api_client):
|
||||
"""Test DAARWIZZ has runtime prompt"""
|
||||
# Try both possible slugs
|
||||
for agent_id in ["agent-daarwizz", "daarwizz"]:
|
||||
response = api_client.get(f"/internal/agents/{agent_id}/prompts/runtime")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("has_prompts"):
|
||||
assert data.get("prompts", {}).get("core"), "DAARWIZZ missing core prompt"
|
||||
return
|
||||
|
||||
pytest.skip("DAARWIZZ agent not found or no prompts configured")
|
||||
|
||||
def test_runtime_system_prompt_endpoint(self, api_client):
|
||||
"""Test runtime system prompt endpoint works"""
|
||||
response = api_client.get("/internal/agents/agent-daarwizz/system-prompt")
|
||||
|
||||
if response.status_code == 404:
|
||||
pytest.skip("DAARWIZZ agent not found")
|
||||
|
||||
assert response.status_code == 200, f"System prompt failed: {response.text}"
|
||||
data = response.json()
|
||||
|
||||
assert "agent_id" in data, "Missing agent_id"
|
||||
assert "system_prompt" in data, "Missing system_prompt"
|
||||
assert len(data.get("system_prompt", "")) > 10, "System prompt too short"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Integration Tests
|
||||
# ==============================================================================
|
||||
|
||||
class TestIntegration:
|
||||
"""End-to-end integration tests"""
|
||||
|
||||
def test_node_to_agents_flow(self, api_client):
|
||||
"""Test full flow: node → agents → prompts"""
|
||||
# Get node
|
||||
response = api_client.get(f"/internal/node/{NODE1_ID}/agents")
|
||||
|
||||
if response.status_code != 200:
|
||||
pytest.skip(f"NODE1 not available: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
agents = data.get("agents", [])
|
||||
|
||||
if not agents:
|
||||
pytest.skip("No agents found for NODE1")
|
||||
|
||||
# Get first agent's prompts
|
||||
agent = agents[0]
|
||||
agent_id = agent.get("id")
|
||||
|
||||
response = api_client.get(f"/internal/agents/{agent_id}/prompts/runtime")
|
||||
|
||||
# Should return successfully even if no prompts
|
||||
assert response.status_code == 200, f"Agent prompts failed for {agent_id}: {response.text}"
|
||||
|
||||
def test_public_nodes_have_metrics(self, api_client):
|
||||
"""Test public nodes endpoint includes metrics"""
|
||||
response = api_client.get("/public/nodes")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
pytest.skip("No nodes in system")
|
||||
|
||||
# Check first node has metrics
|
||||
node = items[0]
|
||||
|
||||
# Should have metrics object after our changes
|
||||
if "metrics" in node:
|
||||
metrics = node["metrics"]
|
||||
assert "cpu_cores" in metrics or "ram_total" in metrics, "Metrics object empty"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Run as script
|
||||
# ==============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
Reference in New Issue
Block a user