"""Tests for the Monzo component.""" from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from monzopy import InvalidMonzoAPIResponseError import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.monzo.const import ( DEVICE_MODEL_ACCOUNT, DEVICE_MODEL_POT, DOMAIN, ) from homeassistant.components.monzo.sensor import ( ACCOUNT_SENSORS, POT_SENSORS, MonzoSensorEntityDescription, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.helpers import ( device_registry as dr, entity_registry as er, label_registry as lr, ) from . import setup_integration from .conftest import TEST_ACCOUNTS, TEST_POTS from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import ClientSessionGenerator EXPECTED_VALUE_GETTERS = { "balance": lambda x: x["balance"]["balance"] / 100, "total_balance": lambda x: x["balance"]["total_balance"] / 100, "spend_today": lambda x: abs(x["balance"]["spend_today"]) / 100, "pot_balance": lambda x: x["balance"] / 100, } async def async_get_entity_id( hass: HomeAssistant, acc_id: str, description: MonzoSensorEntityDescription, ) -> str | None: """Get an entity id for a user's attribute.""" entity_registry = er.async_get(hass) unique_id = f"{acc_id}_{description.key}" return entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id) def async_assert_state_equals( entity_id: str, state_obj: State, expected: Any, description: MonzoSensorEntityDescription, ) -> None: """Assert at given state matches what is expected.""" assert state_obj, f"Expected entity {entity_id} to exist but it did not" assert state_obj.state == str(expected), ( f"Expected {expected} but was {state_obj.state} " f"for measure {description.name}, {entity_id}" ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_default_enabled_entities( hass: HomeAssistant, monzo: AsyncMock, polling_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, entity_registry: er.EntityRegistry, ) -> None: """Test entities enabled by default.""" await setup_integration(hass, polling_config_entry) for acc in TEST_ACCOUNTS: for sensor_description in ACCOUNT_SENSORS: entity_id = await async_get_entity_id(hass, acc["id"], sensor_description) assert entity_id assert entity_registry.async_is_registered(entity_id) state = hass.states.get(entity_id) assert state.state == str( EXPECTED_VALUE_GETTERS[sensor_description.key](acc) ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_unavailable_entity( hass: HomeAssistant, basic_monzo: AsyncMock, polling_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test entities enabled by default.""" await setup_integration(hass, polling_config_entry) basic_monzo.user_account.pots.return_value = [{"id": "pot_savings"}] freezer.tick(timedelta(minutes=100)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) entity_id = await async_get_entity_id(hass, TEST_POTS[0]["id"], POT_SENSORS[0]) state = hass.states.get(entity_id) assert state.state == "unknown" @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_deleted_pot_is_removed_and_can_be_rediscovered( hass: HomeAssistant, basic_monzo: AsyncMock, polling_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, label_registry: lr.LabelRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test a deleted pot is removed without affecting another pot.""" holiday_pot = { "id": "pot_holiday", "name": "Holiday", "balance": 12345, "currency": "EUR", } basic_monzo.user_account.pots.return_value = [TEST_POTS[0], holiday_pot] await setup_integration(hass, polling_config_entry) deleted_entity_id = await async_get_entity_id( hass, TEST_POTS[0]["id"], POT_SENSORS[0] ) holiday_entity_id = await async_get_entity_id( hass, holiday_pot["id"], POT_SENSORS[0] ) assert deleted_entity_id assert holiday_entity_id label = label_registry.async_create("Savings") deleted_entity_id = entity_registry.async_update_entity( deleted_entity_id, labels={label.label_id}, name="Rainy day fund", new_entity_id="sensor.rainy_day_fund", ).entity_id await hass.async_block_till_done() basic_monzo.user_account.pots.return_value = [{**holiday_pot, "balance": 54321}] freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(deleted_entity_id) is None assert entity_registry.async_get(deleted_entity_id) is None assert ( device_registry.async_get_device_by_identifier( (DOMAIN, TEST_POTS[0]["id"]), polling_config_entry.entry_id ) is None ) assert hass.states.get(holiday_entity_id).state == "543.21" basic_monzo.user_account.pots.return_value = [TEST_POTS[0], holiday_pot] freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) restored_entity_id = await async_get_entity_id( hass, TEST_POTS[0]["id"], POT_SENSORS[0] ) assert restored_entity_id == deleted_entity_id restored_state = hass.states.get(restored_entity_id) assert restored_state is not None assert restored_state.state == "1345.78" restored_entry = entity_registry.async_get(restored_entity_id) assert restored_entry is not None assert restored_entry.labels == {label.label_id} assert restored_entry.name == "Rainy day fund" @pytest.mark.usefixtures("entity_registry_enabled_by_default", "monzo") async def test_stale_resource_is_removed_during_initial_refresh( hass: HomeAssistant, polling_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test a resource removed while Home Assistant was stopped is cleaned up.""" polling_config_entry.add_to_hass(hass) stale_device = device_registry.async_get_or_create( config_entry_id=polling_config_entry.entry_id, identifiers={(DOMAIN, "pot_deleted")}, name="Deleted pot", ) stale_entity = entity_registry.async_get_or_create( domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id="pot_deleted_pot_balance", config_entry=polling_config_entry, device_id=stale_device.id, ) assert await hass.config_entries.async_setup(polling_config_entry.entry_id) assert device_registry.async_get(stale_device.id) is None assert entity_registry.async_get(stale_entity.entity_id) is None @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_new_accounts_and_pots_are_discovered( hass: HomeAssistant, monzo: AsyncMock, polling_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test sensors are added for accounts and pots discovered after setup.""" await setup_integration(hass, polling_config_entry) new_account = { "id": "acc_joint", "name": "Joint Account", "type": "uk_retail_joint", "balance": {"balance": 456, "total_balance": 654, "currency": "GBP"}, "owners": [ {"preferred_name": "Jake Martin"}, {"preferred_name": "Jane Martin"}, ], } new_pot = { "id": "pot_holiday", "name": "Holiday", "balance": 12345, "currency": "EUR", } monzo.user_account.accounts.return_value = [*TEST_ACCOUNTS, new_account] monzo.user_account.pots.return_value = [*TEST_POTS, new_pot] freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) account_entity_id = await async_get_entity_id( hass, new_account["id"], ACCOUNT_SENSORS[0] ) pot_entity_id = await async_get_entity_id(hass, new_pot["id"], POT_SENSORS[0]) assert account_entity_id is not None account_state = hass.states.get(account_entity_id) assert account_state is not None assert account_state.state == "4.56" assert pot_entity_id is not None pot_state = hass.states.get(pot_entity_id) assert pot_state is not None assert pot_state.state == "123.45" account_device = device_registry.async_get_device_by_identifier( (DOMAIN, new_account["id"]), polling_config_entry.entry_id ) pot_device = device_registry.async_get_device_by_identifier( (DOMAIN, new_pot["id"]), polling_config_entry.entry_id ) assert account_device is not None assert account_device.name == "Joint Account — Jake Martin & Jane Martin" assert account_device.model == DEVICE_MODEL_ACCOUNT assert pot_device is not None assert pot_device.name == "Holiday" assert pot_device.model == DEVICE_MODEL_POT @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, monzo: AsyncMock, polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" with patch("homeassistant.components.monzo.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, polling_config_entry) await snapshot_platform( hass, entity_registry, snapshot, polling_config_entry.entry_id ) async def test_update_failed( hass: HomeAssistant, snapshot: SnapshotAssertion, monzo: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, ) -> None: """Test all entities.""" await setup_integration(hass, polling_config_entry) monzo.user_account.accounts.side_effect = InvalidMonzoAPIResponseError( {"acc_id": None}, "account_id" ) freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert "Invalid Monzo API response." in caplog.text assert "account_id" in caplog.text assert "acc_id" in caplog.text entity_id = await async_get_entity_id( hass, TEST_ACCOUNTS[0]["id"], ACCOUNT_SENSORS[0] ) state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE