Files
core-api/tests/test_housekeeping.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

656 lines
25 KiB
Python

"""Tests for housekeeping (home automation) endpoints."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock
from src.main import app
@pytest.fixture
def client():
"""Create a test client."""
return TestClient(app)
@pytest.fixture
def mock_ha_client():
"""Create a mock Home Assistant client."""
mock = AsyncMock()
return mock
@pytest.fixture
def sample_states():
"""Sample Home Assistant states for testing."""
return [
{
"entity_id": "light.living_room",
"state": "on",
"attributes": {
"friendly_name": "Living Room Light",
"brightness": 255,
"area_id": "living_room"
},
"last_changed": "2025-01-01T12:00:00Z"
},
{
"entity_id": "light.bedroom",
"state": "off",
"attributes": {
"friendly_name": "Bedroom Light",
"area_id": "bedroom"
},
"last_changed": "2025-01-01T11:00:00Z"
},
{
"entity_id": "switch.garage",
"state": "off",
"attributes": {
"friendly_name": "Garage Switch"
},
"last_changed": "2025-01-01T10:00:00Z"
},
{
"entity_id": "scene.movie_night",
"state": "scening",
"attributes": {
"friendly_name": "Movie Night"
},
"last_changed": "2025-01-01T09:00:00Z"
},
{
"entity_id": "script.bedtime",
"state": "off",
"attributes": {
"friendly_name": "Bedtime Routine"
},
"last_changed": "2025-01-01T08:00:00Z"
},
{
"entity_id": "automation.motion_lights",
"state": "on",
"attributes": {
"friendly_name": "Motion Lights"
},
"last_changed": "2025-01-01T07:00:00Z"
},
{
"entity_id": "sensor.temperature",
"state": "22.5",
"attributes": {
"friendly_name": "Temperature",
"unit_of_measurement": "°C"
},
"last_changed": "2025-01-01T06:00:00Z"
}
]
class TestHousekeepingHealth:
"""Test /housekeeping/health endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_health_returns_200(self, mock_get_ha, client):
"""Health endpoint should return 200."""
mock_client = AsyncMock()
mock_client.health_check.return_value = {
"status": "healthy",
"connected": True,
"platform": "home_assistant",
"version": "2024.12.0"
}
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/health")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_health_returns_connection_status(self, mock_get_ha, client):
"""Health endpoint should return connection status."""
mock_client = AsyncMock()
mock_client.health_check.return_value = {
"status": "healthy",
"connected": True,
"platform": "home_assistant",
"version": "2024.12.0"
}
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/health")
data = response.json()
assert "status" in data
assert "connected" in data
assert "platform" in data
assert data["platform"] == "home_assistant"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_health_returns_unhealthy_when_disconnected(self, mock_get_ha, client):
"""Health should report unhealthy when HA is disconnected."""
mock_client = AsyncMock()
mock_client.health_check.return_value = {
"status": "unhealthy",
"connected": False,
"platform": "home_assistant",
"error": "Connection refused"
}
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/health")
data = response.json()
assert data["connected"] is False
assert data["status"] == "unhealthy"
class TestHousekeepingDevices:
"""Test /housekeeping/devices endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_devices_returns_200(self, mock_get_ha, client, sample_states):
"""List devices should return 200."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_devices_returns_controllable_only(self, mock_get_ha, client, sample_states):
"""List devices should filter out non-controllable entities."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices")
data = response.json()
# Should include lights, switches, scenes, scripts, automations
# Should NOT include sensors
entity_ids = [d["entity_id"] for d in data["devices"]]
assert "light.living_room" in entity_ids
assert "switch.garage" in entity_ids
assert "sensor.temperature" not in entity_ids
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_devices_filter_by_domain(self, mock_get_ha, client, sample_states):
"""List devices should filter by domain parameter."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices?domain=light")
data = response.json()
# Should only return lights
assert len(data["devices"]) == 2
for device in data["devices"]:
assert device["domain"] == "light"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_devices_includes_attributes(self, mock_get_ha, client, sample_states):
"""List devices should include device attributes."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices")
data = response.json()
# Find the living room light
living_room = next(d for d in data["devices"] if d["entity_id"] == "light.living_room")
assert living_room["name"] == "Living Room Light"
assert living_room["state"] == "on"
assert "brightness" in living_room["attributes"]
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_get_device_returns_200(self, mock_get_ha, client):
"""Get device should return 200 for existing device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {
"friendly_name": "Living Room Light",
"brightness": 255
},
"last_changed": "2025-01-01T12:00:00Z"
}
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices/light.living_room")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_get_device_returns_404_for_missing(self, mock_get_ha, client):
"""Get device should return 404 for non-existent device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = None
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/devices/light.nonexistent")
assert response.status_code == 404
data = response.json()
assert data["detail"]["code"] == "DEVICE_NOT_FOUND"
class TestHousekeepingAreas:
"""Test /housekeeping/areas endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_areas_returns_200(self, mock_get_ha, client):
"""List areas should return 200."""
mock_client = AsyncMock()
mock_client.get_areas.return_value = [
{"id": "living_room", "name": "Living Room"},
{"id": "bedroom", "name": "Bedroom"}
]
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/areas")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_areas_returns_area_data(self, mock_get_ha, client):
"""List areas should return area id and name."""
mock_client = AsyncMock()
mock_client.get_areas.return_value = [
{"id": "living_room", "name": "Living Room"},
{"id": "bedroom", "name": "Bedroom"}
]
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/areas")
data = response.json()
assert "areas" in data
assert len(data["areas"]) == 2
assert data["areas"][0]["id"] == "living_room"
assert data["areas"][0]["name"] == "Living Room"
class TestHousekeepingScenes:
"""Test /housekeeping/scenes endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_scenes_returns_200(self, mock_get_ha, client, sample_states):
"""List scenes should return 200."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/scenes")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_scenes_returns_only_scenes(self, mock_get_ha, client, sample_states):
"""List scenes should only return scene entities."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/scenes")
data = response.json()
assert "scenes" in data
assert len(data["scenes"]) == 1
assert data["scenes"][0]["id"] == "scene.movie_night"
assert data["scenes"][0]["name"] == "Movie Night"
class TestHousekeepingScripts:
"""Test /housekeeping/scripts endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_scripts_returns_200(self, mock_get_ha, client, sample_states):
"""List scripts should return 200."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/scripts")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_scripts_returns_only_scripts(self, mock_get_ha, client, sample_states):
"""List scripts should only return script entities."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/scripts")
data = response.json()
assert "scripts" in data
assert len(data["scripts"]) == 1
assert data["scripts"][0]["id"] == "script.bedtime"
class TestHousekeepingAutomations:
"""Test /housekeeping/automations endpoints."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_automations_returns_200(self, mock_get_ha, client, sample_states):
"""List automations should return 200."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/automations")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_list_automations_includes_enabled_status(self, mock_get_ha, client, sample_states):
"""List automations should include enabled status."""
mock_client = AsyncMock()
mock_client.get_states.return_value = sample_states
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/automations")
data = response.json()
assert "automations" in data
assert len(data["automations"]) == 1
assert data["automations"][0]["id"] == "automation.motion_lights"
assert data["automations"][0]["enabled"] is True
class TestHousekeepingHistory:
"""Test /housekeeping/history endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_history_returns_200(self, mock_get_ha, client):
"""History endpoint should return 200."""
mock_client = AsyncMock()
mock_client.get_history.return_value = [[
{"state": "on", "last_changed": "2025-01-01T12:00:00Z", "attributes": {}},
{"state": "off", "last_changed": "2025-01-01T11:00:00Z", "attributes": {}}
]]
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/history?entity_id=light.living_room")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_history_returns_entries(self, mock_get_ha, client):
"""History endpoint should return history entries."""
mock_client = AsyncMock()
mock_client.get_history.return_value = [[
{"state": "on", "last_changed": "2025-01-01T12:00:00Z", "attributes": {"brightness": 255}},
{"state": "off", "last_changed": "2025-01-01T11:00:00Z", "attributes": {}}
]]
mock_get_ha.return_value = mock_client
response = client.get("/housekeeping/history?entity_id=light.living_room&hours=24")
data = response.json()
assert "entity_id" in data
assert "history" in data
assert data["entity_id"] == "light.living_room"
assert len(data["history"]) == 2
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_history_requires_entity_id(self, mock_get_ha, client):
"""History endpoint should require entity_id parameter."""
response = client.get("/housekeeping/history")
assert response.status_code == 422 # Validation error
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_history_validates_hours_range(self, mock_get_ha, client):
"""History endpoint should validate hours range (1-168)."""
mock_client = AsyncMock()
mock_get_ha.return_value = mock_client
# Too high
response = client.get("/housekeeping/history?entity_id=light.test&hours=200")
assert response.status_code == 422
# Too low
response = client.get("/housekeeping/history?entity_id=light.test&hours=0")
assert response.status_code == 422
class TestHousekeepingDeviceControl:
"""Test /housekeeping/devices/{entity_id}/control endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_turn_on(self, mock_get_ha, client):
"""Control should turn on device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {"brightness": 255}
}
mock_client.turn_on.return_value = [{"entity_id": "light.living_room"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "turn_on"}
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_turn_off(self, mock_get_ha, client):
"""Control should turn off device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "off",
"attributes": {}
}
mock_client.turn_off.return_value = [{"entity_id": "light.living_room"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "turn_off"}
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_toggle(self, mock_get_ha, client):
"""Control should toggle device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {}
}
mock_client.toggle.return_value = [{"entity_id": "light.living_room"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "toggle"}
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_with_brightness(self, mock_get_ha, client):
"""Control should set brightness."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {"brightness": 128}
}
mock_client.turn_on.return_value = [{"entity_id": "light.living_room"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "turn_on", "brightness": 128}
)
assert response.status_code == 200
mock_client.turn_on.assert_called_once()
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_returns_404_for_missing(self, mock_get_ha, client):
"""Control should return 404 for non-existent device."""
mock_client = AsyncMock()
mock_client.get_state.return_value = None
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.nonexistent/control",
json={"action": "turn_on"}
)
assert response.status_code == 404
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_control_device_returns_error_response(self, mock_get_ha, client):
"""Control should return proper error response."""
mock_client = AsyncMock()
mock_client.get_state.return_value = {
"entity_id": "light.living_room",
"state": "on",
"attributes": {}
}
mock_client.turn_on.side_effect = Exception("Service unavailable")
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/devices/light.living_room/control",
json={"action": "turn_on"}
)
assert response.status_code == 500
class TestHousekeepingSceneActivation:
"""Test /housekeeping/scenes/{scene_id}/activate endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_activate_scene_returns_200(self, mock_get_ha, client):
"""Activate scene should return 200."""
mock_client = AsyncMock()
mock_client.activate_scene.return_value = [{"entity_id": "scene.movie_night"}]
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_activate_scene_returns_success_response(self, mock_get_ha, client):
"""Activate scene should return success response."""
mock_client = AsyncMock()
mock_client.activate_scene.return_value = [{"entity_id": "scene.movie_night"}]
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
data = response.json()
assert data["success"] is True
assert data["scene_id"] == "scene.movie_night"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_activate_scene_handles_error(self, mock_get_ha, client):
"""Activate scene should handle errors."""
mock_client = AsyncMock()
mock_client.activate_scene.side_effect = Exception("Service error")
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scenes/scene.movie_night/activate")
assert response.status_code == 500
class TestHousekeepingScriptRun:
"""Test /housekeeping/scripts/{script_id}/run endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_run_script_returns_200(self, mock_get_ha, client):
"""Run script should return 200."""
mock_client = AsyncMock()
mock_client.run_script.return_value = [{"entity_id": "script.bedtime"}]
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scripts/script.bedtime/run")
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_run_script_returns_success_response(self, mock_get_ha, client):
"""Run script should return success response."""
mock_client = AsyncMock()
mock_client.run_script.return_value = [{"entity_id": "script.bedtime"}]
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scripts/script.bedtime/run")
data = response.json()
assert data["success"] is True
assert data["script_id"] == "script.bedtime"
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_run_script_handles_error(self, mock_get_ha, client):
"""Run script should handle errors."""
mock_client = AsyncMock()
mock_client.run_script.side_effect = Exception("Script error")
mock_get_ha.return_value = mock_client
response = client.post("/housekeeping/scripts/script.bedtime/run")
assert response.status_code == 500
class TestHousekeepingAutomationToggle:
"""Test /housekeeping/automations/{automation_id}/toggle endpoint."""
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_toggle_automation_enable(self, mock_get_ha, client):
"""Toggle automation should enable when requested."""
mock_client = AsyncMock()
mock_client.enable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/automations/automation.motion_lights/toggle",
json={"enabled": True}
)
assert response.status_code == 200
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_toggle_automation_disable(self, mock_get_ha, client):
"""Toggle automation should disable when requested."""
mock_client = AsyncMock()
mock_client.disable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/automations/automation.motion_lights/toggle",
json={"enabled": False}
)
assert response.status_code == 200
mock_client.disable_automation.assert_called_once()
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_toggle_automation_returns_new_state(self, mock_get_ha, client):
"""Toggle automation should return new enabled state."""
mock_client = AsyncMock()
mock_client.enable_automation.return_value = [{"entity_id": "automation.motion_lights"}]
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/automations/automation.motion_lights/toggle",
json={"enabled": True}
)
data = response.json()
assert data["success"] is True
assert data["automation_id"] == "automation.motion_lights"
assert data["enabled"] is True
@patch("src.controllers.housekeeping_controller.get_homeassistant_client")
def test_toggle_automation_handles_error(self, mock_get_ha, client):
"""Toggle automation should handle errors."""
mock_client = AsyncMock()
mock_client.enable_automation.side_effect = Exception("Automation error")
mock_get_ha.return_value = mock_client
response = client.post(
"/housekeeping/automations/automation.motion_lights/toggle",
json={"enabled": True}
)
assert response.status_code == 500