diff --git a/.coveragerc b/.coveragerc index 05ec729aeff..2f76fa78d0f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -810,6 +810,8 @@ omit = homeassistant/components/moehlenhoff_alpha2/binary_sensor.py homeassistant/components/moehlenhoff_alpha2/climate.py homeassistant/components/moehlenhoff_alpha2/sensor.py + homeassistant/components/monzo/__init__.py + homeassistant/components/monzo/api.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/coordinator.py homeassistant/components/motion_blinds/cover.py diff --git a/.strict-typing b/.strict-typing index 2589b90c998..36bfc6ffac9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -300,6 +300,7 @@ homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* homeassistant.components.modem_callerid.* +homeassistant.components.monzo.* homeassistant.components.moon.* homeassistant.components.mopeka.* homeassistant.components.motionmount.* diff --git a/CODEOWNERS b/CODEOWNERS index 57f29f86a47..4920aeaf075 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -867,6 +867,8 @@ build.json @home-assistant/supervisor /tests/components/moehlenhoff_alpha2/ @j-a-n /homeassistant/components/monoprice/ @etsinko @OnFreund /tests/components/monoprice/ @etsinko @OnFreund +/homeassistant/components/monzo/ @jakemartin-icl +/tests/components/monzo/ @jakemartin-icl /homeassistant/components/moon/ @fabaff @frenck /tests/components/moon/ @fabaff @frenck /homeassistant/components/mopeka/ @bdraco diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py new file mode 100644 index 00000000000..93fef56957e --- /dev/null +++ b/homeassistant/components/monzo/__init__.py @@ -0,0 +1,68 @@ +"""The Monzo integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AuthenticatedMonzoAPI +from .const import DOMAIN +from .data import MonzoData, MonzoSensorData + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Monzo from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + async def async_get_monzo_api_data() -> MonzoSensorData: + monzo_data: MonzoData = hass.data[DOMAIN][entry.entry_id] + accounts = await external_api.user_account.accounts() + pots = await external_api.user_account.pots() + monzo_data.accounts = accounts + monzo_data.pots = pots + return MonzoSensorData(accounts=accounts, pots=pots) + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + external_api = AuthenticatedMonzoAPI( + aiohttp_client.async_get_clientsession(hass), session + ) + + coordinator = DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=async_get_monzo_api_data, + update_interval=timedelta(minutes=1), + ) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = MonzoData(external_api, coordinator) + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + data = hass.data[DOMAIN] + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok and entry.entry_id in data: + data.pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/monzo/api.py b/homeassistant/components/monzo/api.py new file mode 100644 index 00000000000..6862564d343 --- /dev/null +++ b/homeassistant/components/monzo/api.py @@ -0,0 +1,26 @@ +"""API for Monzo bound to Home Assistant OAuth.""" + +from aiohttp import ClientSession +from monzopy import AbstractMonzoApi + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AuthenticatedMonzoAPI(AbstractMonzoApi): + """A Monzo API instance with authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Monzo auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return str(self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/monzo/application_credentials.py b/homeassistant/components/monzo/application_credentials.py new file mode 100644 index 00000000000..f040c150853 --- /dev/null +++ b/homeassistant/components/monzo/application_credentials.py @@ -0,0 +1,15 @@ +"""application_credentials platform the Monzo integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +OAUTH2_AUTHORIZE = "https://auth.monzo.com" +OAUTH2_TOKEN = "https://api.monzo.com/oauth2/token" + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/monzo/config_flow.py b/homeassistant/components/monzo/config_flow.py new file mode 100644 index 00000000000..1d5bc3147b1 --- /dev/null +++ b/homeassistant/components/monzo/config_flow.py @@ -0,0 +1,52 @@ +"""Config flow for Monzo.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + + +class MonzoFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Handle a config flow.""" + + DOMAIN = DOMAIN + + oauth_data: dict[str, Any] + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + async def async_step_await_approval_confirmation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Wait for the user to confirm in-app approval.""" + if user_input is not None: + return self.async_create_entry(title=DOMAIN, data={**self.oauth_data}) + + data_schema = vol.Schema({vol.Required("confirm"): bool}) + + return self.async_show_form( + step_id="await_approval_confirmation", data_schema=data_schema + ) + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow.""" + user_id = str(data[CONF_TOKEN]["user_id"]) + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + self.oauth_data = data + + return await self.async_step_await_approval_confirmation() diff --git a/homeassistant/components/monzo/const.py b/homeassistant/components/monzo/const.py new file mode 100644 index 00000000000..619daf120f7 --- /dev/null +++ b/homeassistant/components/monzo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Monzo integration.""" + +DOMAIN = "monzo" diff --git a/homeassistant/components/monzo/data.py b/homeassistant/components/monzo/data.py new file mode 100644 index 00000000000..c4dd2564c21 --- /dev/null +++ b/homeassistant/components/monzo/data.py @@ -0,0 +1,24 @@ +"""Dataclass for Monzo data.""" + +from dataclasses import dataclass, field +from typing import Any + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AuthenticatedMonzoAPI + + +@dataclass(kw_only=True) +class MonzoSensorData: + """A dataclass for holding sensor data returned by the DataUpdateCoordinator.""" + + accounts: list[dict[str, Any]] = field(default_factory=list) + pots: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class MonzoData(MonzoSensorData): + """A dataclass for holding data stored in hass.data.""" + + external_api: AuthenticatedMonzoAPI + coordinator: DataUpdateCoordinator[MonzoSensorData] diff --git a/homeassistant/components/monzo/entity.py b/homeassistant/components/monzo/entity.py new file mode 100644 index 00000000000..043c06eece0 --- /dev/null +++ b/homeassistant/components/monzo/entity.py @@ -0,0 +1,47 @@ +"""Base entity for Monzo.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .data import MonzoSensorData + + +class MonzoBaseEntity(CoordinatorEntity[DataUpdateCoordinator[MonzoSensorData]]): + """Common base for Monzo entities.""" + + _attr_attribution = "Data provided by Monzo" + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator[MonzoSensorData], + index: int, + device_model: str, + data_accessor: Callable[[MonzoSensorData], list[dict[str, Any]]], + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator) + self.index = index + self._data_accessor = data_accessor + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(self.data["id"]))}, + manufacturer="Monzo", + model=device_model, + name=self.data["name"], + ) + + @property + def data(self) -> dict[str, Any]: + """Shortcut to access coordinator data for the entity.""" + return self._data_accessor(self.coordinator.data)[self.index] diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json new file mode 100644 index 00000000000..8dd084e2b95 --- /dev/null +++ b/homeassistant/components/monzo/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "monzo", + "name": "Monzo", + "codeowners": ["@jakemartin-icl"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/monzo", + "iot_class": "cloud_polling", + "requirements": ["monzopy==1.1.0"] +} diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py new file mode 100644 index 00000000000..be13608ca3b --- /dev/null +++ b/homeassistant/components/monzo/sensor.py @@ -0,0 +1,123 @@ +"""Platform for sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN +from .data import MonzoSensorData +from .entity import MonzoBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class MonzoSensorEntityDescription(SensorEntityDescription): + """Describes Monzo sensor entity.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +ACCOUNT_SENSORS = ( + MonzoSensorEntityDescription( + key="balance", + translation_key="balance", + value_fn=lambda data: data["balance"]["balance"] / 100, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="GBP", + suggested_display_precision=2, + ), + MonzoSensorEntityDescription( + key="total_balance", + translation_key="total_balance", + value_fn=lambda data: data["balance"]["total_balance"] / 100, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="GBP", + suggested_display_precision=2, + ), +) + +POT_SENSORS = ( + MonzoSensorEntityDescription( + key="pot_balance", + translation_key="pot_balance", + value_fn=lambda data: data["balance"] / 100, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="GBP", + suggested_display_precision=2, + ), +) + +MODEL_POT = "Pot" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Defer sensor setup to the shared sensor module.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator + + accounts = [ + MonzoSensor( + coordinator, + entity_description, + index, + account["name"], + lambda x: x.accounts, + ) + for entity_description in ACCOUNT_SENSORS + for index, account in enumerate( + hass.data[DOMAIN][config_entry.entry_id].accounts + ) + ] + + pots = [ + MonzoSensor(coordinator, entity_description, index, MODEL_POT, lambda x: x.pots) + for entity_description in POT_SENSORS + for index, _pot in enumerate(hass.data[DOMAIN][config_entry.entry_id].pots) + ] + + async_add_entities(accounts + pots) + + +class MonzoSensor(MonzoBaseEntity, SensorEntity): + """Represents a Monzo sensor.""" + + entity_description: MonzoSensorEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[MonzoSensorData], + entity_description: MonzoSensorEntityDescription, + index: int, + device_model: str, + data_accessor: Callable[[MonzoSensorData], list[dict[str, Any]]], + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, index, device_model, data_accessor) + self.entity_description = entity_description + self._attr_unique_id = f"{self.data['id']}_{entity_description.key}" + + @property + def native_value(self) -> StateType: + """Return the state.""" + + try: + state = self.entity_description.value_fn(self.data) + except (KeyError, ValueError): + return None + + return state diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json new file mode 100644 index 00000000000..963c02232f1 --- /dev/null +++ b/homeassistant/components/monzo/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "await_approval_confirmation": { + "title": "Confirm in Monzo app", + "description": "Before proceeding, open your Monzo app and approve the request from Home Assistant.", + "data": { + "confirm": "I've approved" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "entity": { + "sensor": { + "balance": { + "name": "Balance" + }, + "total_balance": { + "name": "Total Balance" + }, + "pot_balance": { + "name": "Balance" + } + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 15ae2e369de..c576f242e30 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -17,6 +17,7 @@ APPLICATION_CREDENTIALS = [ "lametric", "lyric", "microbees", + "monzo", "myuplink", "neato", "nest", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1396a161bef..a9a387de473 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -331,6 +331,7 @@ FLOWS = { "modern_forms", "moehlenhoff_alpha2", "monoprice", + "monzo", "moon", "mopeka", "motion_blinds", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c5e7a842c45..ceb3d9955d4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3739,6 +3739,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "monzo": { + "name": "Monzo", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "moon": { "integration_type": "service", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index becf1b7751d..6da57f22252 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2762,6 +2762,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.monzo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.moon.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 71772b13477..3689912e8b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1325,6 +1325,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.3.0 +# homeassistant.components.monzo +monzopy==1.1.0 + # homeassistant.components.mopeka mopeka-iot-ble==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad225de6353..572ef59ebdd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1067,6 +1067,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.3.0 +# homeassistant.components.monzo +monzopy==1.1.0 + # homeassistant.components.mopeka mopeka-iot-ble==0.7.0 diff --git a/tests/components/monzo/__init__.py b/tests/components/monzo/__init__.py new file mode 100644 index 00000000000..db732171521 --- /dev/null +++ b/tests/components/monzo/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Monzo integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/monzo/conftest.py b/tests/components/monzo/conftest.py new file mode 100644 index 00000000000..451fd6b409d --- /dev/null +++ b/tests/components/monzo/conftest.py @@ -0,0 +1,125 @@ +"""Fixtures for tests.""" + +import time +from unittest.mock import AsyncMock, patch + +from monzopy.monzopy import UserAccount +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.monzo.api import AuthenticatedMonzoAPI +from homeassistant.components.monzo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +TEST_ACCOUNTS = [ + { + "id": "acc_curr", + "name": "Current Account", + "type": "uk_retail", + "balance": {"balance": 123, "total_balance": 321}, + }, + { + "id": "acc_flex", + "name": "Flex", + "type": "uk_monzo_flex", + "balance": {"balance": 123, "total_balance": 321}, + }, +] +TEST_POTS = [ + { + "id": "pot_savings", + "name": "Savings", + "style": "savings", + "balance": 134578, + "currency": "GBP", + "type": "instant_access", + } +] +TITLE = "jake" +USER_ID = 12345 + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET, DOMAIN), + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def polling_config_entry(expires_at: int) -> MockConfigEntry: + """Create Monzo entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=str(USER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": str(USER_ID), + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 60, + "expires_at": time.time() + 1000, + }, + "profile": TITLE, + }, + ) + + +@pytest.fixture(name="basic_monzo") +def mock_basic_monzo(): + """Mock monzo with one pot.""" + + mock = AsyncMock(spec=AuthenticatedMonzoAPI) + mock_user_account = AsyncMock(spec=UserAccount) + + mock_user_account.accounts.return_value = [] + + mock_user_account.pots.return_value = TEST_POTS + + mock.user_account = mock_user_account + + with patch( + "homeassistant.components.monzo.AuthenticatedMonzoAPI", + return_value=mock, + ): + yield mock + + +@pytest.fixture(name="monzo") +def mock_monzo(): + """Mock monzo.""" + + mock = AsyncMock(spec=AuthenticatedMonzoAPI) + mock_user_account = AsyncMock(spec=UserAccount) + + mock_user_account.accounts.return_value = TEST_ACCOUNTS + mock_user_account.pots.return_value = TEST_POTS + + mock.user_account = mock_user_account + + with patch( + "homeassistant.components.monzo.AuthenticatedMonzoAPI", + return_value=mock, + ): + yield mock diff --git a/tests/components/monzo/snapshots/test_sensor.ambr b/tests/components/monzo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8a6b39768b0 --- /dev/null +++ b/tests/components/monzo/snapshots/test_sensor.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_all_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Current Account Balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.current_account_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_all_entities.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Current Account Total Balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.current_account_total_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.21', + }) +# --- +# name: test_all_entities.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Flex Balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.flex_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_all_entities.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Flex Total Balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.flex_total_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.21', + }) +# --- diff --git a/tests/components/monzo/test_config_flow.py b/tests/components/monzo/test_config_flow.py new file mode 100644 index 00000000000..dc3138e6a0d --- /dev/null +++ b/tests/components/monzo/test_config_flow.py @@ -0,0 +1,138 @@ +"""Tests for config flow.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components.monzo.application_credentials import ( + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.components.monzo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import setup_integration +from .conftest import CLIENT_ID, USER_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}/?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": 600, + }, + ) + with patch( + "homeassistant.components.monzo.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 0 + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "await_approval_confirmation" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"confirm": True} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == DOMAIN + assert "result" in result + assert result["result"].unique_id == "600" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + + +async def test_config_non_unique_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup a non-unique profile.""" + await setup_integration(hass, polling_config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}/?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": str(USER_ID), + }, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/monzo/test_sensor.py b/tests/components/monzo/test_sensor.py new file mode 100644 index 00000000000..6b5ca4a2349 --- /dev/null +++ b/tests/components/monzo/test_sensor.py @@ -0,0 +1,141 @@ +"""Tests for the Monzo component.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.monzo.const import 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 +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import setup_integration +from .conftest import TEST_ACCOUNTS, TEST_POTS + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator + +EXPECTED_VALUE_GETTERS = { + "balance": lambda x: x["balance"]["balance"] / 100, + "total_balance": lambda x: x["balance"]["total_balance"] / 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, +) -> None: + """Test entities enabled by default.""" + await setup_integration(hass, polling_config_entry) + entity_registry: EntityRegistry = er.async_get(hass) + + 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() + 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_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, +) -> None: + """Test all entities.""" + await setup_integration(hass, polling_config_entry) + + for acc in TEST_ACCOUNTS: + for sensor in ACCOUNT_SENSORS: + entity_id = await async_get_entity_id(hass, acc["id"], sensor) + assert hass.states.get(entity_id) == snapshot + + +async def test_update_failed( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test all entities.""" + await setup_integration(hass, polling_config_entry) + + monzo.user_account.accounts.side_effect = Exception + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + 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