homelab-codex-ws/services/ha-diag-agent/tests/integration/test_updates_available_integration.py

170 lines
5.4 KiB
Python
Raw Normal View History

feat(ha-diag-agent): three REST diagnostic checks + Phase 3 flag fixes New checks: - SystemHealthCheck (15min interval): detects newly-failing HA integrations via /api/system_health snapshot diff; transition-based dedup (ok→error fires, sustained error silent, error→ok clears alert) - UpdatesAvailableCheck (daily cron 09:00): per-update ha_update_available events with 7-day dedup; release notes truncated at 2000 chars - UpdatesDigestCheck (Sunday cron 09:00): single digest event with all pending updates; weekly ISO-week dedup, independent of daily dedup key - AutomationFailuresCheck (30min interval): detects automations with N consecutive failures (default 3) via /api/trace/automation/<id>; 6h cooldown per automation Phase 3 flag fixes: - Flag #1 (since field): UnavailableEntitiesCheck now uses min(state.last_changed, baseline.first_seen) as effective "since", giving accurate duration when agent was offline at entity's first fail - Flag #3 (registry cache): HAClient.get_entity_registry() caches response in-process with configurable TTL (default 300s); avoids repeated API calls across concurrent check cycles; invalidate_registry_cache() for manual invalidation Storage: system_health_snapshot table (component, last_status, last_seen_at, payload) created automatically on next Storage.open() call Config additions (all with defaults): entity_registry_cache_ttl=300, system_health_check_interval=900, automation_check_interval=1800, automation_failure_threshold=3, updates_check_hour=9, updates_check_minute=0, updates_cooldown_days=7 Tests: 95 unit tests pass (49 new), 13 integration tests pass (9 new); 3 skipped (live-HA token not set in CI) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:43:10 +02:00
"""Integration tests for UpdatesAvailableCheck and UpdatesDigestCheck.
Uses real aiosqlite Storage + EventEmitter + mocked HTTP.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from aioresponses import aioresponses
from ha_diag.checks.updates_available import UpdatesAvailableCheck, UpdatesDigestCheck
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",
"alert_cooldown_hours": 0.0,
"updates_cooldown_days": 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
def _update_states(*entity_ids: str) -> list[dict]:
return [
{
"entity_id": eid,
"state": "on",
"attributes": {
"title": eid.split(".")[-1].replace("_", " ").title(),
"installed_version": "1.0.0",
"latest_version": "2.0.0",
"in_progress": False,
"auto_update": False,
},
}
for eid in entity_ids
]
@pytest.mark.integration
async def test_individual_updates_written_to_disk(storage: Storage, events_dir: Path):
"""2 pending updates → 2 event files with correct structure."""
states = _update_states("update.ha_core", "update.mosquitto")
emitter = EventEmitter(events_dir, node_name="piha", location_tag="ken")
with aioresponses() as m:
m.get(f"{HA_URL}/api/states", payload=states)
async with make_session("test-token") as session:
client = HAClient(HA_URL, session)
check = UpdatesAvailableCheck(client, storage, _settings())
results = await check.run()
assert len(results) == 2
for r in results:
assert r.event_type == HAEventType.ha_update_available
emitter.emit(
event_type=r.event_type,
severity=r.severity.value,
service="homeassistant",
message=r.message,
payload=r.payload,
)
files = list(events_dir.glob("*.json"))
assert len(files) == 2
for f in files:
data = json.loads(f.read_text())
assert data["type"] == "ha_update_available"
assert data["payload"]["location_tag"] == "ken"
assert "entity_id" in data["payload"]
@pytest.mark.integration
async def test_digest_writes_single_event_file(storage: Storage, events_dir: Path):
"""Sunday digest → single event file with digest=True payload."""
states = _update_states("update.ha_core", "update.mosquitto", "update.esphome")
emitter = EventEmitter(events_dir, node_name="piha", location_tag="ken")
with aioresponses() as m:
m.get(f"{HA_URL}/api/states", payload=states)
async with make_session("test-token") as session:
client = HAClient(HA_URL, session)
check = UpdatesDigestCheck(client, storage, _settings())
results = await check.run()
assert len(results) == 1
r = results[0]
assert r.payload["digest"] is True
assert r.payload["count"] == 3
emitter.emit(
event_type=r.event_type,
severity=r.severity.value,
service="homeassistant",
message=r.message,
payload=r.payload,
)
files = list(events_dir.glob("*.json"))
assert len(files) == 1
data = json.loads(files[0].read_text())
assert data["payload"]["digest"] is True
assert len(data["payload"]["updates"]) == 3
@pytest.mark.integration
async def test_dedup_across_daily_and_digest_independent(storage: Storage):
"""Daily dedup key doesn't suppress digest, and vice versa."""
states = _update_states("update.ha_core")
settings = _settings(updates_cooldown_days=7)
# Daily check
with aioresponses() as m:
m.get(f"{HA_URL}/api/states", payload=states)
async with make_session("test-token") as session:
r1 = await UpdatesAvailableCheck(
HAClient(HA_URL, session), storage, settings
).run()
assert len(r1) == 1
# Daily again — cooldown active
with aioresponses() as m:
m.get(f"{HA_URL}/api/states", payload=states)
async with make_session("test-token") as session:
r2 = await UpdatesAvailableCheck(
HAClient(HA_URL, session), storage, settings
).run()
assert r2 == []
# Digest — different key, should still fire
with aioresponses() as m:
m.get(f"{HA_URL}/api/states", payload=states)
async with make_session("test-token") as session:
r3 = await UpdatesDigestCheck(
HAClient(HA_URL, session), storage, settings
).run()
assert len(r3) == 1
assert r3[0].payload["digest"] is True