Files
core-api/tests/test_homeassistant_client.py
T
jpmschweitzerandClaude Opus 4.5 a22e168666 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>
2025-12-17 16:32:39 +01:00

472 lines
18 KiB
Python

"""Tests for Home Assistant client."""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
import httpx
from src.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client
class TestHomeAssistantClientInit:
"""Test HomeAssistantClient initialization."""
@patch("src.clients.homeassistant_client.settings")
def test_uses_settings_defaults(self, mock_settings):
"""Client should use settings for defaults."""
mock_settings.homeassistant_url = "http://ha.local:8123"
mock_settings.homeassistant_token = "test_token"
client = HomeAssistantClient()
assert client.base_url == "http://ha.local:8123"
assert client.token == "test_token"
def test_accepts_custom_url_and_token(self):
"""Client should accept custom URL and token."""
client = HomeAssistantClient(
base_url="http://custom:8123",
token="custom_token"
)
assert client.base_url == "http://custom:8123"
assert client.token == "custom_token"
def test_strips_trailing_slash_from_url(self):
"""Client should strip trailing slash from URL."""
client = HomeAssistantClient(
base_url="http://custom:8123/",
token="token"
)
assert client.base_url == "http://custom:8123"
@patch("src.clients.homeassistant_client.logger")
@patch("src.clients.homeassistant_client.settings")
def test_warns_when_token_missing(self, mock_settings, mock_logger):
"""Client should warn when token is not configured."""
mock_settings.homeassistant_url = "http://ha.local:8123"
mock_settings.homeassistant_token = ""
HomeAssistantClient()
mock_logger.warning.assert_called_once()
class TestHomeAssistantClientHeaders:
"""Test header generation."""
def test_get_headers_includes_bearer_token(self):
"""Headers should include Bearer token."""
client = HomeAssistantClient(
base_url="http://ha:8123",
token="my_token"
)
headers = client._get_headers()
assert headers["Authorization"] == "Bearer my_token"
assert headers["Content-Type"] == "application/json"
class TestHomeAssistantClientHealthCheck:
"""Test health check functionality."""
@pytest.mark.asyncio
async def test_health_check_returns_healthy(self):
"""Health check should return healthy when HA responds."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"version": "2024.12.0"}
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result["status"] == "healthy"
assert result["connected"] is True
assert result["platform"] == "home_assistant"
assert result["version"] == "2024.12.0"
@pytest.mark.asyncio
async def test_health_check_returns_unhealthy_on_error(self):
"""Health check should return unhealthy on connection error."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.side_effect = Exception("Connection refused")
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result["status"] == "unhealthy"
assert result["connected"] is False
assert "error" in result
@pytest.mark.asyncio
async def test_health_check_returns_unhealthy_on_non_200(self):
"""Health check should return unhealthy on non-200 status."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.health_check()
assert result["status"] == "unhealthy"
assert result["connected"] is False
class TestHomeAssistantClientStates:
"""Test state retrieval methods."""
@pytest.mark.asyncio
async def test_get_states_returns_list(self):
"""get_states should return list of states."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
states = [
{"entity_id": "light.test", "state": "on"},
{"entity_id": "switch.test", "state": "off"}
]
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = states
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_states()
assert result == states
assert len(result) == 2
@pytest.mark.asyncio
async def test_get_state_returns_single_entity(self):
"""get_state should return single entity state."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
state = {"entity_id": "light.test", "state": "on", "attributes": {}}
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = state
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_state("light.test")
assert result == state
@pytest.mark.asyncio
async def test_get_state_returns_none_for_404(self):
"""get_state should return None for non-existent entity."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 404
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_state("light.nonexistent")
assert result is None
class TestHomeAssistantClientServices:
"""Test service call methods."""
@pytest.mark.asyncio
async def test_call_service_posts_to_correct_endpoint(self):
"""call_service should POST to /api/services/{domain}/{service}."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.call_service("light", "turn_on", "light.test")
# Verify the correct URL was called
call_args = mock_client.post.call_args
assert "/api/services/light/turn_on" in call_args[0][0]
@pytest.mark.asyncio
async def test_turn_on_calls_correct_service(self):
"""turn_on should call the turn_on service with attributes."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.turn_on("light.test", brightness=128)
call_args = mock_client.post.call_args
assert "/api/services/light/turn_on" in call_args[0][0]
# Check that brightness was passed in the payload
payload = call_args[1]["json"]
assert payload["entity_id"] == "light.test"
assert payload["brightness"] == 128
@pytest.mark.asyncio
async def test_turn_off_calls_correct_service(self):
"""turn_off should call the turn_off service."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.turn_off("switch.test")
call_args = mock_client.post.call_args
assert "/api/services/switch/turn_off" in call_args[0][0]
@pytest.mark.asyncio
async def test_toggle_calls_correct_service(self):
"""toggle should call the toggle service."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.toggle("light.test")
call_args = mock_client.post.call_args
assert "/api/services/light/toggle" in call_args[0][0]
class TestHomeAssistantClientScenes:
"""Test scene methods."""
@pytest.mark.asyncio
async def test_activate_scene_calls_scene_turn_on(self):
"""activate_scene should call scene.turn_on service."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.activate_scene("scene.movie_night")
call_args = mock_client.post.call_args
assert "/api/services/scene/turn_on" in call_args[0][0]
class TestHomeAssistantClientScripts:
"""Test script methods."""
@pytest.mark.asyncio
async def test_run_script_calls_script_turn_on(self):
"""run_script should call script.turn_on service."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.run_script("script.bedtime", {"delay": 5})
call_args = mock_client.post.call_args
assert "/api/services/script/turn_on" in call_args[0][0]
class TestHomeAssistantClientAutomations:
"""Test automation methods."""
@pytest.mark.asyncio
async def test_enable_automation_calls_turn_on(self):
"""enable_automation should call automation.turn_on."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.enable_automation("automation.motion")
call_args = mock_client.post.call_args
assert "/api/services/automation/turn_on" in call_args[0][0]
@pytest.mark.asyncio
async def test_disable_automation_calls_turn_off(self):
"""disable_automation should call automation.turn_off."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.disable_automation("automation.motion")
call_args = mock_client.post.call_args
assert "/api/services/automation/turn_off" in call_args[0][0]
class TestHomeAssistantClientHistory:
"""Test history methods."""
@pytest.mark.asyncio
async def test_get_history_calls_correct_endpoint(self):
"""get_history should call /api/history/period endpoint."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [[]]
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
await client.get_history("light.test", hours=24)
call_args = mock_client.get.call_args
assert "/api/history/period/" in call_args[0][0]
assert call_args[1]["params"]["filter_entity_id"] == "light.test"
class TestHomeAssistantClientAreas:
"""Test areas method."""
@pytest.mark.asyncio
async def test_get_areas_uses_template_api(self):
"""get_areas should use the template API."""
client = HomeAssistantClient(base_url="http://ha:8123", token="token")
with patch("httpx.AsyncClient") as mock_client_class:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = '[{"id": "living_room", "name": "Living Room"}]'
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
result = await client.get_areas()
call_args = mock_client.post.call_args
assert "/api/template" in call_args[0][0]
assert result == [{"id": "living_room", "name": "Living Room"}]
class TestGetHomeAssistantClientSingleton:
"""Test singleton pattern."""
def test_returns_same_instance(self):
"""get_homeassistant_client should return singleton."""
# Reset singleton
import src.clients.homeassistant_client as module
module._homeassistant_client = None
client1 = get_homeassistant_client()
client2 = get_homeassistant_client()
assert client1 is client2