diff --git a/custom_components/nestquest/api_client.py b/custom_components/nestquest/api_client.py index 9f3777f..5f30d44 100644 --- a/custom_components/nestquest/api_client.py +++ b/custom_components/nestquest/api_client.py @@ -315,7 +315,10 @@ class NestQuestApiClient: event_type: str | None = None data_lines: list[str] = [] buffer = "" - async for chunk in response: + # The body is ``response.content`` (aiohttp's StreamReader, + # yielding bytes); the response object itself — HA's + # ``HassClientResponse`` included — is NOT async-iterable. + async for chunk in response.content: if isinstance(chunk, (bytes, bytearray)): chunk = chunk.decode("utf-8") buffer += chunk diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 4978035..3082158 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -472,4 +472,88 @@ async def test_client_logs_nothing() -> None: await client.get_snapshot() finally: logging.getLogger().removeHandler(handler) - assert records == [] \ No newline at end of file + assert records == [] + +# --------------------------------------------------------------------------- +# stream_events: the body is read from ``response.content`` +# --------------------------------------------------------------------------- + + +class _StubStreamContent: + """A fake aiohttp ``StreamReader``: async-iterable over byte chunks.""" + + def __init__(self, chunks) -> None: + self._chunks = list(chunks) + + def __aiter__(self): + return self._iterate() + + async def _iterate(self): + for chunk in self._chunks: + yield chunk + + +class StubStreamResponse: + """A fake aiohttp/HA ``ClientResponse`` for a streaming body. + + Like the real ``HassClientResponse`` it does NOT implement + ``__aiter__`` — the body is iterated through ``.content`` — so a + client that iterates the response object itself raises the + ``'async for' requires an object with __aiter__ method`` TypeError. + """ + + def __init__(self, status: int, chunks) -> None: + self.status = status + self.content = _StubStreamContent(chunks) + + +async def test_stream_events_reads_frames_from_response_content() -> None: + """Frames are parsed from ``response.content`` byte chunks, including + a frame split across chunk boundaries; a trailing incomplete frame + is discarded.""" + assert not hasattr(StubStreamResponse, "__aiter__") + chunks = [ + b'event: nestquest_quest_completed\ndata: {"instance_id"', + b': 7}\n', + b'\nevent: nestquest_child_day_complete\n', + b'data: {"child_id": 1}\n\n', + b'event: nestquest_quest_missed\ndata: {"instance_id": 9}\n', + ] + transport = StubTransport(StubStreamResponse(200, chunks)) + client = make_client(transport) + + frames = [frame async for frame in client.stream_events()] + + assert frames == [ + ("nestquest_quest_completed", {"instance_id": 7}), + ("nestquest_child_day_complete", {"child_id": 1}), + ] + assert transport.calls[0]["method"] == "GET" + assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/events" + + +async def test_stream_events_non_2xx_raises_typed_with_status() -> None: + transport = StubTransport(StubStreamResponse(502, [])) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + async for _ in client.stream_events(): + pass + + assert excinfo.value.status == 502 + assert not excinfo.value.is_transport_error + + +async def test_stream_events_invalid_json_frame_raises_typed() -> None: + transport = StubTransport( + StubStreamResponse(200, [b"event: nestquest_quest_completed\n", b"data: {nope\n\n"]) + ) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + async for _ in client.stream_events(): + pass + + assert excinfo.value.status == 200 + assert not excinfo.value.is_transport_error + assert isinstance(excinfo.value.__cause__, ValueError) diff --git a/tests/test_sse_subscription.py b/tests/test_sse_subscription.py index e0b0b62..a07069f 100644 --- a/tests/test_sse_subscription.py +++ b/tests/test_sse_subscription.py @@ -165,19 +165,29 @@ MINIMAL_SNAPSHOT = { class _StreamingResponse: - """A fake aiohttp streaming response: ``status`` + chunk iteration. + """A fake aiohttp streaming response: ``status`` + ``.content``. - ``chunks`` are the body pieces the stream yields in order; a chunk - may be an exception, raised when the iteration reaches it (a - mid-stream transport drop). ``hold_open`` keeps the stream open - forever after the chunks run out, for cancellation tests. + Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``) + the response itself is NOT async-iterable; the body is iterated + through ``.content``. ``chunks`` are the body pieces the stream + yields in order; a chunk may be an exception, raised when the + iteration reaches it (a mid-stream transport drop). ``hold_open`` + keeps the stream open forever after the chunks run out, for + cancellation tests. """ def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None: self.status = status + self.content = _StreamingContent(chunks, hold_open) + self.closed = False + + +class _StreamingContent: + """The fake response body: an async-iterable chunk stream.""" + + def __init__(self, chunks, hold_open: bool) -> None: self._chunks = list(chunks) self._hold_open = hold_open - self.closed = False def __aiter__(self): return self._iterate()