Build and Push / build (release) Successful in 1m28s
- Dashboard domain with Quick Links CRUD + reorder endpoints - Dashboard widgets management endpoints - Database migrations for quick_links and dashboard_widgets tables - Static file controller for Organizr widgets - Default local user when OIDC is disabled - Domain-based architecture refactor (src/domains/, src/shared/) - Test suite updated for new structure (285 tests passing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
472 lines
18 KiB
Python
472 lines
18 KiB
Python
"""Tests for Home Assistant client."""
|
|
import pytest
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
|
import httpx
|
|
|
|
from src.shared.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client
|
|
|
|
|
|
class TestHomeAssistantClientInit:
|
|
"""Test HomeAssistantClient initialization."""
|
|
|
|
@patch("src.shared.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.shared.clients.homeassistant_client.logger")
|
|
@patch("src.shared.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.shared.clients.homeassistant_client as module
|
|
module._homeassistant_client = None
|
|
|
|
client1 = get_homeassistant_client()
|
|
client2 = get_homeassistant_client()
|
|
|
|
assert client1 is client2
|