"""Unit tests for UpdatesAvailableCheck and UpdatesDigestCheck.""" from __future__ import annotations from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from ha_diag.checks.updates_available import ( UpdatesAvailableCheck, UpdatesDigestCheck, _build_update_payload, ) from ha_diag.config import Settings from ha_diag.models import HAEventType from ha_diag.storage import Storage # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_settings(**overrides) -> Settings: defaults: dict = { "ha_url": "http://test.local:8123", "ha_token": "test", "node_name": "test-node", "location_tag": "test-loc", "alert_cooldown_hours": 0.0, "updates_cooldown_days": 0, # no dedup in most tests "check_interval": 60, "check_interval_unavailable": 3600, } defaults.update(overrides) return Settings(**defaults) def _make_client(states=None, error=None): client = MagicMock() if error: client.get_states = AsyncMock(side_effect=error) else: client.get_states = AsyncMock(return_value=states or []) return client def _update_state( entity_id: str = "update.homeassistant_core", state: str = "on", title: str = "Home Assistant Core", installed: str = "2025.5.0", latest: str = "2025.6.0", release_summary: str | None = None, release_url: str | None = None, ) -> dict: attrs: dict = { "title": title, "installed_version": installed, "latest_version": latest, "in_progress": False, "auto_update": False, } if release_summary: attrs["release_summary"] = release_summary if release_url: attrs["release_url"] = release_url return {"entity_id": entity_id, "state": state, "attributes": attrs} # --------------------------------------------------------------------------- # _build_update_payload helper # --------------------------------------------------------------------------- def test_build_update_payload_basic(): attrs = {"title": "HA Core", "installed_version": "1.0", "latest_version": "2.0"} p = _build_update_payload("update.ha_core", attrs) assert p["entity_id"] == "update.ha_core" assert p["title"] == "HA Core" assert p["installed_version"] == "1.0" assert p["latest_version"] == "2.0" def test_build_update_payload_release_summary_truncated(): long_notes = "x" * 3000 attrs = {"release_summary": long_notes} p = _build_update_payload("update.ha_core", attrs) assert len(p["release_summary"]) == 2000 def test_build_update_payload_release_url_omitted_when_absent(): p = _build_update_payload("update.ha_core", {}) assert "release_url" not in p def test_build_update_payload_release_url_included_when_present(): attrs = {"release_url": "https://github.com/..."} p = _build_update_payload("update.x", attrs) assert p["release_url"] == "https://github.com/..." # --------------------------------------------------------------------------- # UpdatesAvailableCheck (daily individual events) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_no_updates_returns_empty(storage: Storage): states = [{"entity_id": "light.living_room", "state": "on", "attributes": {}}] check = UpdatesAvailableCheck(_make_client(states), storage, _make_settings()) assert await check.run() == [] @pytest.mark.asyncio async def test_update_off_state_not_emitted(storage: Storage): states = [_update_state(state="off")] check = UpdatesAvailableCheck(_make_client(states), storage, _make_settings()) assert await check.run() == [] @pytest.mark.asyncio async def test_single_update_emits_event(storage: Storage): states = [_update_state()] check = UpdatesAvailableCheck(_make_client(states), storage, _make_settings()) results = await check.run() assert len(results) == 1 assert results[0].event_type == HAEventType.ha_update_available assert "2025.5.0" in results[0].message assert "2025.6.0" in results[0].message @pytest.mark.asyncio async def test_multiple_updates_emit_multiple_events(storage: Storage): states = [ _update_state("update.ha_core"), _update_state("update.mosquitto", title="Mosquitto"), ] check = UpdatesAvailableCheck(_make_client(states), storage, _make_settings()) results = await check.run() assert len(results) == 2 assert all(r.event_type == HAEventType.ha_update_available for r in results) @pytest.mark.asyncio async def test_cooldown_prevents_same_update_next_day(storage: Storage): states = [_update_state()] settings = _make_settings(updates_cooldown_days=7) check = UpdatesAvailableCheck(_make_client(states), storage, settings) r1 = await check.run() r2 = await check.run() assert len(r1) == 1 assert r2 == [] @pytest.mark.asyncio async def test_no_cooldown_allows_repeat(storage: Storage): states = [_update_state()] check = UpdatesAvailableCheck(_make_client(states), storage, _make_settings(updates_cooldown_days=0)) r1 = await check.run() r2 = await check.run() assert len(r1) == 1 assert len(r2) == 1 @pytest.mark.asyncio async def test_payload_contains_version_fields(storage: Storage): states = [_update_state(installed="2025.5.0", latest="2025.6.0")] check = UpdatesAvailableCheck(_make_client(states), storage, _make_settings()) results = await check.run() p = results[0].payload assert p["installed_version"] == "2025.5.0" assert p["latest_version"] == "2025.6.0" assert p["in_progress"] is False @pytest.mark.asyncio async def test_ha_error_returns_empty(storage: Storage): check = UpdatesAvailableCheck( _make_client(error=ConnectionError("HA down")), storage, _make_settings() ) assert await check.run() == [] # --------------------------------------------------------------------------- # UpdatesDigestCheck (Sunday digest) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_digest_no_updates_returns_empty(storage: Storage): check = UpdatesDigestCheck(_make_client([]), storage, _make_settings()) assert await check.run() == [] @pytest.mark.asyncio async def test_digest_emits_single_event_for_all_updates(storage: Storage): states = [ _update_state("update.ha_core"), _update_state("update.mosquitto", title="Mosquitto"), _update_state("update.esphome", title="ESPHome"), ] check = UpdatesDigestCheck(_make_client(states), storage, _make_settings()) results = await check.run() assert len(results) == 1 p = results[0].payload assert p["digest"] is True assert p["count"] == 3 assert len(p["updates"]) == 3 @pytest.mark.asyncio async def test_digest_payload_has_digest_true(storage: Storage): states = [_update_state()] check = UpdatesDigestCheck(_make_client(states), storage, _make_settings()) results = await check.run() assert results[0].payload["digest"] is True @pytest.mark.asyncio async def test_digest_weekly_dedup_prevents_same_week_refiring(storage: Storage): states = [_update_state()] check = UpdatesDigestCheck(_make_client(states), storage, _make_settings()) r1 = await check.run() r2 = await check.run() assert len(r1) == 1 assert r2 == [] @pytest.mark.asyncio async def test_digest_fires_independently_of_daily_dedup(storage: Storage): """Daily cooldown on entity X doesn't suppress Sunday digest.""" states = [_update_state()] settings = _make_settings(updates_cooldown_days=7) # Daily check marks alert_key="update_available:update.homeassistant_core" daily = UpdatesAvailableCheck(_make_client(states), storage, settings) await daily.run() # Digest uses different key "update_digest:{week}" — should still fire digest = UpdatesDigestCheck(_make_client(states), storage, settings) r = await digest.run() assert len(r) == 1 assert r[0].payload["digest"] is True @pytest.mark.asyncio async def test_digest_name_is_updates_digest(storage: Storage): check = UpdatesDigestCheck(_make_client([]), storage, _make_settings()) assert check.name == "updates_digest" @pytest.mark.asyncio async def test_daily_check_name_is_updates_available(storage: Storage): check = UpdatesAvailableCheck(_make_client([]), storage, _make_settings()) assert check.name == "updates_available"