Skip to content

Commit 4e70cdf

Browse files
committed
Add async config entry methods and improve test coverage to 99%
Add async counterparts for all config entry websocket methods in RawAsyncWebsocketClient. Add async tests for websocket state, entity, config entry, and error path coverage. Fix conftest return type annotation, replace zip(range) with break, and use pytest.raises instead of try/except in error tests.
1 parent 01ba5b3 commit 4e70cdf

5 files changed

Lines changed: 399 additions & 19 deletions

File tree

homeassistant_api/rawasyncwebsocket.py

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,16 @@
2121
ResponseError,
2222
UnauthorizedError,
2323
)
24-
from homeassistant_api.models import Domain, Entity, Group, State
24+
from homeassistant_api.models import (
25+
ConfigEntry,
26+
ConfigEntryEvent,
27+
ConfigSubEntry,
28+
Domain,
29+
Entity,
30+
Group,
31+
State,
32+
)
33+
from homeassistant_api.models.config_entries import DisableEnableResult, FlowResult
2534
from homeassistant_api.models.states import Context
2635
from homeassistant_api.models.websocket import (
2736
AuthInvalid,
@@ -497,6 +506,145 @@ async def _async_unsubscribe(self, subcription_id: int) -> None:
497506
assert cast(ResultResponse, resp).result is None
498507
self._event_responses.pop(subcription_id)
499508

509+
async def async_get_config_entries(self) -> Tuple[ConfigEntry, ...]:
510+
"""
511+
Get all config entries.
512+
513+
Sends command :code:`{"type": "config_entries/get", ...}`.
514+
"""
515+
resp = await self.async_recv(await self.async_send("config_entries/get"))
516+
return tuple(
517+
ConfigEntry.from_json(entry)
518+
for entry in cast(
519+
list[dict[str, JSONType]],
520+
cast(ResultResponse, resp).result,
521+
)
522+
)
523+
524+
async def async_disable_config_entry(self, entry_id: str) -> DisableEnableResult:
525+
"""
526+
Disable a config entry.
527+
528+
Sends command :code:`{"type": "config_entries/disable", ...}`.
529+
"""
530+
resp = await self.async_recv(
531+
await self.async_send(
532+
"config_entries/disable",
533+
entry_id=entry_id,
534+
disabled_by="user",
535+
)
536+
)
537+
return DisableEnableResult.from_json(
538+
cast(dict[str, JSONType], cast(ResultResponse, resp).result)
539+
)
540+
541+
async def async_enable_config_entry(self, entry_id: str) -> DisableEnableResult:
542+
"""
543+
Enable a config entry.
544+
545+
Sends command :code:`{"type": "config_entries/disable", ...}`.
546+
"""
547+
resp = await self.async_recv(
548+
await self.async_send(
549+
"config_entries/disable",
550+
entry_id=entry_id,
551+
disabled_by=None,
552+
)
553+
)
554+
return DisableEnableResult.from_json(
555+
cast(dict[str, JSONType], cast(ResultResponse, resp).result)
556+
)
557+
558+
async def async_ignore_config_flow(self, flow_id: str, title: str) -> None:
559+
"""
560+
Ignore a config flow.
561+
562+
Sends command :code:`{"type": "config_entries/ignore_flow", ...}`.
563+
"""
564+
await self.async_recv(
565+
await self.async_send(
566+
"config_entries/ignore_flow",
567+
flow_id=flow_id,
568+
title=title,
569+
)
570+
)
571+
572+
async def async_get_nonuser_flows_in_progress(self) -> Tuple[FlowResult, ...]:
573+
"""
574+
Get non-user config flows in progress.
575+
576+
Sends command :code:`{"type": "config_entries/flow/progress", ...}`.
577+
"""
578+
resp = await self.async_recv(
579+
await self.async_send("config_entries/flow/progress")
580+
)
581+
return tuple(
582+
FlowResult.from_json(flow)
583+
for flow in cast(
584+
list[dict[str, JSONType]],
585+
cast(ResultResponse, resp).result,
586+
)
587+
)
588+
589+
async def async_get_entry_subentries(
590+
self, entry_id: str
591+
) -> Tuple[ConfigSubEntry, ...]:
592+
"""
593+
Get subentries for a config entry.
594+
595+
Sends command :code:`{"type": "config_entries/subentries/list", ...}`.
596+
"""
597+
resp = await self.async_recv(
598+
await self.async_send("config_entries/subentries/list", entry_id=entry_id)
599+
)
600+
return tuple(
601+
ConfigSubEntry.from_json(subentry)
602+
for subentry in cast(
603+
list[dict[str, JSONType]],
604+
cast(ResultResponse, resp).result,
605+
)
606+
)
607+
608+
async def async_delete_entry_subentry(
609+
self, entry_id: str, subentry_id: str
610+
) -> None:
611+
"""
612+
Delete a subentry from a config entry.
613+
614+
Sends command :code:`{"type": "config_entries/subentries/delete", ...}`.
615+
"""
616+
await self.async_recv(
617+
await self.async_send(
618+
"config_entries/subentries/delete",
619+
entry_id=entry_id,
620+
subentry_id=subentry_id,
621+
)
622+
)
623+
624+
@contextlib.asynccontextmanager
625+
async def async_listen_config_entries(
626+
self,
627+
) -> AsyncGenerator[AsyncGenerator[list[ConfigEntryEvent], None], None]:
628+
"""
629+
Listen for config entry changes.
630+
631+
Sends command :code:`{"type": "config_entries/subscribe", ...}`.
632+
"""
633+
subscription = (
634+
await self.async_recv(await self.async_send("config_entries/subscribe"))
635+
).id
636+
yield self._async_wait_for_config_entries(subscription)
637+
await self._async_unsubscribe(subscription)
638+
639+
async def _async_wait_for_config_entries(
640+
self, subscription_id: int
641+
) -> AsyncGenerator[list[ConfigEntryEvent], None]:
642+
"""An async iterator that waits for config entry events."""
643+
while True:
644+
event_resp = cast(EventResponse, await self.async_recv(subscription_id))
645+
entries = cast(list[dict[str, JSONType]], event_resp.event)
646+
yield [ConfigEntryEvent.from_json(entry) for entry in entries]
647+
500648
async def async_fire_event(self, event_type: str, **event_data) -> Context:
501649
"""
502650
Fire an event.

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def setup_websocket_client(
6262
@pytest.fixture(name="async_websocket_client", scope="session")
6363
async def setup_async_websocket_client(
6464
wait_for_server: Literal[None],
65-
) -> AsyncGenerator[Client, None]:
65+
) -> AsyncGenerator[WebsocketClient, None]:
6666
"""Initializes the Client and enters an async WebSocket session."""
6767
async with WebsocketClient(
6868
os.environ["HOMEASSISTANTAPI_WS_URL"],

tests/test_endpoints.py

Lines changed: 125 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,15 @@ def test_websocket_get_config(websocket_client: WebsocketClient) -> None:
171171
assert config.get("state") in {"RUNNING", "NOT_RUNNING"}
172172

173173

174+
async def test_async_websocket_get_config(
175+
async_websocket_client: WebsocketClient,
176+
) -> None:
177+
"""Tests the `"type": "get_config"` websocket command."""
178+
config = await async_websocket_client.async_get_config()
179+
assert isinstance(config, dict)
180+
assert config.get("state") in {"RUNNING", "NOT_RUNNING"}
181+
182+
174183
def test_websocket_get_state(websocket_client: WebsocketClient) -> None:
175184
"""Tests WebsocketClient.get_state with entity_id."""
176185
state = websocket_client.get_state(entity_id="sun.sun")
@@ -200,6 +209,53 @@ def test_websocket_get_entity_no_args(websocket_client: WebsocketClient) -> None
200209
websocket_client.get_entity()
201210

202211

212+
async def test_async_websocket_get_state(
213+
async_websocket_client: WebsocketClient,
214+
) -> None:
215+
"""Tests async WebsocketClient.async_get_state with entity_id."""
216+
state = await async_websocket_client.async_get_state(entity_id="sun.sun")
217+
assert state.entity_id == "sun.sun"
218+
assert state.state in {"above_horizon", "below_horizon"}
219+
220+
221+
async def test_async_websocket_get_entity_by_group_slug(
222+
async_websocket_client: WebsocketClient,
223+
) -> None:
224+
"""Tests async WebsocketClient.async_get_entity with group_id and slug."""
225+
entity = await async_websocket_client.async_get_entity(group_id="sun", slug="sun")
226+
assert entity is not None
227+
assert entity.entity_id == "sun.sun"
228+
229+
230+
async def test_async_websocket_get_entity_by_entity_id(
231+
async_websocket_client: WebsocketClient,
232+
) -> None:
233+
"""Tests async WebsocketClient.async_get_entity with entity_id."""
234+
entity = await async_websocket_client.async_get_entity(entity_id="sun.sun")
235+
assert entity is not None
236+
assert entity.entity_id == "sun.sun"
237+
238+
239+
async def test_async_websocket_get_entity_no_args(
240+
async_websocket_client: WebsocketClient,
241+
) -> None:
242+
"""Tests async WebsocketClient.async_get_entity raises ValueError with no arguments."""
243+
with pytest.raises(
244+
ValueError, match="Neither group_id and slug or entity_id provided"
245+
):
246+
await async_websocket_client.async_get_entity()
247+
248+
249+
async def test_async_websocket_get_state_not_found(
250+
async_websocket_client: WebsocketClient,
251+
) -> None:
252+
"""Tests async WebsocketClient.async_get_state raises ValueError for nonexistent entity."""
253+
with pytest.raises(ValueError, match="not found"):
254+
await async_websocket_client.async_get_state(
255+
entity_id="fake.nonexistent_entity_12345"
256+
)
257+
258+
203259
def test_websocket_get_state_not_found(websocket_client: WebsocketClient) -> None:
204260
"""Tests WebsocketClient.get_state raises ValueError for nonexistent entity."""
205261
with pytest.raises(ValueError, match="not found"):
@@ -283,6 +339,14 @@ def test_get_nonuser_flows_in_progress(websocket_client: WebsocketClient) -> Non
283339
assert not flows
284340

285341

342+
async def test_async_get_nonuser_flows_in_progress(
343+
async_websocket_client: WebsocketClient,
344+
) -> None:
345+
"""Tests the `"type": "config_entries/flow/progress"` websocket command."""
346+
flows = await async_websocket_client.async_get_nonuser_flows_in_progress()
347+
assert not flows
348+
349+
286350
def test_disable_enable_config_entry(websocket_client: WebsocketClient) -> None:
287351
"""Tests the `"type": "config_entries/disable"` websocket command."""
288352
# Get sun entry
@@ -299,21 +363,42 @@ def test_disable_enable_config_entry(websocket_client: WebsocketClient) -> None:
299363
# Re-enable
300364
websocket_client.enable_config_entry(entry.entry_id)
301365

302-
# Check that it was enable
366+
# Check that it was enabled
303367
enabled_entry = websocket_client.get_config_entries()[0]
304368
assert enabled_entry.disabled_by is None
305369

306370

371+
async def test_async_disable_enable_config_entry(
372+
async_websocket_client: WebsocketClient,
373+
) -> None:
374+
"""Tests the `"type": "config_entries/disable"` websocket command."""
375+
entry = (await async_websocket_client.async_get_config_entries())[0]
376+
assert entry.disabled_by is None
377+
378+
await async_websocket_client.async_disable_config_entry(entry.entry_id)
379+
380+
disabled_entry = (await async_websocket_client.async_get_config_entries())[0]
381+
assert disabled_entry.disabled_by is ConfigEntryDisabler.USER
382+
383+
await async_websocket_client.async_enable_config_entry(entry.entry_id)
384+
385+
enabled_entry = (await async_websocket_client.async_get_config_entries())[0]
386+
assert enabled_entry.disabled_by is None
387+
388+
307389
def test_ignore_config_flow(websocket_client: WebsocketClient) -> None:
308390
"""Tests the `"type": "config_entries/ignore_flow"` websocket command."""
309391
# Currently not able to test as no flows are in progress. Send invalid parameters and handle that error
310-
try:
392+
with pytest.raises(RequestError, match="Config entry not found"):
311393
websocket_client.ignore_config_flow("", "")
312-
except RequestError as error:
313-
assert (
314-
error.__str__()
315-
== "An error occurred while making the request to 'Config entry not found' with data: 'not_found'"
316-
)
394+
395+
396+
async def test_async_ignore_config_flow(
397+
async_websocket_client: WebsocketClient,
398+
) -> None:
399+
"""Tests the `"type": "config_entries/ignore_flow"` websocket command."""
400+
with pytest.raises(RequestError, match="Config entry not found"):
401+
await async_websocket_client.async_ignore_config_flow("", "")
317402

318403

319404
def test_get_config_entries(websocket_client: WebsocketClient) -> None:
@@ -345,6 +430,20 @@ def test_get_config_entries(websocket_client: WebsocketClient) -> None:
345430
assert sun.num_subentries == 0
346431

347432

433+
async def test_async_get_config_entries(
434+
async_websocket_client: WebsocketClient,
435+
) -> None:
436+
"""Tests the `"type": "config_entries/get"` websocket command."""
437+
entries = await async_websocket_client.async_get_config_entries()
438+
assert len(entries) == 4
439+
440+
sun = entries[0]
441+
assert sun.entry_id == "5f8426fa502435857743f302651753c9"
442+
assert sun.domain == "sun"
443+
assert sun.title == "Sun"
444+
assert sun.disabled_by is None
445+
446+
348447
def test_get_entry_subentries(websocket_client: WebsocketClient) -> None:
349448
"""Tests the `"type": "config_entries/subentries/list"` websocket command."""
350449
# Currently not able to test as no entries with subentries available
@@ -356,16 +455,28 @@ def test_get_entry_subentries(websocket_client: WebsocketClient) -> None:
356455
assert not websocket_client.get_entry_subentries(sun.entry_id)
357456

358457

458+
async def test_async_get_entry_subentries(
459+
async_websocket_client: WebsocketClient,
460+
) -> None:
461+
"""Tests the `"type": "config_entries/subentries/list"` websocket command."""
462+
sun = (await async_websocket_client.async_get_config_entries())[0]
463+
assert sun
464+
assert not await async_websocket_client.async_get_entry_subentries(sun.entry_id)
465+
466+
359467
def test_delete_entry_subentry(websocket_client: WebsocketClient) -> None:
360468
"""Tests the `"type": "config_entries/subentries/delete"` websocket command."""
361469
# Currently not able to test as no entries with subentries available. Send invalid parameters and handle that error
362-
try:
470+
with pytest.raises(RequestError, match="Config entry not found"):
363471
websocket_client.delete_entry_subentry("", "")
364-
except RequestError as error:
365-
assert (
366-
error.__str__()
367-
== "An error occurred while making the request to 'Config entry not found' with data: 'not_found'"
368-
)
472+
473+
474+
async def test_async_delete_entry_subentry(
475+
async_websocket_client: WebsocketClient,
476+
) -> None:
477+
"""Tests the `"type": "config_entries/subentries/delete"` websocket command."""
478+
with pytest.raises(RequestError, match="Config entry not found"):
479+
await async_websocket_client.async_delete_entry_subentry("", "")
369480

370481

371482
def test_trigger_service(cached_client: Client) -> None:
@@ -473,7 +584,7 @@ async def test_async_websocket_trigger_service_with_response(
473584
"""Tests the `"type": "trigger_service_with_response"` websocket command."""
474585
weather = await async_websocket_client.async_get_domain("weather")
475586
assert weather is not None
476-
data = weather.get_forecasts(
587+
data = await weather.get_forecasts(
477588
entity_id="weather.forecast_home",
478589
type="hourly",
479590
)

0 commit comments

Comments
 (0)