Files
home-automation/tests/test_homeassistant.py
T
tliu93 018f13d73d
frontend / frontend (push) Successful in 47s
pytest / test (push) Successful in 4m1s
docker-image / build-and-push (push) Successful in 1m38s
M8-R15: fix HA discovery identities and thermal totals
2026-08-28 01:20:52 +02:00

198 lines
7.3 KiB
Python

import json
from urllib import error
import pytest
from app.config import Settings
from app.integrations.homeassistant import (
HomeAssistantClient,
HomeAssistantConfigError,
HomeAssistantRequestError,
)
class _FakeResponse:
def __init__(self, status_code: int):
self.status_code = status_code
def getcode(self) -> int:
return self.status_code
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
def _configured_settings() -> Settings:
return Settings(
home_assistant_base_url="http://ha.local:8123",
home_assistant_auth_token="secret-token",
home_assistant_timeout_seconds=1.5,
)
def test_publish_sensor_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None:
captured = {}
client = HomeAssistantClient(
settings=_configured_settings(),
timeout_seconds=_configured_settings().home_assistant_timeout_seconds,
)
def fake_urlopen(req, timeout):
captured["url"] = req.full_url
captured["timeout"] = timeout
captured["authorization"] = req.headers["Authorization"]
captured["content_type"] = req.headers["Content-type"]
captured["body"] = json.loads(req.data.decode("utf-8"))
return _FakeResponse(200)
monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen)
client.publish_sensor(
entity_id="sensor.test_poo_status",
state="happy",
attributes={"friendly_name": "Poo Status"},
)
assert captured["url"] == "http://ha.local:8123/api/states/sensor.test_poo_status"
assert captured["timeout"] == pytest.approx(1.5)
assert captured["authorization"] == "Bearer secret-token"
assert captured["content_type"] == "application/json"
assert captured["body"] == {
"entity_id": "sensor.test_poo_status",
"state": "happy",
"attributes": {"friendly_name": "Poo Status"},
}
def test_trigger_webhook_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None:
captured = {}
client = HomeAssistantClient(settings=_configured_settings())
def fake_urlopen(req, timeout):
captured["url"] = req.full_url
captured["body"] = json.loads(req.data.decode("utf-8"))
return _FakeResponse(201)
monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen)
client.trigger_webhook(webhook_id="poo-status", body={"status": "done"})
assert captured["url"] == "http://ha.local:8123/api/webhook/poo-status"
assert captured["body"] == {"status": "done"}
def test_homeassistant_client_raises_on_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
client = HomeAssistantClient(settings=_configured_settings())
def fake_urlopen(req, timeout):
raise error.HTTPError(req.full_url, 500, "boom", hdrs=None, fp=None)
monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen)
with pytest.raises(HomeAssistantRequestError, match="HTTP 500"):
client.publish_sensor(entity_id="sensor.test_status", state="bad")
def test_homeassistant_client_raises_when_not_configured() -> None:
client = HomeAssistantClient(settings=Settings(_env_file=None))
with pytest.raises(HomeAssistantConfigError, match="not configured"):
client.publish_sensor(entity_id="sensor.test_status", state="ok")
def test_homeassistant_client_raises_on_invalid_arguments() -> None:
client = HomeAssistantClient(settings=_configured_settings())
with pytest.raises(ValueError, match="entity_id"):
client.publish_sensor(entity_id="", state="ok")
with pytest.raises(ValueError, match="webhook_id"):
client.trigger_webhook(webhook_id="", body={})
def test_discovery_registry_bindings_reads_entity_and_device_registry(monkeypatch: pytest.MonkeyPatch) -> None:
"""The repair confirmation reads HA's authoritative registry bindings."""
sent: list[dict] = []
class _Socket:
replies = iter((
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
'{"id":1,"success":true,"result":[{"platform":"mqtt","unique_id":"u1","device_id":"d1"}]}',
'{"id":2,"success":true,"result":[{"id":"d1","identifiers":[["mqtt","home-automation:meter:m1"]]}]}',
))
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def recv(self, *, timeout=None):
assert timeout is not None
assert timeout <= 1.5
return next(self.replies)
def send(self, payload):
sent.append(json.loads(payload))
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
bindings = HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1", "missing"})
assert bindings == {"u1": {"home-automation:meter:m1"}}
assert [message.get("type") for message in sent] == [
"auth", "config/entity_registry/list", "config/device_registry/list"
]
def test_discovery_registry_bindings_uses_one_deadline_and_ignores_non_mqtt_identifiers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Only official ["mqtt", value] pairs participate in repair matching."""
received_timeouts: list[float] = []
class _Socket:
replies = iter((
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
'{"id":1,"success":true,"result":['
'{"platform":"mqtt","unique_id":"u1","device_id":"d1"},'
'{"platform":"mqtt","unique_id":"u2","device_id":"d2"},'
'{"platform":"mqtt","unique_id":"u3","device_id":"d3"}]}',
'{"id":2,"success":true,"result":['
'{"id":"d1","identifiers":[["mqtt","expected"],["esphome","aux"]]},'
'{"id":"d2","identifiers":[["esphome","expected"],"expected",["mqtt",3],[]]},'
'{"id":"d3","identifiers":[["mqtt","expected"],["mqtt","old"]]}]}',
))
def __enter__(self): return self
def __exit__(self, *_args): return None
def send(self, _payload): return None
def recv(self, *, timeout=None):
received_timeouts.append(timeout)
return next(self.replies)
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
bindings = HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1", "u2", "u3"})
assert bindings == {"u1": {"expected"}, "u2": set(), "u3": {"expected", "old"}}
assert len(received_timeouts) == 4
assert all(timeout is not None and 0 < timeout <= 1.5 for timeout in received_timeouts)
def test_discovery_registry_bindings_silent_socket_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
class _Socket:
def __enter__(self): return self
def __exit__(self, *_args): return None
def send(self, _payload): return None
def recv(self, *, timeout=None):
assert timeout is not None and timeout > 0
raise TimeoutError("silent")
monkeypatch.setattr("app.integrations.homeassistant.connect", lambda *_args, **_kwargs: _Socket())
with pytest.raises(HomeAssistantRequestError, match="registry query failed"):
HomeAssistantClient(_configured_settings()).discovery_registry_bindings({"u1"})