"""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