Files
notify-bridge/packages/server/tests/test_home_assistant_parser.py
alexei.dolgolyov 22127e2a59 feat: Home Assistant provider — WebSocket subscription + bot commands
Adds Home Assistant as a service provider with two coordinated surfaces:

Notifications (subscription):
- Long-lived WebSocket client (aiohttp ws_connect) with auth handshake,
  exponential-backoff reconnect, bounded event queue, and area-registry
  enrichment cached per (re)connect
- ServiceProvider ABC gains an optional `subscribe()` method for push-style
  providers; HomeAssistantServiceProvider uses it via a per-provider
  supervisor task started in the FastAPI lifespan
- 4 event types (state_changed, automation_triggered, call_service,
  event_fired), 4 default Jinja templates (en + ru), HA-specific
  tracker filters (entity_glob, domain_allowlist, exact entity ids)
- Extracted shared dispatch pipeline (api/webhooks.py → services/
  event_dispatch.py) so subscription and webhook ingest share the same
  event_log + deferred-dispatch + quiet-hours code path

Bot commands:
- /status, /entities [glob], /state <entity_id>, /areas
- Multi-command WS session so /status and /areas cost one handshake
- Sensitive-attribute blocklist (camera access_token, entity_picture, etc.)
  and 30-attribute cap to keep /state output safe and within Telegram's
  message size
- Error-message redaction strips URL userinfo before surfacing to chat

Frontend:
- HA descriptor with toggle ConfigField type (new) and tag-input filter
  mode for free-text glob/domain lists (new TagInput component)
- 15 command slots + 4 notification slots wired into the existing
  template-config UI
2026-05-13 14:31:56 +03:00

188 lines
6.5 KiB
Python

"""Unit tests for the Home Assistant event parser.
These tests don't need a database or HA server — the parser is a pure
function from ``ha_event_dict`` to :class:`ServiceEvent`.
"""
from __future__ import annotations
from notify_bridge_core.models.events import EventType
from notify_bridge_core.providers.base import ServiceProviderType
from notify_bridge_core.providers.home_assistant.event_parser import parse_event
def _ha_event_envelope(event_type: str, data: dict) -> dict:
return {
"event_type": event_type,
"data": data,
"time_fired": "2026-05-13T12:34:56.789Z",
}
def test_state_changed_basic() -> None:
payload = _ha_event_envelope(
"state_changed",
{
"entity_id": "binary_sensor.front_door",
"old_state": {"state": "off", "attributes": {}},
"new_state": {
"state": "on",
"attributes": {
"friendly_name": "Front Door",
"device_class": "door",
},
},
},
)
evt = parse_event(payload, provider_name="HA")
assert evt is not None
assert evt.event_type is EventType.HA_STATE_CHANGED
assert evt.provider_type is ServiceProviderType.HOME_ASSISTANT
assert evt.collection_id == "binary_sensor.front_door"
assert evt.collection_name == "Front Door"
assert evt.extra["old_state"] == "off"
assert evt.extra["new_state"] == "on"
assert evt.extra["domain"] == "binary_sensor"
assert evt.extra["device_class"] == "door"
# Area was not provided in lookup -> None.
assert evt.extra["area"] is None
def test_state_changed_with_area_lookup() -> None:
payload = _ha_event_envelope(
"state_changed",
{
"entity_id": "light.kitchen",
"old_state": {"state": "off", "attributes": {}},
"new_state": {
"state": "on",
"attributes": {"friendly_name": "Kitchen Light"},
},
},
)
evt = parse_event(
payload,
provider_name="HA",
area_lookup={"light.kitchen": "Kitchen"},
)
assert evt is not None
assert evt.extra["area"] == "Kitchen"
def test_state_changed_entity_removed() -> None:
"""new_state=None means HA removed the entity. Surface as 'removed' so
templates can branch on it; collection_name falls back to old_state."""
payload = _ha_event_envelope(
"state_changed",
{
"entity_id": "sensor.dropped",
"old_state": {
"state": "online",
"attributes": {"friendly_name": "Dropped Sensor"},
},
"new_state": None,
},
)
evt = parse_event(payload, provider_name="HA")
assert evt is not None
assert evt.extra["new_state"] == "removed"
assert evt.collection_name == "Dropped Sensor"
def test_automation_triggered() -> None:
payload = _ha_event_envelope(
"automation_triggered",
{
"name": "Front door notification",
"entity_id": "automation.front_door_notify",
"source": "state of binary_sensor.front_door",
},
)
evt = parse_event(payload, provider_name="HA")
assert evt is not None
assert evt.event_type is EventType.HA_AUTOMATION_TRIGGERED
assert evt.collection_name == "Front door notification"
assert evt.collection_id == "automation.front_door_notify"
assert evt.extra["automation_name"] == "Front door notification"
assert evt.extra["trigger_source"] == "state of binary_sensor.front_door"
def test_call_service_with_target() -> None:
payload = _ha_event_envelope(
"call_service",
{
"domain": "light",
"service": "turn_on",
"service_data": {"entity_id": "light.kitchen"},
},
)
evt = parse_event(payload, provider_name="HA")
assert evt is not None
assert evt.event_type is EventType.HA_SERVICE_CALLED
assert evt.collection_id == "light.turn_on"
assert evt.extra["target_entity"] == "light.kitchen"
assert evt.extra["service_domain"] == "light"
assert evt.extra["service_name"] == "turn_on"
def test_call_service_with_multi_target() -> None:
"""When the call hits multiple entities, the parser comma-joins them
so templates can render ``{{ target_entity }}`` without iterating."""
payload = _ha_event_envelope(
"call_service",
{
"domain": "light",
"service": "turn_off",
"service_data": {
"entity_id": ["light.kitchen", "light.living_room"],
},
},
)
evt = parse_event(payload, provider_name="HA")
assert evt is not None
assert evt.extra["target_entity"] == "light.kitchen, light.living_room"
def test_generic_event_fallback() -> None:
"""Any event_type not in the known set becomes ha_event_fired with the
raw event_type stashed in extras so loud catch-all subscriptions work."""
payload = _ha_event_envelope(
"custom_event_xyz",
{"foo": "bar"},
)
evt = parse_event(payload, provider_name="HA")
assert evt is not None
assert evt.event_type is EventType.HA_EVENT_FIRED
assert evt.extra["ha_event_type"] == "custom_event_xyz"
assert evt.extra["event_data"] == {"foo": "bar"}
def test_malformed_payload_returns_none() -> None:
assert parse_event({}, provider_name="HA") is None
assert parse_event("not a dict", provider_name="HA") is None # type: ignore[arg-type]
# state_changed without entity_id is unrecoverable
bad = _ha_event_envelope("state_changed", {"new_state": None})
assert parse_event(bad, provider_name="HA") is None
# call_service without domain/service is unrecoverable
bad2 = _ha_event_envelope("call_service", {"service": "turn_on"})
assert parse_event(bad2, provider_name="HA") is None
def test_time_fired_iso_with_z_suffix_parses() -> None:
"""HA uses ``Z`` suffix; older Python ``fromisoformat`` rejects it.
The parser must handle both forms or we'd lose the timestamp."""
from datetime import timezone
payload = _ha_event_envelope(
"state_changed",
{
"entity_id": "sensor.temp",
"old_state": {"state": "20", "attributes": {}},
"new_state": {"state": "21", "attributes": {}},
},
)
payload["time_fired"] = "2026-05-13T12:34:56.789Z"
evt = parse_event(payload, provider_name="HA")
assert evt is not None
assert evt.timestamp.tzinfo is not None
assert evt.timestamp.utcoffset() == timezone.utc.utcoffset(None)