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"})