898 lines
33 KiB
Python
898 lines
33 KiB
Python
"""Coverage tests for the health coordinator and health diagnostic sensors.
|
|
|
|
These tests exercise the runtime health model end to end without requiring real
|
|
network access. The add-on reachability check in `_async_update_data` is the only
|
|
network-bound path; it is covered by monkeypatching the aiohttp session factory
|
|
and the network helpers (`async_get_source_ip`, `get_url`).
|
|
|
|
Architecture notes (see CRITICAL ARCHITECTURE NOTES in the task brief):
|
|
- `HealthCoordinator(hass, config)` forwards `config_entry=config` to
|
|
`DataUpdateCoordinator.__init__`, which calls `config.async_on_unload(...)`.
|
|
A `MockConfigEntry` supports that, so we always pass one.
|
|
- Health persistence writes `config.runtime_data.health_data`; we set
|
|
`entry.runtime_data` to a real `SWSRuntimeData` to cover the success path and
|
|
also test the `AttributeError` branch when it is missing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import aiohttp
|
|
from aiohttp import ClientConnectionError
|
|
from aiohttp.web_exceptions import HTTPUnauthorized
|
|
import pytest
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from custom_components.sws12500 import health_coordinator as hc, health_sensor as hs
|
|
from custom_components.sws12500.const import (
|
|
API_ID,
|
|
API_KEY,
|
|
DEFAULT_URL,
|
|
DOMAIN,
|
|
ECOWITT_ENABLED,
|
|
ECOWITT_URL_PREFIX,
|
|
HEALTH_URL,
|
|
LEGACY_ENABLED,
|
|
POCASI_CZ_ENABLED,
|
|
WINDY_ENABLED,
|
|
WSLINK,
|
|
WSLINK_ADDON_PORT,
|
|
WSLINK_URL,
|
|
)
|
|
from custom_components.sws12500.data import SWSRuntimeData
|
|
from custom_components.sws12500.health_coordinator import HealthCoordinator
|
|
from custom_components.sws12500.routes import Routes
|
|
from homeassistant.components.http import KEY_AUTHENTICATED
|
|
from homeassistant.config_entries import ConfigEntryState
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers / fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _ProbeRouterStub:
|
|
"""Minimal aiohttp router stub for the end-to-end setup test."""
|
|
|
|
def add_get(self, path: str, handler: Any, **_kwargs: Any) -> Any:
|
|
return SimpleNamespace(method="GET")
|
|
|
|
def add_post(self, path: str, handler: Any, **_kwargs: Any) -> Any:
|
|
return SimpleNamespace(method="POST")
|
|
|
|
|
|
def _make_entry(options: dict[str, Any] | None = None) -> MockConfigEntry:
|
|
"""Create a config entry usable by the coordinator constructor."""
|
|
return MockConfigEntry(domain=DOMAIN, data={}, options=options or {})
|
|
|
|
|
|
@pytest.fixture
|
|
def entry() -> MockConfigEntry:
|
|
"""A config entry with empty options."""
|
|
return _make_entry()
|
|
|
|
|
|
def _attach_runtime_data(entry: MockConfigEntry, coordinator: HealthCoordinator) -> None:
|
|
"""Attach a real SWSRuntimeData so the persistence success path is exercised."""
|
|
entry.runtime_data = SWSRuntimeData(
|
|
coordinator=MagicMock(),
|
|
health_coordinator=coordinator,
|
|
last_options={},
|
|
)
|
|
|
|
|
|
class _FakeResponse:
|
|
"""Minimal aiohttp-like response."""
|
|
|
|
def __init__(self, status: int, json_data: Any = None, json_exc: Exception | None = None) -> None:
|
|
self.status = status
|
|
self._json_data = json_data
|
|
self._json_exc = json_exc
|
|
|
|
async def json(self, content_type: Any = None) -> Any: # noqa: ARG002 - signature match
|
|
if self._json_exc is not None:
|
|
raise self._json_exc
|
|
return self._json_data
|
|
|
|
|
|
class _FakeSession:
|
|
"""Fake aiohttp session whose `.get()` returns an async context manager.
|
|
|
|
`responses` maps a URL substring to either a `_FakeResponse` or an Exception
|
|
that should be raised when entering the context manager.
|
|
"""
|
|
|
|
def __init__(self, responses: dict[str, Any]) -> None:
|
|
self._responses = responses
|
|
|
|
def get(self, url: str): # noqa: D401 - mimic aiohttp
|
|
outcome: Any = None
|
|
for needle, value in self._responses.items():
|
|
if needle in url:
|
|
outcome = value
|
|
break
|
|
|
|
@asynccontextmanager
|
|
async def _ctx():
|
|
if isinstance(outcome, Exception):
|
|
raise outcome
|
|
yield outcome
|
|
|
|
return _ctx()
|
|
|
|
|
|
def _patch_network(monkeypatch, session: _FakeSession, ip: str = "1.2.3.4") -> None:
|
|
"""Patch the coordinator network helpers to deterministic values."""
|
|
monkeypatch.setattr(hc, "async_get_clientsession", lambda _hass, _verify=False: session)
|
|
monkeypatch.setattr(hc, "async_get_source_ip", AsyncMock(return_value=ip))
|
|
monkeypatch.setattr(hc, "get_url", lambda _hass: "http://ha:8123")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level helpers in health_coordinator.py
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_configured_protocol() -> None:
|
|
# Legacy enabled (default) -> wu / wslink based on the WSLINK flag.
|
|
assert hc._configured_protocol(_make_entry()) == "wu"
|
|
assert hc._configured_protocol(_make_entry({WSLINK: True})) == "wslink"
|
|
# Ecowitt-only (legacy off, ecowitt on) -> ecowitt.
|
|
assert hc._configured_protocol(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: True})) == "ecowitt"
|
|
# Nothing configured -> wu fallback.
|
|
assert hc._configured_protocol(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: False})) == "wu"
|
|
|
|
|
|
def test_protocol_from_path_all_branches() -> None:
|
|
assert hc._protocol_from_path(WSLINK_URL) == "wslink"
|
|
assert hc._protocol_from_path(DEFAULT_URL) == "wu"
|
|
assert hc._protocol_from_path(HEALTH_URL) == "health"
|
|
assert hc._protocol_from_path(ECOWITT_URL_PREFIX + "/abc") == "ecowitt"
|
|
assert hc._protocol_from_path("/something/else") == "unknown"
|
|
|
|
|
|
def test_empty_forwarding_state() -> None:
|
|
enabled = hc._empty_forwarding_state(True)
|
|
assert enabled == {
|
|
"enabled": True,
|
|
"last_status": "idle",
|
|
"last_error": None,
|
|
"last_attempt_at": None,
|
|
}
|
|
disabled = hc._empty_forwarding_state(False)
|
|
assert disabled["enabled"] is False
|
|
assert disabled["last_status"] == "disabled"
|
|
|
|
|
|
def test_default_health_data() -> None:
|
|
entry = _make_entry({WSLINK: True, WINDY_ENABLED: True, POCASI_CZ_ENABLED: False})
|
|
data = hc._default_health_data(entry)
|
|
|
|
assert data["configured_protocol"] == "wslink"
|
|
assert data["active_protocol"] == "wslink"
|
|
assert data["integration_status"] == "online_wslink"
|
|
assert data["addon"]["online"] is False
|
|
assert data["addon"]["paths"] == {"wslink": WSLINK_URL, "wu": DEFAULT_URL}
|
|
assert data["forwarding"]["windy"]["enabled"] is True
|
|
assert data["forwarding"]["pocasi"]["enabled"] is False
|
|
assert data["last_ingress"]["reason"] == "no_data"
|
|
|
|
|
|
def test_default_health_data_wu_default() -> None:
|
|
data = hc._default_health_data(_make_entry())
|
|
assert data["configured_protocol"] == "wu"
|
|
assert data["integration_status"] == "online_wu"
|
|
|
|
|
|
def test_default_health_data_ecowitt() -> None:
|
|
data = hc._default_health_data(_make_entry({LEGACY_ENABLED: False, ECOWITT_ENABLED: True}))
|
|
assert data["configured_protocol"] == "ecowitt"
|
|
assert data["active_protocol"] == "ecowitt"
|
|
assert data["integration_status"] == "online_ecowitt"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HealthCoordinator construction & persistence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_store_runtime_health_success(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
payload = {"hello": "world"}
|
|
coordinator._store_runtime_health(payload)
|
|
|
|
assert entry.runtime_data.health_data == payload
|
|
# deepcopy: stored value is independent of the source dict
|
|
assert entry.runtime_data.health_data is not payload
|
|
|
|
|
|
def test_store_runtime_health_missing_runtime_data(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
# entry.runtime_data is unset -> AttributeError branch must be swallowed.
|
|
assert getattr(entry, "runtime_data", None) is None
|
|
coordinator._store_runtime_health({"a": 1}) # must not raise
|
|
|
|
|
|
def test_commit_publishes_and_persists(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
new_data = hc._default_health_data(entry)
|
|
new_data["integration_status"] = "custom"
|
|
|
|
result = coordinator._commit(new_data)
|
|
|
|
assert result is new_data
|
|
assert coordinator.data["integration_status"] == "custom"
|
|
assert entry.runtime_data.health_data["integration_status"] == "custom"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _refresh_summary branches
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_refresh_summary_degraded_by_reason(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
data = hc._default_health_data(entry)
|
|
data["configured_protocol"] = "wu"
|
|
data["last_ingress"] = {"protocol": "wu", "accepted": False, "reason": "route_disabled"}
|
|
|
|
coordinator._refresh_summary(data)
|
|
|
|
assert data["integration_status"] == "degraded"
|
|
# not accepted -> active falls back to configured protocol
|
|
assert data["active_protocol"] == "wu"
|
|
|
|
|
|
def test_refresh_summary_degraded_by_protocol_mismatch(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
data = hc._default_health_data(entry)
|
|
data["configured_protocol"] = "wu"
|
|
# last protocol differs from configured -> degraded even when accepted
|
|
data["last_ingress"] = {"protocol": "wslink", "accepted": True, "reason": "accepted"}
|
|
|
|
coordinator._refresh_summary(data)
|
|
|
|
assert data["integration_status"] == "degraded"
|
|
# accepted + recognized protocol -> active protocol tracks the ingress
|
|
assert data["active_protocol"] == "wslink"
|
|
|
|
|
|
def test_refresh_summary_online_protocol(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
data = hc._default_health_data(entry)
|
|
data["configured_protocol"] = "wslink"
|
|
data["last_ingress"] = {"protocol": "wslink", "accepted": True, "reason": "accepted"}
|
|
|
|
coordinator._refresh_summary(data)
|
|
|
|
assert data["integration_status"] == "online_wslink"
|
|
assert data["active_protocol"] == "wslink"
|
|
|
|
|
|
def test_refresh_summary_online_idle(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
data = hc._default_health_data(entry)
|
|
data["configured_protocol"] = "wu"
|
|
data["last_ingress"] = {"protocol": "unknown", "accepted": False, "reason": "no_data"}
|
|
|
|
coordinator._refresh_summary(data)
|
|
|
|
assert data["integration_status"] == "online_idle"
|
|
assert data["active_protocol"] == "wu"
|
|
|
|
|
|
def test_refresh_summary_online_ecowitt(hass, entry) -> None:
|
|
# Ecowitt ingress is a valid protocol: active_protocol must track it (not fall back
|
|
# to the legacy "wu"), and it must not be flagged as a WU/WSLink mismatch.
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
data = hc._default_health_data(entry)
|
|
data["configured_protocol"] = "wu"
|
|
data["last_ingress"] = {"protocol": "ecowitt", "accepted": True, "reason": "accepted"}
|
|
|
|
coordinator._refresh_summary(data)
|
|
|
|
assert data["integration_status"] == "online_ecowitt"
|
|
assert data["active_protocol"] == "ecowitt"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _async_update_data: offline and online paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_async_update_data_addon_offline(hass, monkeypatch) -> None:
|
|
entry = _make_entry({WSLINK_ADDON_PORT: 8443})
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
session = _FakeSession({"/healthz": ClientConnectionError("boom")})
|
|
_patch_network(monkeypatch, session)
|
|
|
|
data = await coordinator._async_update_data()
|
|
|
|
addon = data["addon"]
|
|
assert addon["online"] is False
|
|
assert addon["health_url"] == "https://1.2.3.4:8443/healthz"
|
|
assert addon["info_url"] == "https://1.2.3.4:8443/status/internal"
|
|
assert addon["home_assistant_url"] == "http://ha:8123"
|
|
assert addon["home_assistant_source_ip"] == "1.2.3.4"
|
|
assert addon["raw_status"] is None
|
|
assert addon["name"] is None
|
|
|
|
|
|
async def test_async_update_data_addon_online(hass, monkeypatch) -> None:
|
|
entry = _make_entry() # no WSLINK_ADDON_PORT -> default 443
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
raw_status = {
|
|
"addon": "wslink-addon",
|
|
"version": "1.2.3",
|
|
"listen": {"port": 8443, "tls": True},
|
|
"upstream": {"ha_port": 8123},
|
|
"paths": {"wslink": "/custom/wslink", "wu": "/custom/wu"},
|
|
}
|
|
session = _FakeSession(
|
|
{
|
|
"/healthz": _FakeResponse(200),
|
|
"/status/internal": _FakeResponse(200, json_data=raw_status),
|
|
}
|
|
)
|
|
_patch_network(monkeypatch, session)
|
|
|
|
data = await coordinator._async_update_data()
|
|
|
|
addon = data["addon"]
|
|
assert addon["online"] is True
|
|
assert addon["health_url"] == "https://1.2.3.4:443/healthz"
|
|
assert addon["name"] == "wslink-addon"
|
|
assert addon["version"] == "1.2.3"
|
|
assert addon["listen_port"] == 8443
|
|
assert addon["tls"] is True
|
|
assert addon["upstream_ha_port"] == 8123
|
|
assert addon["paths"] == {"wslink": "/custom/wslink", "wu": "/custom/wu"}
|
|
assert addon["raw_status"] == raw_status
|
|
|
|
|
|
async def test_async_update_data_info_endpoint_value_error(hass, monkeypatch) -> None:
|
|
"""Online add-on but the info endpoint returns invalid JSON."""
|
|
entry = _make_entry()
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
info_resp = _FakeResponse(200, json_exc=ValueError("bad json"))
|
|
session = _FakeSession(
|
|
{
|
|
"/healthz": _FakeResponse(200),
|
|
"/status/internal": info_resp,
|
|
}
|
|
)
|
|
_patch_network(monkeypatch, session)
|
|
|
|
data = await coordinator._async_update_data()
|
|
|
|
addon = data["addon"]
|
|
assert addon["online"] is True
|
|
assert addon["raw_status"] is None
|
|
# raw_status falsy -> add-on metadata stays at defaults
|
|
assert addon["name"] is None
|
|
assert addon["version"] is None
|
|
|
|
|
|
async def test_async_update_data_info_non_200(hass, monkeypatch) -> None:
|
|
"""Online add-on but info endpoint replies non-200 -> raw_status stays None."""
|
|
entry = _make_entry()
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
session = _FakeSession(
|
|
{
|
|
"/healthz": _FakeResponse(200),
|
|
"/status/internal": _FakeResponse(503),
|
|
}
|
|
)
|
|
_patch_network(monkeypatch, session)
|
|
|
|
data = await coordinator._async_update_data()
|
|
assert data["addon"]["online"] is True
|
|
assert data["addon"]["raw_status"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_update_routing_none(hass) -> None:
|
|
entry = _make_entry({WSLINK: True})
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
coordinator.update_routing(None)
|
|
|
|
assert coordinator.data["configured_protocol"] == "wslink"
|
|
# routes block unchanged from default when None passed
|
|
assert coordinator.data["routes"]["wu_enabled"] is False
|
|
|
|
|
|
def test_update_routing_with_routes(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
routes = Routes()
|
|
coordinator.update_routing(routes)
|
|
|
|
routes_block = coordinator.data["routes"]
|
|
assert routes_block["wu_enabled"] is False
|
|
assert routes_block["wslink_enabled"] is False
|
|
assert routes_block["health_enabled"] is False
|
|
assert routes_block["snapshot"] == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# record_dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_record_dispatch_skips_health(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
before = coordinator.data["last_ingress"].copy()
|
|
|
|
request = SimpleNamespace(path=HEALTH_URL, method="GET")
|
|
coordinator.record_dispatch(request, route_enabled=True, reason=None)
|
|
|
|
# health path is ignored -> last_ingress untouched
|
|
assert coordinator.data["last_ingress"] == before
|
|
|
|
|
|
def test_record_dispatch_records(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
request = SimpleNamespace(path=WSLINK_URL, method="POST")
|
|
coordinator.record_dispatch(request, route_enabled=True, reason=None)
|
|
|
|
ingress = coordinator.data["last_ingress"]
|
|
assert ingress["protocol"] == "wslink"
|
|
assert ingress["path"] == WSLINK_URL
|
|
assert ingress["method"] == "POST"
|
|
assert ingress["route_enabled"] is True
|
|
assert ingress["accepted"] is False
|
|
assert ingress["reason"] == "pending"
|
|
assert ingress["time"] is not None
|
|
|
|
|
|
def test_record_dispatch_records_with_reason(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
|
|
coordinator.record_dispatch(request, route_enabled=False, reason="route_disabled")
|
|
|
|
ingress = coordinator.data["last_ingress"]
|
|
assert ingress["reason"] == "route_disabled"
|
|
# route_disabled reason makes the summary degraded
|
|
assert coordinator.data["integration_status"] == "degraded"
|
|
|
|
|
|
def test_record_dispatch_masks_ecowitt_webhook_id(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
request = SimpleNamespace(path=ECOWITT_URL_PREFIX + "/supersecretid", method="POST")
|
|
coordinator.record_dispatch(request, route_enabled=True, reason=None)
|
|
|
|
ingress = coordinator.data["last_ingress"]
|
|
assert ingress["protocol"] == "ecowitt"
|
|
# The secret webhook id must never reach the (potentially exposed) snapshot.
|
|
assert ingress["path"] == ECOWITT_URL_PREFIX + "/***"
|
|
assert "supersecretid" not in ingress["path"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_ingress_result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_update_ingress_result_accepted(hass) -> None:
|
|
entry = _make_entry({WSLINK: True})
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
request = SimpleNamespace(path=WSLINK_URL, method="POST")
|
|
coordinator.update_ingress_result(request, accepted=True, authorized=True)
|
|
|
|
ingress = coordinator.data["last_ingress"]
|
|
assert ingress["accepted"] is True
|
|
assert ingress["authorized"] is True
|
|
assert ingress["reason"] == "accepted"
|
|
assert ingress["protocol"] == "wslink"
|
|
assert coordinator.data["integration_status"] == "online_wslink"
|
|
|
|
|
|
def test_update_ingress_result_rejected_default_reason(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
|
|
coordinator.update_ingress_result(request, accepted=False, authorized=False)
|
|
|
|
ingress = coordinator.data["last_ingress"]
|
|
assert ingress["accepted"] is False
|
|
assert ingress["reason"] == "rejected"
|
|
|
|
|
|
def test_update_ingress_result_explicit_reason(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
request = SimpleNamespace(path=DEFAULT_URL, method="GET")
|
|
coordinator.update_ingress_result(request, accepted=False, authorized=None, reason="unauthorized")
|
|
|
|
assert coordinator.data["last_ingress"]["reason"] == "unauthorized"
|
|
assert coordinator.data["integration_status"] == "degraded"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_forwarding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_update_forwarding(hass, entry) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
windy = SimpleNamespace(enabled=True, last_status="ok", last_error=None, last_attempt_at="2026-06-20T10:00:00")
|
|
pocasi = SimpleNamespace(enabled=False, last_status="disabled", last_error="oops", last_attempt_at=None)
|
|
|
|
coordinator.update_forwarding(windy, pocasi)
|
|
|
|
forwarding = coordinator.data["forwarding"]
|
|
assert forwarding["windy"] == {
|
|
"enabled": True,
|
|
"last_status": "ok",
|
|
"last_error": None,
|
|
"last_attempt_at": "2026-06-20T10:00:00",
|
|
}
|
|
assert forwarding["pocasi"]["last_error"] == "oops"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# health_status HTTP endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_health_status_endpoint_authenticated(hass, entry, monkeypatch) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
# Avoid network: stub the refresh that health_status awaits.
|
|
monkeypatch.setattr(coordinator, "async_request_refresh", AsyncMock(return_value=None))
|
|
|
|
# aiohttp Request is dict-like; health_status only reads KEY_AUTHENTICATED.
|
|
request = {KEY_AUTHENTICATED: True}
|
|
response = await coordinator.health_status(request) # type: ignore[arg-type]
|
|
|
|
assert isinstance(response, aiohttp.web.Response)
|
|
assert response.status == 200
|
|
coordinator.async_request_refresh.assert_awaited_once()
|
|
|
|
|
|
async def test_health_status_endpoint_rejects_unauthenticated(hass, entry, monkeypatch) -> None:
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
refresh = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(coordinator, "async_request_refresh", refresh)
|
|
|
|
# No KEY_AUTHENTICATED flag -> unauthenticated -> 401, no refresh triggered.
|
|
with pytest.raises(HTTPUnauthorized):
|
|
await coordinator.health_status({}) # type: ignore[arg-type]
|
|
|
|
refresh.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# health_sensor.py module helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_path_nested() -> None:
|
|
data = {"addon": {"online": True}}
|
|
assert hs._resolve_path(data, ("addon", "online")) is True
|
|
|
|
|
|
def test_resolve_path_missing_key() -> None:
|
|
data = {"addon": {}}
|
|
assert hs._resolve_path(data, ("addon", "missing")) is None
|
|
|
|
|
|
def test_resolve_path_intermediate_not_dict() -> None:
|
|
# Intermediate value is not a dict -> _resolve_path returns None.
|
|
data = {"addon": "not-a-dict"}
|
|
assert hs._resolve_path(data, ("addon", "online")) is None
|
|
|
|
|
|
def test_on_off() -> None:
|
|
assert hs._on_off(True) == "on"
|
|
assert hs._on_off(False) == "off"
|
|
assert hs._on_off(None) == "off"
|
|
|
|
|
|
def test_accepted_state() -> None:
|
|
assert hs._accepted_state(True) == "accepted"
|
|
assert hs._accepted_state(False) == "rejected"
|
|
|
|
|
|
def test_authorized_state() -> None:
|
|
assert hs._authorized_state(None) == "unknown"
|
|
assert hs._authorized_state(True) == "authorized"
|
|
assert hs._authorized_state(False) == "unauthorized"
|
|
|
|
|
|
def test_timestamp_or_none() -> None:
|
|
assert hs._timestamp_or_none(123) is None
|
|
assert hs._timestamp_or_none(None) is None
|
|
parsed = hs._timestamp_or_none("2026-06-20T10:00:00+00:00")
|
|
assert isinstance(parsed, datetime)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# health_sensor.py async_setup_entry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_async_setup_entry_creates_sensors(hass, entry) -> None:
|
|
coordinator = MagicMock()
|
|
coordinator.data = {}
|
|
entry.runtime_data = SWSRuntimeData(
|
|
coordinator=MagicMock(),
|
|
health_coordinator=coordinator,
|
|
last_options={},
|
|
)
|
|
|
|
added: list[Any] = []
|
|
|
|
def _add_entities(entities: Any) -> None:
|
|
added.extend(entities)
|
|
|
|
await hs.async_setup_entry(hass, entry, _add_entities)
|
|
|
|
assert len(added) == len(hs.HEALTH_SENSOR_DESCRIPTIONS)
|
|
assert all(isinstance(sensor, hs.HealthDiagnosticSensor) for sensor in added)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HealthDiagnosticSensor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _description(key: str) -> hs.HealthSensorEntityDescription:
|
|
"""Return the bundled description for `key`."""
|
|
return next(d for d in hs.HEALTH_SENSOR_DESCRIPTIONS if d.key == key)
|
|
|
|
|
|
def _stub_coordinator(data: dict[str, Any]) -> Any:
|
|
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices.
|
|
|
|
device_info reads coordinator.config for the shared device model.
|
|
"""
|
|
return SimpleNamespace(data=data, config=SimpleNamespace(options={}))
|
|
|
|
|
|
def test_sensor_native_value_without_value_fn() -> None:
|
|
coordinator = _stub_coordinator({"integration_status": "online_wu"})
|
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
|
|
assert sensor.native_value == "online_wu"
|
|
|
|
|
|
def test_sensor_native_value_with_value_fn() -> None:
|
|
coordinator = _stub_coordinator({"addon": {"online": True}})
|
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("wslink_addon_status"))
|
|
assert sensor.native_value == "online"
|
|
|
|
|
|
def test_sensor_extra_state_attributes_strips_internal_fields() -> None:
|
|
data = {
|
|
"integration_status": "online_wu",
|
|
"addon": {
|
|
"online": True,
|
|
"name": "wslink_proxy",
|
|
"home_assistant_source_ip": "1.2.3.4",
|
|
"health_url": "https://1.2.3.4:443/healthz",
|
|
"info_url": "https://1.2.3.4:443/status/internal",
|
|
"home_assistant_url": "http://ha:8123",
|
|
"raw_status": {"secret": "x"},
|
|
},
|
|
}
|
|
coordinator = _stub_coordinator(data)
|
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
|
|
|
|
attrs = sensor.extra_state_attributes
|
|
assert attrs is not None
|
|
# Non-sensitive fields pass through.
|
|
assert attrs["integration_status"] == "online_wu"
|
|
assert attrs["addon"]["online"] is True
|
|
assert attrs["addon"]["name"] == "wslink_proxy"
|
|
# Internal network details are stripped from the (any-user-readable) attributes.
|
|
for field in ("home_assistant_source_ip", "health_url", "info_url", "home_assistant_url", "raw_status"):
|
|
assert field not in attrs["addon"]
|
|
# The source snapshot is not mutated (deepcopy).
|
|
assert "raw_status" in data["addon"]
|
|
|
|
|
|
def test_sensor_extra_state_attributes_none_data() -> None:
|
|
coordinator = _stub_coordinator(None)
|
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("integration_health"))
|
|
assert sensor.extra_state_attributes is None
|
|
|
|
|
|
def test_sensor_extra_state_attributes_for_other_keys() -> None:
|
|
coordinator = _stub_coordinator({"active_protocol": "wu"})
|
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
|
assert sensor.extra_state_attributes is None
|
|
|
|
|
|
def test_public_health_snapshot_handles_missing_or_nondict_addon() -> None:
|
|
# addon missing -> returned as-is (copy).
|
|
assert hc.public_health_snapshot({"integration_status": "x"}) == {"integration_status": "x"}
|
|
# addon not a dict -> left untouched.
|
|
assert hc.public_health_snapshot({"addon": "nope"}) == {"addon": "nope"}
|
|
|
|
|
|
def test_sensor_device_info() -> None:
|
|
coordinator = _stub_coordinator({})
|
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
|
info = sensor.device_info
|
|
assert info["name"] == "Weather Station SWS 12500"
|
|
assert info["manufacturer"] == "Schizza"
|
|
assert info["model"] == "PWS" # no ecowitt/wslink in stub options -> PWS
|
|
|
|
|
|
def test_sensor_unique_id_and_category() -> None:
|
|
coordinator = _stub_coordinator({})
|
|
sensor = hs.HealthDiagnosticSensor(coordinator, _description("active_protocol"))
|
|
assert sensor.unique_id == "active_protocol_health"
|
|
assert sensor.entity_category == hs.EntityCategory.DIAGNOSTIC
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Regression: the optional add-on probe must never fail the config entry
|
|
#
|
|
# `async_config_entry_first_refresh` converts *any* exception escaping
|
|
# `_async_update_data` into `ConfigEntryNotReady`. Since the WSLink proxy add-on is
|
|
# optional, an unreachable/misbehaving add-on used to take the whole integration -
|
|
# including the station webhook - down with it.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"failure",
|
|
[
|
|
pytest.param(TimeoutError(), id="asyncio-timeout"),
|
|
pytest.param(ClientConnectionError("refused"), id="connection-refused"),
|
|
pytest.param(aiohttp.ClientResponseError(None, (), status=500), id="client-response-error"),
|
|
pytest.param(aiohttp.ClientError("generic"), id="generic-client-error"),
|
|
pytest.param(OSError("raw socket"), id="raw-oserror"),
|
|
],
|
|
)
|
|
async def test_probe_failure_never_raises(hass, monkeypatch, failure) -> None:
|
|
"""Any expected probe failure is recorded as offline, not raised."""
|
|
entry = _make_entry({WSLINK_ADDON_PORT: 8443})
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
_patch_network(monkeypatch, _FakeSession({"/healthz": failure}))
|
|
|
|
data = await coordinator._async_update_data()
|
|
|
|
assert data["addon"]["online"] is False
|
|
assert data["addon"]["raw_status"] is None
|
|
# Metadata resolved before the failing request is still reported.
|
|
assert data["addon"]["health_url"] == "https://1.2.3.4:8443/healthz"
|
|
assert data["addon"]["home_assistant_source_ip"] == "1.2.3.4"
|
|
|
|
|
|
async def test_probe_unexpected_error_never_raises(hass, monkeypatch, caplog) -> None:
|
|
"""Even an unforeseen error is contained (and logged) rather than propagated."""
|
|
entry = _make_entry()
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
_patch_network(monkeypatch, _FakeSession({"/healthz": RuntimeError("boom")}))
|
|
|
|
data = await coordinator._async_update_data()
|
|
|
|
assert data["addon"]["online"] is False
|
|
assert "Unexpected error while probing" in caplog.text
|
|
|
|
|
|
async def test_probe_survives_missing_ha_url(hass, monkeypatch) -> None:
|
|
"""`get_url` raising NoURLAvailableError must not skip the reachability probe."""
|
|
entry = _make_entry()
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
session = _FakeSession({"/healthz": _FakeResponse(200), "/status/internal": _FakeResponse(404)})
|
|
_patch_network(monkeypatch, session)
|
|
|
|
def _no_url(_hass):
|
|
raise hc.NoURLAvailableError
|
|
|
|
monkeypatch.setattr(hc, "get_url", _no_url)
|
|
|
|
data = await coordinator._async_update_data()
|
|
|
|
# The add-on was still probed successfully despite HA not knowing its own URL.
|
|
assert data["addon"]["online"] is True
|
|
|
|
|
|
async def test_probe_non_dict_status_body(hass, monkeypatch) -> None:
|
|
"""A non-dict /status/internal body must not blow up metadata parsing."""
|
|
entry = _make_entry()
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
session = _FakeSession(
|
|
{
|
|
"/healthz": _FakeResponse(200),
|
|
"/status/internal": _FakeResponse(200, json_data=["not", "a", "dict"]),
|
|
}
|
|
)
|
|
_patch_network(monkeypatch, session)
|
|
|
|
data = await coordinator._async_update_data()
|
|
|
|
assert data["addon"]["online"] is True
|
|
assert data["addon"]["raw_status"] is None
|
|
assert data["addon"]["name"] is None
|
|
|
|
|
|
async def test_setup_succeeds_when_addon_probe_fails(hass, enable_custom_integrations, monkeypatch) -> None:
|
|
"""End to end: a dead add-on must not put the config entry into SETUP_RETRY."""
|
|
hass.http = SimpleNamespace(app=SimpleNamespace(router=_ProbeRouterStub()))
|
|
|
|
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
|
|
entry.add_to_hass(hass)
|
|
|
|
async def _timeout(_self, _addon):
|
|
raise TimeoutError
|
|
|
|
monkeypatch.setattr(HealthCoordinator, "_probe_addon", _timeout)
|
|
|
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert entry.state is ConfigEntryState.LOADED
|
|
assert entry.runtime_data.health_coordinator.data["addon"]["online"] is False
|
|
|
|
|
|
async def test_probe_healthz_non_200_skips_info_call(hass, monkeypatch) -> None:
|
|
"""Add-on reachable but unhealthy: report offline and skip the info endpoint."""
|
|
entry = _make_entry()
|
|
coordinator = HealthCoordinator(hass, entry)
|
|
_attach_runtime_data(entry, coordinator)
|
|
|
|
session = _FakeSession(
|
|
{
|
|
"/healthz": _FakeResponse(503),
|
|
"/status/internal": _FakeResponse(200, json_data={"addon": "should-not-be-read"}),
|
|
}
|
|
)
|
|
_patch_network(monkeypatch, session)
|
|
|
|
data = await coordinator._async_update_data()
|
|
|
|
assert data["addon"]["online"] is False
|
|
assert data["addon"]["raw_status"] is None
|
|
assert data["addon"]["name"] is None
|