fix: complete websocket delivery for v0.7.1
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-14 22:30:29 +02:00
parent 51d23e0a9a
commit 2ae5576b8f
5 changed files with 129 additions and 49 deletions

View File

@@ -1,50 +1,112 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import anyio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.main import _ha_event_listener, lifespan
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.main import _ha_event_listener, app as fastapi_app, lifespan
@pytest.mark.asyncio
async def test_ha_event_listener_processes_state_change() -> None:
# Mock settings, store, engine
class _FakeWebSocket:
def __init__(self, messages: list[str | BaseException]) -> None:
self._messages = messages
self.sent: list[dict[str, object]] = []
async def __aenter__(self) -> "_FakeWebSocket":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def recv(self) -> str:
message = self._messages.pop(0)
if isinstance(message, BaseException):
raise message
return message
async def send(self, message: str) -> None:
import json
self.sent.append(json.loads(message))
class _RecordingBehaviorEngine(BehaviorEngine):
def __init__(self, tmp_path: Path) -> None:
super().__init__(
ha_reader=MagicMock(),
store=ActuatorStore(tmp_path / "actuators"),
settings=MagicMock(),
)
self.state_changes: list[tuple[str, dict[str, object] | None]] = []
def handle_state_change(self, entity_id: str, new_state: dict[str, object] | None) -> None:
self.state_changes.append((entity_id, new_state))
def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
async def run_test() -> None:
fake_ws = _FakeWebSocket(
[
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
(
'{"type":"event","event":{"event_type":"state_changed",'
'"entity_id":"light.test","new_state":{"state":"on"}}}'
),
asyncio.CancelledError(),
]
)
with patch("websockets.connect", return_value=fake_ws):
try:
await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError:
pass
assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"},
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
]
mock_app = MagicMock()
mock_app.state.settings = MagicMock()
mock_app.state.settings.ha_url = "http://homeassistant:8123"
mock_app.state.settings.ha_token = "test-token"
mock_engine = MagicMock()
mock_engine.handle_state_change = MagicMock()
mock_app.state.ws_status = MagicMock()
mock_engine = _RecordingBehaviorEngine(tmp_path)
mock_app.state.behavior_engine = mock_engine
mock_store = MagicMock()
mock_store = ActuatorStore(tmp_path / "store")
mock_app.state.actuator_store = mock_store
mock_client = MagicMock()
# Mock websockets connection and messages
mock_ws = AsyncMock()
mock_ws.recv.side_effect = [
'{"type":"auth_ok"}', # auth response
'{"type":"event","event":{"event_type":"state_changed","entity_id":"light.test","new_state":{"state":"on"}}}', # event
Exception("EOF"), # break loop
]
mock_ws.send.return_value = None
with patch("websockets.connect", return_value=mock_ws):
await _ha_event_listener(mock_app, mock_client)
mock_engine.handle_state_change.assert_called_once_with("light.test", {"state": "on"})
anyio.run(run_test)
assert mock_engine.state_changes == [("light.test", {"state": "on"})]
assert mock_app.state.ws_status.status == "connected"
assert mock_app.state.ws_status.error is None
@pytest.mark.asyncio
async def test_lifespan_starts_event_listener() -> None:
# This is a basic smoke test that lifespan creates the expected tasks
from fastapi import FastAPI
def test_lifespan_skips_event_listener_without_ha_config() -> None:
app = FastAPI()
app.state.settings = MagicMock()
app.state.settings.ha_configured = False
# Should not raise
async with lifespan(app):
pass
async def run_test() -> None:
async with lifespan(app):
pass
anyio.run(run_test)
def test_websocket_health_returns_unavailable_without_listener() -> None:
with TestClient(fastapi_app) as client:
response = client.get("/health/websocket")
assert response.status_code == 200
assert response.json() == {
"status": "unavailable",
"error": "WebSocket-Listener nicht initialisiert",
}