hass-core/tests/components/matter/test_init.py
Stefan Agner 44aad2b821
Improve Matter Server version incompatibility handling (#120416)
* Improve Matter Server version incompatibility handling

Improve the handling of Matter Server version. Noteably fix the issues
raised (add strings for the issue) and split the version check into
two cases: One if the server is too old and one if the server is too
new.

* Bump Python Matter Server library to 6.2.0b1

* Address review feedback
2024-06-26 11:43:51 +02:00

711 lines
23 KiB
Python

"""Test the Matter integration init."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, call, patch
from matter_server.client.exceptions import (
CannotConnect,
ServerVersionTooNew,
ServerVersionTooOld,
)
from matter_server.client.models.node import MatterNode
from matter_server.common.errors import MatterError
from matter_server.common.helpers.util import dataclass_from_dict
from matter_server.common.models import MatterNodeData
import pytest
from typing_extensions import Generator
from homeassistant.components.hassio import HassioAPIError
from homeassistant.components.matter.const import DOMAIN
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.setup import async_setup_component
from .common import load_and_parse_node_fixture, setup_integration_with_node_fixture
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
@pytest.fixture(name="connect_timeout")
def connect_timeout_fixture() -> Generator[int]:
"""Mock the connect timeout."""
with patch("homeassistant.components.matter.CONNECT_TIMEOUT", new=0) as timeout:
yield timeout
@pytest.fixture(name="listen_ready_timeout")
def listen_ready_timeout_fixture() -> Generator[int]:
"""Mock the listen ready timeout."""
with patch(
"homeassistant.components.matter.LISTEN_READY_TIMEOUT", new=0
) as timeout:
yield timeout
async def test_entry_setup_unload(
hass: HomeAssistant,
matter_client: MagicMock,
) -> None:
"""Test the integration set up and unload."""
node_data = load_and_parse_node_fixture("onoff-light")
node = MatterNode(
dataclass_from_dict(
MatterNodeData,
node_data,
)
)
matter_client.get_nodes.return_value = [node]
matter_client.get_node.return_value = node
entry = MockConfigEntry(domain="matter", data={"url": "ws://localhost:5580/ws"})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert matter_client.connect.call_count == 1
assert entry.state is ConfigEntryState.LOADED
entity_state = hass.states.get("light.mock_onoff_light_light")
assert entity_state
assert entity_state.state != STATE_UNAVAILABLE
await hass.config_entries.async_unload(entry.entry_id)
assert matter_client.disconnect.call_count == 1
assert entry.state is ConfigEntryState.NOT_LOADED
entity_state = hass.states.get("light.mock_onoff_light_light")
assert entity_state
assert entity_state.state == STATE_UNAVAILABLE
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_home_assistant_stop(
hass: HomeAssistant,
matter_client: MagicMock,
integration: MockConfigEntry,
) -> None:
"""Test clean up on home assistant stop."""
await hass.async_stop()
assert matter_client.disconnect.call_count == 1
@pytest.mark.parametrize("error", [CannotConnect(Exception("Boom")), Exception("Boom")])
async def test_connect_failed(
hass: HomeAssistant,
matter_client: MagicMock,
error: Exception,
) -> None:
"""Test failure during client connection."""
entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"})
entry.add_to_hass(hass)
matter_client.connect.side_effect = error
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_connect_timeout(
hass: HomeAssistant,
matter_client: MagicMock,
connect_timeout: int,
) -> None:
"""Test timeout during client connection."""
entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize("error", [MatterError("Boom"), Exception("Boom")])
async def test_listen_failure_timeout(
hass: HomeAssistant,
listen_ready_timeout: int,
matter_client: MagicMock,
error: Exception,
) -> None:
"""Test client listen errors during the first timeout phase."""
async def start_listening(listen_ready: asyncio.Event) -> None:
"""Mock the client start_listening method."""
# Set the connect side effect to stop an endless loop on reload.
matter_client.connect.side_effect = MatterError("Boom")
raise error
matter_client.start_listening.side_effect = start_listening
entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize("error", [MatterError("Boom"), Exception("Boom")])
async def test_listen_failure_config_entry_not_loaded(
hass: HomeAssistant,
matter_client: MagicMock,
error: Exception,
) -> None:
"""Test client listen errors during the final phase before config entry loaded."""
listen_block = asyncio.Event()
async def start_listening(listen_ready: asyncio.Event) -> None:
"""Mock the client start_listening method."""
listen_ready.set()
await listen_block.wait()
# Set the connect side effect to stop an endless loop on reload.
matter_client.connect.side_effect = MatterError("Boom")
raise error
def get_nodes() -> list[MagicMock]:
"""Mock the client get_nodes method."""
listen_block.set()
return []
matter_client.start_listening.side_effect = start_listening
matter_client.get_nodes.side_effect = get_nodes
entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert matter_client.disconnect.call_count == 1
@pytest.mark.parametrize("error", [MatterError("Boom"), Exception("Boom")])
async def test_listen_failure_config_entry_loaded(
hass: HomeAssistant,
matter_client: MagicMock,
error: Exception,
) -> None:
"""Test client listen errors after config entry is loaded."""
listen_block = asyncio.Event()
async def start_listening(listen_ready: asyncio.Event) -> None:
"""Mock the client start_listening method."""
listen_ready.set()
await listen_block.wait()
# Set the connect side effect to stop an endless loop on reload.
matter_client.connect.side_effect = MatterError("Boom")
raise error
matter_client.start_listening.side_effect = start_listening
entry = MockConfigEntry(domain=DOMAIN, data={"url": "ws://localhost:5580/ws"})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
listen_block.set()
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert matter_client.disconnect.call_count == 1
async def test_raise_addon_task_in_progress(
hass: HomeAssistant,
addon_not_installed: AsyncMock,
install_addon: AsyncMock,
start_addon: AsyncMock,
) -> None:
"""Test raise ConfigEntryNotReady if an add-on task is in progress."""
install_event = asyncio.Event()
install_addon_original_side_effect = install_addon.side_effect
async def install_addon_side_effect(hass: HomeAssistant, slug: str) -> None:
"""Mock install add-on."""
await install_event.wait()
await install_addon_original_side_effect(hass, slug)
install_addon.side_effect = install_addon_side_effect
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={
"url": "ws://host1:5581/ws",
"use_addon": True,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await asyncio.sleep(0.05)
assert entry.state is ConfigEntryState.SETUP_RETRY
assert install_addon.call_count == 1
assert start_addon.call_count == 0
# Check that we only call install add-on once if a task is in progress.
await hass.config_entries.async_reload(entry.entry_id)
await asyncio.sleep(0.05)
assert entry.state is ConfigEntryState.SETUP_RETRY
assert install_addon.call_count == 1
assert start_addon.call_count == 0
install_event.set()
await hass.async_block_till_done()
assert install_addon.call_count == 1
assert start_addon.call_count == 1
async def test_start_addon(
hass: HomeAssistant,
addon_installed: AsyncMock,
addon_info: AsyncMock,
install_addon: AsyncMock,
start_addon: AsyncMock,
) -> None:
"""Test start the Matter Server add-on during entry setup."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={
"url": "ws://host1:5581/ws",
"use_addon": True,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert addon_info.call_count == 1
assert install_addon.call_count == 0
assert start_addon.call_count == 1
assert start_addon.call_args == call(hass, "core_matter_server")
async def test_install_addon(
hass: HomeAssistant,
addon_not_installed: AsyncMock,
addon_store_info: AsyncMock,
install_addon: AsyncMock,
start_addon: AsyncMock,
) -> None:
"""Test install and start the Matter add-on during entry setup."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={
"url": "ws://host1:5581/ws",
"use_addon": True,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert addon_store_info.call_count == 3
assert install_addon.call_count == 1
assert install_addon.call_args == call(hass, "core_matter_server")
assert start_addon.call_count == 1
assert start_addon.call_args == call(hass, "core_matter_server")
async def test_addon_info_failure(
hass: HomeAssistant,
addon_installed: AsyncMock,
addon_info: AsyncMock,
install_addon: AsyncMock,
start_addon: AsyncMock,
) -> None:
"""Test failure to get add-on info for Matter add-on during entry setup."""
addon_info.side_effect = HassioAPIError("Boom")
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={
"url": "ws://host1:5581/ws",
"use_addon": True,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert addon_info.call_count == 1
assert install_addon.call_count == 0
assert start_addon.call_count == 0
@pytest.mark.parametrize(
(
"addon_version",
"update_available",
"update_calls",
"backup_calls",
"update_addon_side_effect",
"create_backup_side_effect",
"connect_side_effect",
),
[
("1.0.0", True, 1, 1, None, None, ServerVersionTooOld("Invalid version")),
("1.0.0", True, 0, 0, None, None, ServerVersionTooNew("Invalid version")),
("1.0.0", False, 0, 0, None, None, ServerVersionTooOld("Invalid version")),
(
"1.0.0",
True,
1,
1,
HassioAPIError("Boom"),
None,
ServerVersionTooOld("Invalid version"),
),
(
"1.0.0",
True,
0,
1,
None,
HassioAPIError("Boom"),
ServerVersionTooOld("Invalid version"),
),
],
)
async def test_update_addon(
hass: HomeAssistant,
addon_installed: AsyncMock,
addon_running: AsyncMock,
addon_info: AsyncMock,
install_addon: AsyncMock,
start_addon: AsyncMock,
create_backup: AsyncMock,
update_addon: AsyncMock,
matter_client: MagicMock,
addon_version: str,
update_available: bool,
update_calls: int,
backup_calls: int,
update_addon_side_effect: Exception | None,
create_backup_side_effect: Exception | None,
connect_side_effect: Exception,
) -> None:
"""Test update the Matter add-on during entry setup."""
addon_info.return_value["version"] = addon_version
addon_info.return_value["update_available"] = update_available
create_backup.side_effect = create_backup_side_effect
update_addon.side_effect = update_addon_side_effect
matter_client.connect.side_effect = connect_side_effect
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={
"url": "ws://host1:5581/ws",
"use_addon": True,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert create_backup.call_count == backup_calls
assert update_addon.call_count == update_calls
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize(
(
"connect_side_effect",
"issue_raised",
),
[
(
ServerVersionTooOld("Invalid version"),
"server_version_version_too_old",
),
(
ServerVersionTooNew("Invalid version"),
"server_version_version_too_new",
),
],
)
async def test_issue_registry_invalid_version(
hass: HomeAssistant,
matter_client: MagicMock,
issue_registry: ir.IssueRegistry,
connect_side_effect: Exception,
issue_raised: str,
) -> None:
"""Test issue registry for invalid version."""
original_connect_side_effect = matter_client.connect.side_effect
matter_client.connect.side_effect = connect_side_effect
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={
"url": "ws://host1:5581/ws",
"use_addon": False,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entry_state = entry.state
assert entry_state is ConfigEntryState.SETUP_RETRY
assert issue_registry.async_get_issue(DOMAIN, issue_raised)
matter_client.connect.side_effect = original_connect_side_effect
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert not issue_registry.async_get_issue(DOMAIN, issue_raised)
@pytest.mark.parametrize(
("stop_addon_side_effect", "entry_state"),
[
(None, ConfigEntryState.NOT_LOADED),
(HassioAPIError("Boom"), ConfigEntryState.LOADED),
],
)
async def test_stop_addon(
hass: HomeAssistant,
matter_client: MagicMock,
addon_installed: AsyncMock,
addon_running: AsyncMock,
addon_info: AsyncMock,
stop_addon: AsyncMock,
stop_addon_side_effect: Exception | None,
entry_state: ConfigEntryState,
) -> None:
"""Test stop the Matter add-on on entry unload if entry is disabled."""
stop_addon.side_effect = stop_addon_side_effect
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={
"url": "ws://host1:5581/ws",
"use_addon": True,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert addon_info.call_count == 1
addon_info.reset_mock()
await hass.config_entries.async_set_disabled_by(
entry.entry_id, ConfigEntryDisabler.USER
)
await hass.async_block_till_done()
assert entry.state == entry_state
assert stop_addon.call_count == 1
assert stop_addon.call_args == call(hass, "core_matter_server")
async def test_remove_entry(
hass: HomeAssistant,
addon_installed: AsyncMock,
stop_addon: AsyncMock,
create_backup: AsyncMock,
uninstall_addon: AsyncMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test remove the config entry."""
# test successful remove without created add-on
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={"integration_created_addon": False},
)
entry.add_to_hass(hass)
assert entry.state is ConfigEntryState.NOT_LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
await hass.config_entries.async_remove(entry.entry_id)
assert entry.state is ConfigEntryState.NOT_LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
# test successful remove with created add-on
entry = MockConfigEntry(
domain=DOMAIN,
title="Matter",
data={"integration_created_addon": True},
)
entry.add_to_hass(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
await hass.config_entries.async_remove(entry.entry_id)
assert stop_addon.call_count == 1
assert stop_addon.call_args == call(hass, "core_matter_server")
assert create_backup.call_count == 1
assert create_backup.call_args == call(
hass,
{"name": "addon_core_matter_server_1.0.0", "addons": ["core_matter_server"]},
partial=True,
)
assert uninstall_addon.call_count == 1
assert uninstall_addon.call_args == call(hass, "core_matter_server")
assert entry.state is ConfigEntryState.NOT_LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
stop_addon.reset_mock()
create_backup.reset_mock()
uninstall_addon.reset_mock()
# test add-on stop failure
entry.add_to_hass(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
stop_addon.side_effect = HassioAPIError()
await hass.config_entries.async_remove(entry.entry_id)
assert stop_addon.call_count == 1
assert stop_addon.call_args == call(hass, "core_matter_server")
assert create_backup.call_count == 0
assert uninstall_addon.call_count == 0
assert entry.state is ConfigEntryState.NOT_LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
assert "Failed to stop the Matter Server add-on" in caplog.text
stop_addon.side_effect = None
stop_addon.reset_mock()
create_backup.reset_mock()
uninstall_addon.reset_mock()
# test create backup failure
entry.add_to_hass(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
create_backup.side_effect = HassioAPIError()
await hass.config_entries.async_remove(entry.entry_id)
assert stop_addon.call_count == 1
assert stop_addon.call_args == call(hass, "core_matter_server")
assert create_backup.call_count == 1
assert create_backup.call_args == call(
hass,
{"name": "addon_core_matter_server_1.0.0", "addons": ["core_matter_server"]},
partial=True,
)
assert uninstall_addon.call_count == 0
assert entry.state is ConfigEntryState.NOT_LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
assert "Failed to create a backup of the Matter Server add-on" in caplog.text
create_backup.side_effect = None
stop_addon.reset_mock()
create_backup.reset_mock()
uninstall_addon.reset_mock()
# test add-on uninstall failure
entry.add_to_hass(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
uninstall_addon.side_effect = HassioAPIError()
await hass.config_entries.async_remove(entry.entry_id)
assert stop_addon.call_count == 1
assert stop_addon.call_args == call(hass, "core_matter_server")
assert create_backup.call_count == 1
assert create_backup.call_args == call(
hass,
{"name": "addon_core_matter_server_1.0.0", "addons": ["core_matter_server"]},
partial=True,
)
assert uninstall_addon.call_count == 1
assert uninstall_addon.call_args == call(hass, "core_matter_server")
assert entry.state is ConfigEntryState.NOT_LOADED
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
assert "Failed to uninstall the Matter Server add-on" in caplog.text
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_remove_config_entry_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
matter_client: MagicMock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that a device can be removed ok."""
assert await async_setup_component(hass, "config", {})
await setup_integration_with_node_fixture(hass, "device_diagnostics", matter_client)
await hass.async_block_till_done()
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
device_entry = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)[0]
entity_id = "light.m5stamp_lighting_app_light"
assert device_entry
assert entity_registry.async_get(entity_id)
assert hass.states.get(entity_id)
client = await hass_ws_client(hass)
response = await client.remove_device(device_entry.id, config_entry.entry_id)
assert response["success"]
await hass.async_block_till_done()
assert not device_registry.async_get(device_entry.id)
assert not entity_registry.async_get(entity_id)
assert not hass.states.get(entity_id)
# This tests needs to be adjusted to remove lingering tasks
@pytest.mark.parametrize("expected_lingering_tasks", [True])
async def test_remove_config_entry_device_no_node(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
matter_client: MagicMock,
integration: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that a device can be removed ok without an existing node."""
assert await async_setup_component(hass, "config", {})
config_entry = integration
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={
(DOMAIN, "deviceid_00000000000004D2-0000000000000005-MatterNodeDevice")
},
)
client = await hass_ws_client(hass)
response = await client.remove_device(device_entry.id, config_entry.entry_id)
assert response["success"]
await hass.async_block_till_done()
assert not device_registry.async_get(device_entry.id)