"""Functional integration test for UnavailableEntitiesCheck. Uses aioresponses for HA HTTP (controlled, deterministic) and real aiosqlite + EventEmitter (tests the full agent pipeline end-to-end without a live HA). Marked 'integration' because it exercises the complete multi-component stack. For a live-HA variant, start the ken testenv Docker instances, set TEST_HA_TOKEN, and extend with tests that call real HA endpoints. """ from __future__ import annotations import json import time from pathlib import Path from typing import AsyncGenerator import pytest import pytest_asyncio from aioresponses import aioresponses from ha_diag.checks.unavailable_entities import UnavailableEntitiesCheck from ha_diag.config import Settings from ha_diag.event_emitter import EventEmitter from ha_diag.ha_client import HAClient, make_session from ha_diag.models import HAEventType from ha_diag.storage import Storage HA_URL = "http://ha-test-ken:8123" def _settings(**overrides) -> Settings: defaults: dict = { "ha_url": HA_URL, "ha_token": "test-token", "node_name": "piha", "location_tag": "ken", "unavailable_threshold_hours": 0.0, "integration_failure_threshold_pct": 0.5, "integration_failure_min_entities": 3, "alert_cooldown_hours": 0.0, "check_interval": 60, "check_interval_unavailable": 3600, } defaults.update(overrides) return Settings(**defaults) @pytest_asyncio.fixture async def storage(tmp_path: Path) -> AsyncGenerator[Storage, None]: s = Storage(tmp_path / "integration_test.db") await s.open() yield s await s.close() @pytest.fixture def events_dir(tmp_path: Path) -> Path: d = tmp_path / "events" d.mkdir() return d @pytest.mark.integration async def test_full_pipeline_integration_event(storage: Storage, events_dir: Path): """3/3 zha entities unavailable → ha_integration_failed, 1 event file on disk.""" unavailable_entities = [ {"entity_id": f"light.test_{i}", "state": "unavailable", "attributes": {}} for i in range(3) ] available_entities = [{"entity_id": "sensor.ok", "state": "on", "attributes": {}}] all_states = unavailable_entities + available_entities registry = [ {"entity_id": e["entity_id"], "platform": "zha", "area_id": "living_room"} for e in unavailable_entities ] for e in unavailable_entities: await storage.set_entity_unavailable_since( e["entity_id"], "unavailable", time.time() - 25 * 3600 ) emitter = EventEmitter(events_dir, node_name="piha", location_tag="ken") with aioresponses() as m: m.get(f"{HA_URL}/api/states", payload=all_states) m.get(f"{HA_URL}/api/config/entity_registry", payload=registry) async with make_session("test-token") as session: client = HAClient(HA_URL, session) check = UnavailableEntitiesCheck(client, storage, _settings()) results = await check.run() # 3/3 zha entities (100% >= 50%, count 3 >= 3) → integration event assert len(results) == 1 assert results[0].event_type == HAEventType.ha_integration_failed assert results[0].payload["integration"] == "zha" emitter.emit( event_type=results[0].event_type, severity=results[0].severity.value, service="homeassistant", message=results[0].message, payload=results[0].payload, ) event_files = list(events_dir.glob("*.json")) assert len(event_files) == 1 event_data = json.loads(event_files[0].read_text()) assert event_data["node"] == "piha" assert event_data["payload"]["location_tag"] == "ken" assert event_data["payload"]["integration"] == "zha" assert event_data["type"] == "ha_integration_failed" @pytest.mark.integration async def test_full_pipeline_individual_entity_events( storage: Storage, events_dir: Path ): """2 unavailable entities from different integrations → 2 individual events.""" states = [ {"entity_id": "light.zha_one", "state": "unavailable", "attributes": {}}, {"entity_id": "sensor.mqtt_one", "state": "unavailable", "attributes": {}}, {"entity_id": "switch.ok", "state": "on", "attributes": {}}, ] registry = [ {"entity_id": "light.zha_one", "platform": "zha", "area_id": ""}, {"entity_id": "sensor.mqtt_one", "platform": "mqtt", "area_id": ""}, ] for e in ["light.zha_one", "sensor.mqtt_one"]: await storage.set_entity_unavailable_since(e, "unavailable", time.time() - 25 * 3600) emitter = EventEmitter(events_dir, node_name="piha", location_tag="ken") with aioresponses() as m: m.get(f"{HA_URL}/api/states", payload=states) m.get(f"{HA_URL}/api/config/entity_registry", payload=registry) async with make_session("test-token") as session: client = HAClient(HA_URL, session) check = UnavailableEntitiesCheck(client, storage, _settings()) results = await check.run() # Both integrations have only 1 entity each → below min_entities threshold assert len(results) == 2 assert all(r.event_type == HAEventType.ha_entity_unavailable_long for r in results) for result in results: emitter.emit( event_type=result.event_type, severity=result.severity.value, service="homeassistant", message=result.message, payload=result.payload, ) files = list(events_dir.glob("*.json")) assert len(files) == 2 for f in files: data = json.loads(f.read_text()) assert data["payload"]["location_tag"] == "ken" assert "entity_id" in data["payload"] assert "since" in data["payload"] assert data["payload"]["since"].endswith("Z") @pytest.mark.integration async def test_recovery_removes_tracking(storage: Storage, events_dir: Path): """Entity recovers between check cycles → baseline cleared, no event next cycle.""" eid = "light.recoverable" await storage.set_entity_unavailable_since(eid, "unavailable", time.time() - 25 * 3600) # Cycle 1: entity unavailable → event states_cycle1 = [{"entity_id": eid, "state": "unavailable", "attributes": {}}] with aioresponses() as m: m.get(f"{HA_URL}/api/states", payload=states_cycle1) m.get(f"{HA_URL}/api/config/entity_registry", payload=[]) async with make_session("test-token") as session: client = HAClient(HA_URL, session) check = UnavailableEntitiesCheck(client, storage, _settings()) results1 = await check.run() assert len(results1) == 1 # Cycle 2: entity recovered → no event, baseline cleared states_cycle2 = [{"entity_id": eid, "state": "on", "attributes": {}}] with aioresponses() as m: m.get(f"{HA_URL}/api/states", payload=states_cycle2) m.get(f"{HA_URL}/api/config/entity_registry", payload=[]) async with make_session("test-token") as session: client = HAClient(HA_URL, session) check2 = UnavailableEntitiesCheck(client, storage, _settings()) results2 = await check2.run() assert results2 == [] assert await storage.get_entity_first_unavailable_at(eid) is None