Add Monzo integration (#101731)

* Initial monzo implementation

* Tests and fixes

* Extracted api to pypi package

* Add app confirmation step

* Corrected data path for accounts

* Removed useless check

* Improved tests

* Exclude partially tested files from coverage check

* Use has_entity_name naming

* Bumped monzopy to 1.0.10

* Remove commented out code

* Remove reauth from initial PR

* Remove useless code

* Correct comment

* Remove reauth tests

* Remove device triggers from intial PR

* Set attr outside constructor

* Remove f-strings where no longer needed in entity.py

* Rename field to make clearer it's a Callable

* Correct native_unit_of_measurement

* Remove pot transfer service from intial PR

* Remove reauth string

* Remove empty fields in manifest.json

* Freeze SensorEntityDescription and remove Mixin

Also use list comprehensions for producing sensor lists

* Use consts in application_credentials.py

* Revert "Remove useless code"

Apparently this wasn't useless

This reverts commit c6b7109e47202f866c766ea4c16ce3eb0588795b.

* Ruff and pylint style fixes

* Bumped monzopy to 1.1.0

Adds support for joint/business/etc account pots

* Update test snapshot

* Rename AsyncConfigEntryAuth

* Use dataclasses instead of dictionaries

* Move OAuth constants to application_credentials.py

* Remove remaining constants and dependencies for services from this PR

* Remove empty manifest entry

* Fix comment

* Set device entry_type to service

* ACC_SENSORS -> ACCOUNT_SENSORS

* Make value_fn of sensors return StateType

* Rename OAuthMonzoAPI again

* Fix tests

* Patch API instead of integration for unavailable test

* Move pot constant to sensor.py

* Improve type safety in async_get_monzo_api_data()

* Update async_oauth_create_entry() docstring

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Jake Martin 2024-05-07 19:38:58 +01:00 committed by GitHub
parent 5bef2d5d25
commit 6e024d54f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 919 additions and 0 deletions

View File

@ -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

View File

@ -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.*

View File

@ -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

View File

@ -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

View File

@ -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"])

View File

@ -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,
)

View File

@ -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()

View File

@ -0,0 +1,3 @@
"""Constants for the Monzo integration."""
DOMAIN = "monzo"

View File

@ -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]

View File

@ -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]

View File

@ -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"]
}

View File

@ -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

View File

@ -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"
}
}
}
}

View File

@ -17,6 +17,7 @@ APPLICATION_CREDENTIALS = [
"lametric",
"lyric",
"microbees",
"monzo",
"myuplink",
"neato",
"nest",

View File

@ -331,6 +331,7 @@ FLOWS = {
"modern_forms",
"moehlenhoff_alpha2",
"monoprice",
"monzo",
"moon",
"mopeka",
"motion_blinds",

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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': <ANY>,
'entity_id': 'sensor.current_account_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.current_account_total_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.flex_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.flex_total_balance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3.21',
})
# ---

View File

@ -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"

View File

@ -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