Mechanical only, and separated from the judgment calls that follow so the reviewable changes are not buried in a 98-file whitespace diff. 227 automatic fixes: 60 blank lines carrying whitespace, 60 unsorted import blocks, 34 Optional[X] to X | None, 28 unused imports, 16 deprecated typing imports, 12 datetime.timezone.utc to datetime.UTC, and assorted smaller modernisations. Then `ruff format` over src and tests: 98 files reformatted, 35 already conforming. No file among the unused-import findings defines __all__ or is an __init__.py, so nothing here removes a re-export. `make test`: 658 passed, unchanged from HEAD. Two things observed while verifying, neither addressed here: `pytest tests/` cannot collect — tests/e2e/test_orchestration_e2e.py uses an `e2e` marker that is not registered, and the config is strict about markers. This fails identically at HEAD, so it predates this change; `make test` passes because it ignores tests/e2e, tests/integration and tests/contracts. test_tatlock_tool_call_logging_calculator is flaky. It failed once in a full run with these changes and passed on the next, passes in isolation with them, and fails in isolation at HEAD. It is order- or timing-dependent, not a regression from this commit — established by running the full suite both ways rather than by reasoning about which change could have caused it. Co-Authored-By: Claude <noreply@anthropic.com>
559 lines
17 KiB
Python
559 lines
17 KiB
Python
"""
|
|
Tests for the Core-API HTTP client.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src.agents.housekeeper.client import (
|
|
Area,
|
|
Automation,
|
|
ControlResult,
|
|
CoreAPIClient,
|
|
Device,
|
|
DeviceState,
|
|
HistoryEntry,
|
|
Scene,
|
|
Script,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_httpx_client():
|
|
"""Create a mock httpx client."""
|
|
return AsyncMock(spec=httpx.AsyncClient)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_mock(mock_httpx_client):
|
|
"""Create a CoreAPIClient with mocked httpx client."""
|
|
client = CoreAPIClient(
|
|
base_url="http://test:8090",
|
|
api_key="test-key",
|
|
)
|
|
client._client = mock_httpx_client
|
|
return client
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCoreAPIClientInit:
|
|
"""Tests for client initialization."""
|
|
|
|
def test_default_initialization(self):
|
|
"""Test client initializes with defaults from config."""
|
|
client = CoreAPIClient()
|
|
|
|
assert client.base_url is not None
|
|
assert client.timeout == 30
|
|
assert client._client is None
|
|
|
|
def test_custom_initialization(self):
|
|
"""Test client with custom parameters."""
|
|
client = CoreAPIClient(
|
|
base_url="http://custom:9000",
|
|
api_key="my-api-key",
|
|
timeout=60,
|
|
)
|
|
|
|
assert client.base_url == "http://custom:9000"
|
|
assert client.api_key == "my-api-key"
|
|
assert client.timeout == 60
|
|
|
|
def test_ensure_client_not_initialized(self):
|
|
"""Test _ensure_client raises when not in context."""
|
|
client = CoreAPIClient()
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
client._ensure_client()
|
|
|
|
assert "not initialized" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestContextManager:
|
|
"""Tests for async context manager."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_manager_creates_client(self):
|
|
"""Test context manager creates httpx client."""
|
|
async with CoreAPIClient(
|
|
base_url="http://test:8090",
|
|
api_key="test-key",
|
|
) as client:
|
|
assert client._client is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_manager_closes_client(self):
|
|
"""Test context manager closes client on exit."""
|
|
client = CoreAPIClient(base_url="http://test:8090")
|
|
|
|
async with client:
|
|
assert client._client is not None
|
|
|
|
# After exit, client should be None
|
|
assert client._client is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestDeviceDiscovery:
|
|
"""Tests for device discovery methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_devices(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing devices."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"devices": [
|
|
{
|
|
"entity_id": "light.living_room",
|
|
"name": "Living Room Light",
|
|
"state": "on",
|
|
"domain": "light",
|
|
"area": "living_room",
|
|
"attributes": {"brightness": 255},
|
|
},
|
|
{
|
|
"entity_id": "switch.coffee_maker",
|
|
"name": "Coffee Maker",
|
|
"state": "off",
|
|
"domain": "switch",
|
|
"area": "kitchen",
|
|
},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
devices = await client_with_mock.list_devices()
|
|
|
|
assert len(devices) == 2
|
|
assert isinstance(devices[0], Device)
|
|
assert devices[0].entity_id == "light.living_room"
|
|
assert devices[0].state == "on"
|
|
assert devices[0].domain == "light"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_areas(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing areas."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"areas": [
|
|
{
|
|
"area_id": "living_room",
|
|
"name": "Living Room",
|
|
"device_count": 5,
|
|
},
|
|
{
|
|
"area_id": "bedroom",
|
|
"name": "Bedroom",
|
|
"device_count": 3,
|
|
},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
areas = await client_with_mock.list_areas()
|
|
|
|
assert len(areas) == 2
|
|
assert isinstance(areas[0], Area)
|
|
assert areas[0].area_id == "living_room"
|
|
assert areas[0].name == "Living Room"
|
|
assert areas[0].device_count == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_devices_with_filter(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing devices with domain filter."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"devices": [
|
|
{
|
|
"entity_id": "light.bedroom",
|
|
"name": "Bedroom Light",
|
|
"state": "off",
|
|
"domain": "light",
|
|
}
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
devices = await client_with_mock.list_devices(domain="light")
|
|
|
|
assert len(devices) == 1
|
|
mock_httpx_client.get.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_device_state(self, client_with_mock, mock_httpx_client):
|
|
"""Test getting device state."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"entity_id": "light.living_room",
|
|
"state": "on",
|
|
"attributes": {
|
|
"brightness": 200,
|
|
"color_temp": 370,
|
|
},
|
|
"last_changed": "2024-01-15T10:30:00Z",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
state = await client_with_mock.get_device_state("light.living_room")
|
|
|
|
assert isinstance(state, DeviceState)
|
|
assert state.entity_id == "light.living_room"
|
|
assert state.state == "on"
|
|
assert state.attributes["brightness"] == 200
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestDeviceControl:
|
|
"""Tests for device control methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_on(self, client_with_mock, mock_httpx_client):
|
|
"""Test turning on a device."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"success": True,
|
|
"message": "Turned on",
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.turn_on("light.living_room")
|
|
|
|
assert isinstance(result, ControlResult)
|
|
assert result.success is True
|
|
assert result.entity_id == "light.living_room"
|
|
assert result.action == "turn_on"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_on_with_brightness(self, client_with_mock, mock_httpx_client):
|
|
"""Test turning on with brightness."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.turn_on(
|
|
"light.bedroom",
|
|
brightness=128,
|
|
)
|
|
|
|
assert result.success is True
|
|
# Check that brightness was in the payload
|
|
call_kwargs = mock_httpx_client.post.call_args[1]
|
|
assert call_kwargs["json"]["brightness"] == 128
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_off(self, client_with_mock, mock_httpx_client):
|
|
"""Test turning off a device."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.turn_off("switch.coffee_maker")
|
|
|
|
assert result.success is True
|
|
assert result.action == "turn_off"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_toggle(self, client_with_mock, mock_httpx_client):
|
|
"""Test toggling a device."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.toggle("light.hallway")
|
|
|
|
assert result.success is True
|
|
assert result.action == "toggle"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestScenes:
|
|
"""Tests for scene methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_scenes(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing scenes."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"scenes": [
|
|
{
|
|
"entity_id": "scene.movie_night",
|
|
"name": "movie_night",
|
|
"friendly_name": "Movie Night",
|
|
},
|
|
{
|
|
"entity_id": "scene.good_morning",
|
|
"name": "good_morning",
|
|
"friendly_name": "Good Morning",
|
|
},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
scenes = await client_with_mock.list_scenes()
|
|
|
|
assert len(scenes) == 2
|
|
assert isinstance(scenes[0], Scene)
|
|
assert scenes[0].entity_id == "scene.movie_night"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_activate_scene(self, client_with_mock, mock_httpx_client):
|
|
"""Test activating a scene."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.activate_scene("scene.movie_night")
|
|
|
|
assert result.success is True
|
|
assert result.action == "activate"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestScripts:
|
|
"""Tests for script methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_scripts(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing scripts."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"scripts": [
|
|
{
|
|
"entity_id": "script.good_morning",
|
|
"name": "Good Morning Routine",
|
|
"description": "Morning automation",
|
|
},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
scripts = await client_with_mock.list_scripts()
|
|
|
|
assert len(scripts) == 1
|
|
assert isinstance(scripts[0], Script)
|
|
assert scripts[0].name == "Good Morning Routine"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_script(self, client_with_mock, mock_httpx_client):
|
|
"""Test running a script."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.run_script("script.good_morning")
|
|
|
|
assert result.success is True
|
|
assert result.action == "run"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestAutomations:
|
|
"""Tests for automation methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_automations(self, client_with_mock, mock_httpx_client):
|
|
"""Test listing automations."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"automations": [
|
|
{
|
|
"entity_id": "automation.morning_lights",
|
|
"name": "Morning Lights",
|
|
"state": "on",
|
|
},
|
|
{
|
|
"entity_id": "automation.vacation_mode",
|
|
"name": "Vacation Mode",
|
|
"state": "off",
|
|
},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
automations = await client_with_mock.list_automations()
|
|
|
|
assert len(automations) == 2
|
|
assert isinstance(automations[0], Automation)
|
|
assert automations[0].state == "on"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_toggle_automation_enable(self, client_with_mock, mock_httpx_client):
|
|
"""Test enabling an automation."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.toggle_automation(
|
|
"automation.vacation_mode",
|
|
enable=True,
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.action == "enable"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_toggle_automation_disable(self, client_with_mock, mock_httpx_client):
|
|
"""Test disabling an automation."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"success": True}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.post.return_value = mock_response
|
|
|
|
result = await client_with_mock.toggle_automation(
|
|
"automation.morning_lights",
|
|
enable=False,
|
|
)
|
|
|
|
assert result.action == "disable"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestHistory:
|
|
"""Tests for history methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_history(self, client_with_mock, mock_httpx_client):
|
|
"""Test getting device history."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"history": [
|
|
{
|
|
"state": "on",
|
|
"timestamp": "2024-01-15T08:00:00Z",
|
|
"attributes": {"brightness": 255},
|
|
},
|
|
{
|
|
"state": "off",
|
|
"timestamp": "2024-01-15T10:30:00Z",
|
|
"attributes": {},
|
|
},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
history = await client_with_mock.get_history("light.living_room")
|
|
|
|
assert len(history) == 2
|
|
assert isinstance(history[0], HistoryEntry)
|
|
assert history[0].state == "on"
|
|
assert history[1].state == "off"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestHealthCheck:
|
|
"""Tests for health check."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_healthy(self, client_with_mock, mock_httpx_client):
|
|
"""Test health check returns true when healthy."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_httpx_client.get.return_value = mock_response
|
|
|
|
result = await client_with_mock.health_check()
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_check_unhealthy(self, client_with_mock, mock_httpx_client):
|
|
"""Test health check returns false on error."""
|
|
mock_httpx_client.get.side_effect = httpx.ConnectError("Connection refused")
|
|
|
|
result = await client_with_mock.health_check()
|
|
|
|
assert result is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestResponseModels:
|
|
"""Tests for response model validation."""
|
|
|
|
def test_device_model(self):
|
|
"""Test Device model."""
|
|
device = Device(
|
|
entity_id="light.test",
|
|
name="Test Light",
|
|
state="on",
|
|
domain="light",
|
|
area="bedroom",
|
|
attributes={"brightness": 255},
|
|
)
|
|
|
|
assert device.entity_id == "light.test"
|
|
assert device.state == "on"
|
|
assert device.attributes["brightness"] == 255
|
|
|
|
def test_device_model_optional_fields(self):
|
|
"""Test Device with minimal fields."""
|
|
device = Device(
|
|
entity_id="switch.test",
|
|
name="Test Switch",
|
|
state="off",
|
|
domain="switch",
|
|
)
|
|
|
|
assert device.area is None
|
|
assert device.attributes == {}
|
|
|
|
def test_area_model(self):
|
|
"""Test Area model."""
|
|
area = Area(
|
|
area_id="living_room",
|
|
name="Living Room",
|
|
device_count=5,
|
|
)
|
|
|
|
assert area.area_id == "living_room"
|
|
assert area.name == "Living Room"
|
|
assert area.device_count == 5
|
|
|
|
def test_area_model_defaults(self):
|
|
"""Test Area with default device_count."""
|
|
area = Area(
|
|
area_id="bedroom",
|
|
name="Bedroom",
|
|
)
|
|
|
|
assert area.device_count == 0
|
|
|
|
def test_control_result_model(self):
|
|
"""Test ControlResult model."""
|
|
result = ControlResult(
|
|
success=True,
|
|
entity_id="light.test",
|
|
action="turn_on",
|
|
message="Success",
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.action == "turn_on"
|
|
|
|
def test_history_entry_model(self):
|
|
"""Test HistoryEntry model."""
|
|
entry = HistoryEntry(
|
|
state="on",
|
|
timestamp="2024-01-15T10:00:00Z",
|
|
attributes={"brightness": 200},
|
|
)
|
|
|
|
assert entry.state == "on"
|
|
assert entry.attributes["brightness"] == 200
|