Files
core/tests/components/netatmo/test_init.py
T

921 lines
32 KiB
Python

"""The tests for Netatmo component."""
from collections.abc import Callable, Coroutine, Iterator
from datetime import timedelta
from functools import partial
from itertools import pairwise
from time import time
from typing import Any
from unittest.mock import AsyncMock, patch
import aiohttp
from freezegun.api import FrozenDateTimeFactory
import pyatmo
from pyatmo.const import ALL_SCOPES
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import cloud, webhook
from homeassistant.components.netatmo import DOMAIN, coordinator
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_WEBHOOK_ID,
EVENT_STATE_CHANGED,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import (
OAuth2TokenRequestReauthError,
ServiceValidationError,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .common import (
FAKE_WEBHOOK_ACTIVATION,
HOME_ID,
fake_post_request,
selected_platforms,
simulate_webhook,
)
from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed
from tests.components.cloud import mock_cloud
from tests.typing import WebSocketGenerator
# Fake webhook thermostat mode change to "Max"
FAKE_WEBHOOK = {
"room_id": "2746182631",
"home": {
"id": HOME_ID,
"name": "MYHOME",
"country": "DE",
"rooms": [
{
"id": "2746182631",
"name": "Livingroom",
"type": "livingroom",
"therm_setpoint_mode": "max",
"therm_setpoint_end_time": 1612749189,
}
],
"modules": [
{"id": "12:34:56:00:01:ae", "name": "Livingroom", "type": "NATherm1"}
],
},
"mode": "max",
"event_type": "set_point",
"push_type": "display_change",
}
SWITCH_ENTITY_ID = "switch.prise"
# The switch's home is polled every 150s with the cloud credentials the test
# config entry uses
HOME_POLL_INTERVAL = 150
# Scheduled updates to drive, enough for the longest failure script to run
# through the escalating retry backoff
SCHEDULED_UPDATES = 80
async def test_setup_component(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test setup and teardown of the netatmo component."""
with (
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth,
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
fake_post_request, hass
)
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
mock_auth.assert_called_once()
mock_impl.assert_called_once()
mock_webhook.assert_called_once()
assert config_entry.state is ConfigEntryState.LOADED
assert hass.config_entries.async_entries(DOMAIN)
assert len(hass.states.async_all()) > 0
for entry in hass.config_entries.async_entries("netatmo"):
await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
assert not hass.config_entries.async_entries(DOMAIN)
async def test_setup_component_with_config(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test setup of the netatmo component with dev account."""
fake_post_hits = 0
async def fake_post(*args: Any, **kwargs: Any):
"""Fake error during requesting backend data."""
nonlocal fake_post_hits
fake_post_hits += 1
return await fake_post_request(hass, *args, **kwargs)
with (
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth,
patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["sensor"]),
):
mock_auth.return_value.async_post_api_request.side_effect = fake_post
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(
hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}}
)
await hass.async_block_till_done()
assert fake_post_hits >= 8
mock_impl.assert_called_once()
mock_webhook.assert_called_once()
assert hass.config_entries.async_entries(DOMAIN)
assert len(hass.states.async_all()) > 0
async def test_setup_component_with_webhook(
hass: HomeAssistant, config_entry, netatmo_auth
) -> None:
"""Test setup and teardown of the netatmo component with webhook registration."""
with selected_platforms(
[Platform.CAMERA, Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR]
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK_ACTIVATION)
assert len(hass.states.async_all()) > 0
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK_ACTIVATION)
# Assert webhook is established successfully
climate_entity_livingroom = "climate.livingroom_livingroom"
assert hass.states.get(climate_entity_livingroom).state == "auto"
await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK)
assert hass.states.get(climate_entity_livingroom).state == "heat"
for entry in hass.config_entries.async_entries("netatmo"):
await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
async def test_no_deprecation_issue_on_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
netatmo_auth: AsyncMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test the automatic webhook lifecycle does not raise the deprecation issue."""
with selected_platforms([Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert not issue_registry.async_get_issue(
DOMAIN, "deprecated_service_register_webhook"
)
assert not issue_registry.async_get_issue(
DOMAIN, "deprecated_service_unregister_webhook"
)
@pytest.mark.parametrize(
("service", "expected_registered"),
[
pytest.param("register_webhook", True, id="register"),
pytest.param("unregister_webhook", False, id="unregister"),
],
)
async def test_deprecated_webhook_service(
hass: HomeAssistant,
config_entry: MockConfigEntry,
netatmo_auth: AsyncMock,
issue_registry: ir.IssueRegistry,
service: str,
expected_registered: bool,
) -> None:
"""Test the deprecated webhook actions still work and raise a repair issue."""
with selected_platforms([Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
assert webhook_id in hass.data[webhook.DOMAIN]
# register_webhook re-registers the already-active webhook without
# raising; unregister_webhook tears it down.
await hass.services.async_call(DOMAIN, service, blocking=True)
assert (webhook_id in hass.data[webhook.DOMAIN]) is expected_registered
assert issue_registry.async_get_issue(DOMAIN, f"deprecated_service_{service}")
@pytest.mark.parametrize("service", ["register_webhook", "unregister_webhook"])
async def test_deprecated_webhook_service_not_loaded(
hass: HomeAssistant,
service: str,
) -> None:
"""Test calling a webhook action without a loaded entry raises."""
await async_setup_component(hass, DOMAIN, {})
with pytest.raises(ServiceValidationError):
await hass.services.async_call(DOMAIN, service, blocking=True)
async def test_setup_without_https(
hass: HomeAssistant, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture
) -> None:
"""Test if set up with cloud link and without https."""
hass.config.components.add("cloud")
with (
patch(
"homeassistant.helpers.network.get_url",
return_value="http://example.nabu.casa",
),
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth"
) as mock_auth,
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_async_generate_url,
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
fake_post_request, hass
)
mock_async_generate_url.return_value = "http://example.com"
assert await async_setup_component(
hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}}
)
await hass.async_block_till_done()
mock_auth.assert_called_once()
mock_async_generate_url.assert_called_once()
assert "https and port 443 is required to register the webhook" in caplog.text
async def test_setup_with_cloud(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test if set up with active cloud subscription."""
await mock_cloud(hass)
await hass.async_block_till_done()
with (
patch("homeassistant.components.cloud.async_is_logged_in", return_value=True),
patch.object(cloud, "async_is_connected", return_value=True),
patch.object(cloud, "async_active_subscription", return_value=True),
patch(
"homeassistant.components.cloud.async_create_cloudhook",
return_value="https://hooks.nabu.casa/ABCD",
) as fake_create_cloudhook,
patch(
"homeassistant.components.cloud.async_delete_cloudhook"
) as fake_delete_cloudhook,
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth"
) as mock_auth,
patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []),
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
fake_post_request, hass
)
assert await async_setup_component(
hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}}
)
assert cloud.async_active_subscription(hass) is True
assert cloud.async_is_connected(hass) is True
fake_create_cloudhook.assert_called_once()
assert (
hass.config_entries.async_entries("netatmo")[0].data["cloudhook_url"]
== "https://hooks.nabu.casa/ABCD"
)
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN)
for entry in hass.config_entries.async_entries("netatmo"):
await hass.config_entries.async_remove(entry.entry_id)
fake_delete_cloudhook.assert_called_once()
await hass.async_block_till_done()
assert not hass.config_entries.async_entries(DOMAIN)
async def test_setup_with_cloudhook(hass: HomeAssistant) -> None:
"""Test if set up with active cloud subscription and cloud hook."""
config_entry = MockConfigEntry(
domain="netatmo",
data={
"auth_implementation": "cloud",
"cloudhook_url": "https://hooks.nabu.casa/ABCD",
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 1000,
"scope": ALL_SCOPES,
},
},
)
config_entry.add_to_hass(hass)
await mock_cloud(hass)
await hass.async_block_till_done()
with (
patch("homeassistant.components.cloud.async_is_logged_in", return_value=True),
patch("homeassistant.components.cloud.async_is_connected", return_value=True),
patch.object(cloud, "async_active_subscription", return_value=True),
patch(
"homeassistant.components.cloud.async_create_cloudhook",
return_value="https://hooks.nabu.casa/ABCD",
) as fake_create_cloudhook,
patch(
"homeassistant.components.cloud.async_delete_cloudhook"
) as fake_delete_cloudhook,
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth"
) as mock_auth,
patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []),
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url",
),
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
fake_post_request, hass
)
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(hass, DOMAIN, {})
assert cloud.async_active_subscription(hass) is True
assert (
hass.config_entries.async_entries("netatmo")[0].data["cloudhook_url"]
== "https://hooks.nabu.casa/ABCD"
)
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN)
fake_create_cloudhook.assert_not_called()
for config_entry in hass.config_entries.async_entries("netatmo"):
await hass.config_entries.async_remove(config_entry.entry_id)
fake_delete_cloudhook.assert_called_once()
await hass.async_block_till_done()
assert not hass.config_entries.async_entries(DOMAIN)
async def test_setup_component_with_delay(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test setup of the netatmo component with delayed startup."""
hass.set_state(CoreState.not_running)
with (
patch(
"pyatmo.AbstractAsyncAuth.async_addwebhook", side_effect=AsyncMock()
) as mock_addwebhook,
patch(
"pyatmo.AbstractAsyncAuth.async_dropwebhook", side_effect=AsyncMock()
) as mock_dropwebhook,
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
patch(
"pyatmo.AbstractAsyncAuth.async_post_api_request",
side_effect=partial(fake_post_request, hass),
) as mock_post_api_request,
patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["light"]),
):
assert await async_setup_component(
hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}}
)
await hass.async_block_till_done()
assert mock_post_api_request.call_count == 7
mock_impl.assert_called_once()
mock_webhook.assert_not_called()
await hass.async_start()
await hass.async_block_till_done()
mock_webhook.assert_called_once()
# Fake webhook activation
await simulate_webhook(
hass, config_entry.data[CONF_WEBHOOK_ID], FAKE_WEBHOOK_ACTIVATION
)
await hass.async_block_till_done()
mock_addwebhook.assert_called_once()
mock_dropwebhook.assert_not_awaited()
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=60),
)
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN)
assert len(hass.states.async_all()) > 0
await hass.async_stop()
mock_dropwebhook.assert_called_once()
async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None:
"""Test handling of invalid token scope."""
config_entry = MockConfigEntry(
domain="netatmo",
data={
"auth_implementation": "cloud",
"token": {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
"expires_at": time() + 1000,
"scope": "read_smokedetector read_thermostat write_thermostat",
},
},
options={},
)
config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth,
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
fake_post_request, hass
)
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
mock_auth.assert_not_called()
mock_impl.assert_called_once()
mock_webhook.assert_not_called()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
assert hass.config_entries.async_entries(DOMAIN)
# Test a reauth flow is initiated
assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1
for config_entry in hass.config_entries.async_entries("netatmo"):
await hass.config_entries.async_remove(config_entry.entry_id)
async def test_setup_component_invalid_token(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test handling of invalid token."""
async def fake_ensure_valid_token(*args, **kwargs):
raise OAuth2TokenRequestReauthError(
request_info=aiohttp.client.RequestInfo(
url="http://example.com",
method="GET",
headers={},
real_url="http://example.com",
),
status=400,
history=(),
domain="netatmo",
)
with (
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth,
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
) as mock_impl,
patch(
"homeassistant.components.netatmo.webhook.webhook_generate_url"
) as mock_webhook,
patch("homeassistant.components.netatmo.OAuth2Session") as mock_session,
):
mock_auth.return_value.async_post_api_request.side_effect = partial(
fake_post_request, hass
)
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
mock_session.return_value.async_ensure_token_valid.side_effect = (
fake_ensure_valid_token
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
mock_auth.assert_not_called()
mock_impl.assert_called_once()
mock_webhook.assert_not_called()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
assert hass.config_entries.async_entries(DOMAIN)
# Test a reauth flow is initiated
assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1
for entry in hass.config_entries.async_entries("netatmo"):
await hass.config_entries.async_remove(entry.entry_id)
async def test_devices(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
netatmo_auth: AsyncMock,
) -> None:
"""Test devices are registered."""
with selected_platforms(
[
Platform.CAMERA,
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
assert device_entries
for device_entry in device_entries:
identifier = list(device_entry.identifiers)[0]
assert device_entry == snapshot(name=f"{identifier[0]}-{identifier[1]}")
async def test_device_remove_devices(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
config_entry: MockConfigEntry,
netatmo_auth: AsyncMock,
) -> None:
"""Test we can only remove a device that no longer exists."""
assert await async_setup_component(hass, "config", {})
with selected_platforms([Platform.CLIMATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
climate_entity_livingroom = "climate.livingroom_livingroom"
entity = entity_registry.async_get(climate_entity_livingroom)
device_entry = device_registry.async_get(entity.device_id)
client = await hass_ws_client(hass)
response = await client.remove_device(device_entry.id, config_entry.entry_id)
assert not response["success"]
dead_device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, "remove-device-id")},
)
response = await client.remove_device(dead_device_entry.id, config_entry.entry_id)
assert response["success"]
async def test_oauth_implementation_not_available(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
("platform", "entity_id", "module_id", "initial_state"),
[
pytest.param(
"switch", "switch.prise", "12:34:56:80:00:12:ac:f2", "on", id="switch"
),
pytest.param(
"cover", "cover.entrance_blinds", "0009999992", "closed", id="cover"
),
pytest.param(
"fan",
"fan.centralized_ventilation_controler",
"12:34:56:00:01:01:01:b1",
"on",
id="fan",
),
pytest.param(
"light",
"light.unknown_00_11_22_33_00_11_45_fe",
"00:11:22:33:00:11:45:fe",
"off",
id="light",
),
pytest.param(
"button",
"button.entrance_blinds_preferred_position",
"0009999992",
STATE_UNKNOWN,
id="button",
),
],
)
async def test_entity_unavailable_when_device_unreachable(
hass: HomeAssistant,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
platform: str,
entity_id: str,
module_id: str,
initial_state: str,
) -> None:
"""Test that entities become unavailable when their device is unreachable."""
reachable = True
def set_reachable(payload: dict) -> None:
home = payload.get("body", {}).get("home")
if not isinstance(home, dict):
return
for module in home.get("modules", []):
if module.get("id") == module_id:
module["reachable"] = reachable
async def fake_post(*args: Any, **kwargs: Any):
return await fake_post_request(
hass, *args, msg_callback=set_reachable, **kwargs
)
with (
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth"
) as mock_auth,
patch("homeassistant.components.netatmo.coordinator.PLATFORMS", [platform]),
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
return_value=AsyncMock(),
),
patch("homeassistant.components.netatmo.webhook.webhook_generate_url"),
):
mock_auth.return_value.async_post_api_request.side_effect = fake_post
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == initial_state
reachable = False
for _ in range(11):
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
async def _setup_switch_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
fake_post: Callable[..., Coroutine[Any, Any, Any]],
) -> None:
"""Set up the switch platform with a custom API request side effect."""
with (
patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth"
) as mock_auth,
patch(
"homeassistant.components.netatmo.coordinator.PLATFORMS", [Platform.SWITCH]
),
patch(
"homeassistant.components.netatmo.async_get_config_entry_implementation",
return_value=AsyncMock(),
),
patch("homeassistant.components.netatmo.webhook.webhook_generate_url"),
):
mock_auth.return_value.async_post_api_request.side_effect = fake_post
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.parametrize(
"failure_script",
[
pytest.param((True,), id="single_error"),
pytest.param((True, True), id="errors_within_tolerance"),
pytest.param(
(True, True, False, True, True), id="error_count_reset_by_success"
),
],
)
async def test_entity_stays_available_through_tolerated_errors(
hass: HomeAssistant,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
failure_script: tuple[bool, ...],
) -> None:
"""Test that entities do not flicker when up to two updates in a row fail."""
# Scripted per home status request of the switch's home, so that the number
# of consecutive errors does not depend on when the updates happen to run
script: Iterator[bool] = iter(())
failures = 0
async def fake_post(*args: Any, **kwargs: Any):
nonlocal failures
if (
kwargs.get("endpoint", "").endswith("homestatus")
and kwargs.get("params", {}).get("home_id") == HOME_ID
and next(script, False)
):
failures += 1
raise TimeoutError
return await fake_post_request(hass, *args, **kwargs)
await _setup_switch_platform(hass, config_entry, fake_post)
assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON
# Collect every state the entity takes on from here, so that a tolerated
# error cannot go unnoticed by recovering before the final assertion
state_changes = async_capture_events(hass, EVENT_STATE_CHANGED)
script = iter(failure_script)
for _ in range(SCHEDULED_UPDATES):
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert failures == sum(failure_script)
assert not [
event for event in state_changes if event.data["entity_id"] == SWITCH_ENTITY_ID
]
@pytest.mark.parametrize(
"error",
[TimeoutError, pyatmo.ApiError],
ids=["timeout", "api_error"],
)
async def test_entity_unavailable_after_three_failed_updates(
hass: HomeAssistant,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
error: type[Exception],
) -> None:
"""Test that entities go unavailable once three updates in a row fail."""
failing = False
failures = 0
async def fake_post(*args: Any, **kwargs: Any):
nonlocal failures
if (
failing
and kwargs.get("endpoint", "").endswith("homestatus")
and kwargs.get("params", {}).get("home_id") == HOME_ID
):
failures += 1
raise error
return await fake_post_request(hass, *args, **kwargs)
await _setup_switch_platform(hass, config_entry, fake_post)
assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON
failing = True
for _ in range(SCHEDULED_UPDATES):
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert failures >= 3
assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_UNAVAILABLE
async def test_failed_updates_are_retried_with_escalating_backoff(
hass: HomeAssistant,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that a failing home is retried promptly at first, then less often."""
failing = False
request_times: list[float] = []
async def fake_post(*args: Any, **kwargs: Any):
if (
failing
and kwargs.get("endpoint", "").endswith("homestatus")
and kwargs.get("params", {}).get("home_id") == HOME_ID
):
request_times.append(time())
raise TimeoutError
return await fake_post_request(hass, *args, **kwargs)
with patch.object(coordinator, "MAX_ERROR_BACKOFF", 4 * HOME_POLL_INTERVAL):
await _setup_switch_platform(hass, config_entry, fake_post)
failing = True
for _ in range(SCHEDULED_UPDATES):
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
gaps = [round(later - earlier) for earlier, later in pairwise(request_times)]
# The first retry comes at the regular poll interval (rounded up to the next
# scheduled update), the delay then doubles per consecutive error until the
# patched cap of 600s is reached
assert gaps == [180, 300, 600, 600]