From 9ec43b682954b286620067237b4e081ff7fcfd31 Mon Sep 17 00:00:00 2001 From: Oskar Kapala Date: Wed, 3 Jun 2026 19:19:34 +0200 Subject: [PATCH] fix(ha-diag-agent): structlog event kwarg collision + replace aioresponses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.py: rename event= to ha_event= in _log.warning() — structlog treats 'event' as a reserved positional arg; the old name caused TypeError when any check returned unhealthy results (events were still emitted, but the check was logged as check_error instead of check_unhealthy) - tests/test_ha_client.py: replace aioresponses with unittest.mock — aioresponses 0.7.8 is incompatible with aiohttp >=3.12 (missing stream_writer kwarg) - pyproject.toml: remove aioresponses from dev dependencies Co-Authored-By: Claude Sonnet 4.6 --- services/ha-diag-agent/pyproject.toml | 1 - services/ha-diag-agent/src/ha_diag/main.py | 2 +- .../ha-diag-agent/tests/test_ha_client.py | 126 +++++++++--------- 3 files changed, 65 insertions(+), 64 deletions(-) diff --git a/services/ha-diag-agent/pyproject.toml b/services/ha-diag-agent/pyproject.toml index 7a975f6..e61b0e6 100644 --- a/services/ha-diag-agent/pyproject.toml +++ b/services/ha-diag-agent/pyproject.toml @@ -22,7 +22,6 @@ dependencies = [ dev = [ "pytest>=8.1", "pytest-asyncio>=0.23", - "aioresponses>=0.7", ] [tool.setuptools.packages.find] diff --git a/services/ha-diag-agent/src/ha_diag/main.py b/services/ha-diag-agent/src/ha_diag/main.py index d157258..23b9d53 100644 --- a/services/ha-diag-agent/src/ha_diag/main.py +++ b/services/ha-diag-agent/src/ha_diag/main.py @@ -68,7 +68,7 @@ async def _run_check_and_emit( _log.warning( "check_unhealthy", check=check.name, - event=result.event_type, + ha_event=result.event_type, msg=result.message, ) diff --git a/services/ha-diag-agent/tests/test_ha_client.py b/services/ha-diag-agent/tests/test_ha_client.py index 41c991c..1343565 100644 --- a/services/ha-diag-agent/tests/test_ha_client.py +++ b/services/ha-diag-agent/tests/test_ha_client.py @@ -1,8 +1,9 @@ -"""Tests for HAClient using aioresponses to mock aiohttp.""" +"""Tests for HAClient using unittest.mock to avoid aioresponses/aiohttp version coupling.""" from __future__ import annotations +from unittest.mock import AsyncMock, MagicMock + import pytest -from aioresponses import aioresponses from ha_diag.ha_client import HAClient, make_session @@ -10,34 +11,49 @@ HA_URL = "http://homeassistant.test:8123" TOKEN = "test-token" +def _mock_resp(payload=None, text=None, status=200): + """Return a mock that behaves like an aiohttp response context manager.""" + resp = MagicMock() + resp.status = status + if status >= 400: + resp.raise_for_status.side_effect = Exception(f"HTTP {status}") + else: + resp.raise_for_status = MagicMock() + resp.json = AsyncMock(return_value=payload if payload is not None else {}) + resp.text = AsyncMock(return_value=text or "") + resp.__aenter__ = AsyncMock(return_value=resp) + resp.__aexit__ = AsyncMock(return_value=False) + return resp + + +def _mock_session(get_resp=None): + session = MagicMock() + session.get.return_value = get_resp or _mock_resp() + return session + + @pytest.mark.asyncio async def test_get_api_status_ok(): - with aioresponses() as m: - m.get(f"{HA_URL}/api/", payload={"message": "API running."}) - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session) - result = await client.get_api_status() + session = _mock_session(_mock_resp({"message": "API running."})) + client = HAClient(HA_URL, session) + result = await client.get_api_status() assert result == {"message": "API running."} @pytest.mark.asyncio async def test_get_api_status_unauthorized(): - with aioresponses() as m: - m.get(f"{HA_URL}/api/", status=401) - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session) - with pytest.raises(Exception): - await client.get_api_status() + session = _mock_session(_mock_resp(status=401)) + client = HAClient(HA_URL, session) + with pytest.raises(Exception): + await client.get_api_status() @pytest.mark.asyncio async def test_get_states_returns_list(): payload = [{"entity_id": "light.living_room", "state": "on"}] - with aioresponses() as m: - m.get(f"{HA_URL}/api/states", payload=payload) - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session) - states = await client.get_states() + session = _mock_session(_mock_resp(payload)) + client = HAClient(HA_URL, session) + states = await client.get_states() assert isinstance(states, list) assert states[0]["entity_id"] == "light.living_room" @@ -45,11 +61,9 @@ async def test_get_states_returns_list(): @pytest.mark.asyncio async def test_get_config_returns_dict(): payload = {"version": "2024.1.0", "location_name": "Home"} - with aioresponses() as m: - m.get(f"{HA_URL}/api/config", payload=payload) - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session) - config = await client.get_config() + session = _mock_session(_mock_resp(payload)) + client = HAClient(HA_URL, session) + config = await client.get_config() assert config["version"] == "2024.1.0" @@ -59,11 +73,9 @@ async def test_get_entity_registry_returns_list(): {"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"}, {"entity_id": "sensor.temp", "platform": "mqtt", "area_id": None}, ] - with aioresponses() as m: - m.get(f"{HA_URL}/api/config/entity_registry", payload=payload) - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session) - registry = await client.get_entity_registry() + session = _mock_session(_mock_resp(payload)) + client = HAClient(HA_URL, session) + registry = await client.get_entity_registry() assert len(registry) == 2 assert registry[0]["platform"] == "zha" @@ -71,12 +83,7 @@ async def test_get_entity_registry_returns_list(): @pytest.mark.asyncio async def test_make_session_sets_auth_header(): """make_session injects the Bearer token in all requests.""" - with aioresponses() as m: - m.get(f"{HA_URL}/api/", payload={"message": "API running."}) - async with make_session("my-secret-token") as session: - client = HAClient(HA_URL, session) - await client.get_api_status() - # Verify the Authorization header was sent + async with make_session("my-secret-token") as session: assert session.headers.get("Authorization") == "Bearer my-secret-token" @@ -89,47 +96,42 @@ async def test_make_session_sets_auth_header(): async def test_entity_registry_cached_on_second_call(): """Second call within TTL returns cache, making only one HTTP request.""" payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"}] - with aioresponses() as m: - m.get(f"{HA_URL}/api/config/entity_registry", payload=payload) - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session, entity_registry_cache_ttl=300.0) - r1 = await client.get_entity_registry() - r2 = await client.get_entity_registry() # from cache — no second HTTP call + session = _mock_session(_mock_resp(payload)) + client = HAClient(HA_URL, session, entity_registry_cache_ttl=300.0) + r1 = await client.get_entity_registry() + r2 = await client.get_entity_registry() # from cache assert r1 == r2 - # aioresponses would raise ConnectionError on the unmocked second request - # if caching weren't working; reaching here means it used the cache. + session.get.assert_called_once() # only one HTTP request @pytest.mark.asyncio -async def test_entity_registry_cache_bypassed_after_ttl(monkeypatch): - """After TTL expiry, next call fetches fresh data.""" - import time +async def test_entity_registry_cache_bypassed_after_ttl(): + """After TTL expiry (ttl=0), next call fetches fresh data.""" payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": "hallway"}] - with aioresponses() as m: - m.get(f"{HA_URL}/api/config/entity_registry", payload=payload) - m.get(f"{HA_URL}/api/config/entity_registry", payload=payload) - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session, entity_registry_cache_ttl=0.0) - await client.get_entity_registry() # fetches - await client.get_entity_registry() # TTL=0 → fetches again + session = _mock_session(_mock_resp(payload)) + # TTL=0 means every call is stale → two fetches + session.get.side_effect = [_mock_resp(payload), _mock_resp(payload)] + client = HAClient(HA_URL, session, entity_registry_cache_ttl=0.0) + await client.get_entity_registry() + await client.get_entity_registry() + assert session.get.call_count == 2 @pytest.mark.asyncio async def test_invalidate_registry_cache_forces_refetch(): """invalidate_registry_cache() makes the next call hit the network.""" payload = [{"entity_id": "light.hall", "platform": "zha", "area_id": ""}] - with aioresponses() as m: - m.get(f"{HA_URL}/api/config/entity_registry", payload=payload) - m.get(f"{HA_URL}/api/config/entity_registry", payload=payload) - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session, entity_registry_cache_ttl=300.0) - await client.get_entity_registry() - client.invalidate_registry_cache() - await client.get_entity_registry() # must hit network again + session = _mock_session(_mock_resp(payload)) + session.get.side_effect = [_mock_resp(payload), _mock_resp(payload)] + client = HAClient(HA_URL, session, entity_registry_cache_ttl=300.0) + await client.get_entity_registry() + client.invalidate_registry_cache() + await client.get_entity_registry() + assert session.get.call_count == 2 @pytest.mark.asyncio async def test_entity_registry_cache_default_ttl_is_300(): - async with make_session(TOKEN) as session: - client = HAClient(HA_URL, session) + session = _mock_session() + client = HAClient(HA_URL, session) assert client._registry_cache_ttl == 300.0