Improve test coverage to 65%
Add comprehensive test suites for: - NPM client (27 tests) - Ollama client (16 tests) - AI client and controller (34 tests) - Static controller (8 tests) - Tools controller DNS lookup (9 tests) - OIDC authentication (10 tests) - Housekeeping endpoints (28 tests) - Infrastructure endpoints (15 tests) - Health endpoints (12 tests) - Portainer client (12 tests) - Home Assistant client (24 tests) Total: 285 tests passing with 65% code coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
"""Tests for Core-AI client."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import httpx
|
||||
|
||||
from src.clients.ai_client import CoreAIClient, get_ai_client
|
||||
|
||||
|
||||
class TestCoreAIClientInit:
|
||||
"""Test CoreAIClient initialization."""
|
||||
|
||||
@patch("src.clients.ai_client.settings")
|
||||
def test_uses_settings_defaults(self, mock_settings):
|
||||
"""Client should use settings for defaults."""
|
||||
mock_settings.core_ai_base_url = "http://core-ai:8086"
|
||||
|
||||
client = CoreAIClient()
|
||||
|
||||
assert client.base_url == "http://core-ai:8086"
|
||||
assert client.timeout == 10
|
||||
|
||||
def test_accepts_custom_url(self):
|
||||
"""Client should accept custom URL."""
|
||||
client = CoreAIClient(base_url="http://custom:9000")
|
||||
|
||||
assert client.base_url == "http://custom:9000"
|
||||
|
||||
def test_accepts_custom_timeout(self):
|
||||
"""Client should accept custom timeout."""
|
||||
client = CoreAIClient(base_url="http://test:8086", timeout=30)
|
||||
|
||||
assert client.timeout == 30
|
||||
|
||||
def test_strips_trailing_slash_from_url(self):
|
||||
"""Client should strip trailing slash from URL."""
|
||||
client = CoreAIClient(base_url="http://core-ai:8086/")
|
||||
|
||||
assert client.base_url == "http://core-ai:8086"
|
||||
|
||||
def test_creates_http_client(self):
|
||||
"""Client should create httpx AsyncClient."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
assert client.client is not None
|
||||
|
||||
|
||||
class TestCoreAIClientClose:
|
||||
"""Test client close functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_closes_client(self):
|
||||
"""close should close the HTTP client."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client.client, "aclose", new_callable=AsyncMock) as mock_close:
|
||||
await client.close()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TestCoreAIClientContextManager:
|
||||
"""Test async context manager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_enters(self):
|
||||
"""Context manager should return client on enter."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client.client, "aclose", new_callable=AsyncMock):
|
||||
async with client as ctx:
|
||||
assert ctx is client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_closes_on_exit(self):
|
||||
"""Context manager should close client on exit."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client, "close", new_callable=AsyncMock) as mock_close:
|
||||
async with client:
|
||||
pass
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TestCoreAIClientHealthCheck:
|
||||
"""Test health check functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_true_on_200(self):
|
||||
"""Health check should return True when service responds 200."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_false_on_error(self):
|
||||
"""Health check should return False on connection error."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.side_effect = Exception("Connection refused")
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_returns_false_on_non_200(self):
|
||||
"""Health check should return False on non-200 status."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestCoreAIClientGetMetrics:
|
||||
"""Test get metrics functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metrics_returns_dict(self):
|
||||
"""get_metrics should return metrics dict."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
metrics_data = {
|
||||
"uptime_seconds": 3600,
|
||||
"agent": {"total_requests": 100},
|
||||
"tools": {"total_calls": 250}
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = metrics_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.get_metrics()
|
||||
|
||||
assert result == metrics_data
|
||||
assert result["uptime_seconds"] == 3600
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metrics_raises_on_http_error(self):
|
||||
"""get_metrics should raise on HTTP error."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server Error", request=MagicMock(), response=mock_response
|
||||
)
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await client.get_metrics()
|
||||
|
||||
|
||||
class TestCoreAIClientGetRecentErrors:
|
||||
"""Test get recent errors functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_errors_returns_list(self):
|
||||
"""get_recent_errors should return list of errors."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
errors_data = {
|
||||
"errors": [
|
||||
{"timestamp": "2025-12-03T19:45:12Z", "error": "Timeout"},
|
||||
{"timestamp": "2025-12-03T19:46:00Z", "error": "Connection refused"}
|
||||
]
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = errors_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.get_recent_errors()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["error"] == "Timeout"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recent_errors_passes_limit(self):
|
||||
"""get_recent_errors should pass limit parameter."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"errors": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
await client.get_recent_errors(limit=5)
|
||||
|
||||
call_args = mock_get.call_args
|
||||
assert call_args[1]["params"]["limit"] == 5
|
||||
|
||||
|
||||
class TestCoreAIClientGetToolFailures:
|
||||
"""Test get tool failures functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_failures_returns_list(self):
|
||||
"""get_tool_failures should return list of failures."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
failures_data = {
|
||||
"failures": [
|
||||
{"tool_name": "list_containers", "error": "Connection refused"}
|
||||
]
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = failures_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.get_tool_failures()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["tool_name"] == "list_containers"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_failures_passes_limit(self):
|
||||
"""get_tool_failures should pass limit parameter."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"failures": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
await client.get_tool_failures(limit=10)
|
||||
|
||||
call_args = mock_get.call_args
|
||||
assert call_args[1]["params"]["limit"] == 10
|
||||
|
||||
|
||||
class TestCoreAIClientResetMetrics:
|
||||
"""Test reset metrics functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_metrics_returns_true_on_success(self):
|
||||
"""reset_metrics should return True on success."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
result = await client.reset_metrics()
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_metrics_raises_on_error(self):
|
||||
"""reset_metrics should raise on error."""
|
||||
client = CoreAIClient(base_url="http://test:8086")
|
||||
|
||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.side_effect = Exception("Connection refused")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await client.reset_metrics()
|
||||
|
||||
|
||||
class TestCoreAIClientSingleton:
|
||||
"""Test singleton pattern."""
|
||||
|
||||
def test_get_ai_client_returns_same_instance(self):
|
||||
"""get_ai_client should return singleton."""
|
||||
import src.clients.ai_client as module
|
||||
module._ai_client = None
|
||||
|
||||
client1 = get_ai_client()
|
||||
client2 = get_ai_client()
|
||||
|
||||
assert client1 is client2
|
||||
Reference in New Issue
Block a user