mirror of
https://github.com/home-assistant/core.git
synced 2026-08-29 10:21:09 -05:00
433 lines
14 KiB
Python
433 lines
14 KiB
Python
"""Tests for Monzo actions."""
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from math import nan
|
|
from typing import cast
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
from aiohttp import ClientError
|
|
from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError
|
|
import pytest
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.monzo.const import (
|
|
DEVICE_MODEL_ACCOUNT,
|
|
DEVICE_MODEL_POT,
|
|
DOMAIN,
|
|
)
|
|
from homeassistant.components.monzo.services import (
|
|
ATTR_ACCOUNT,
|
|
ATTR_AMOUNT,
|
|
ATTR_POT,
|
|
SERVICE_DEPOSIT_INTO_POT,
|
|
SERVICE_WITHDRAW_FROM_POT,
|
|
)
|
|
from homeassistant.core import Context, HomeAssistant
|
|
from homeassistant.exceptions import (
|
|
HomeAssistantError,
|
|
OAuth2TokenRequestReauthError,
|
|
ServiceValidationError,
|
|
Unauthorized,
|
|
)
|
|
from homeassistant.helpers import device_registry as dr
|
|
|
|
from . import setup_integration
|
|
from .conftest import TEST_ACCOUNTS, TEST_POTS
|
|
|
|
from tests.common import MockConfigEntry, MockUser
|
|
|
|
|
|
@dataclass
|
|
class TransferDevices:
|
|
"""Devices used to test pot transfers."""
|
|
|
|
account_id: str
|
|
account_device_id: str
|
|
pot_id: str
|
|
pot_device_id: str
|
|
|
|
|
|
@pytest.fixture
|
|
async def transfer_devices(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
polling_config_entry: MockConfigEntry,
|
|
monzo: AsyncMock,
|
|
) -> TransferDevices:
|
|
"""Set up Monzo and return its account and pot devices."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
account_id = cast(str, TEST_ACCOUNTS[0]["id"])
|
|
pot_id = cast(str, TEST_POTS[0]["id"])
|
|
account_device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, account_id), polling_config_entry.entry_id
|
|
)
|
|
pot_device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, pot_id), polling_config_entry.entry_id
|
|
)
|
|
assert account_device is not None
|
|
assert pot_device is not None
|
|
return TransferDevices(account_id, account_device.id, pot_id, pot_device.id)
|
|
|
|
|
|
async def _async_call_transfer(
|
|
hass: HomeAssistant,
|
|
devices: TransferDevices,
|
|
service_name: str,
|
|
amount: object = 0.29,
|
|
) -> None:
|
|
"""Call a Monzo pot transfer action."""
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service_name,
|
|
{
|
|
ATTR_ACCOUNT: devices.account_device_id,
|
|
ATTR_POT: devices.pot_device_id,
|
|
ATTR_AMOUNT: amount,
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
|
|
async def test_device_models_support_selector_filtering(
|
|
device_registry: dr.DeviceRegistry,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test account and pot devices can be filtered in action selectors."""
|
|
account_device = device_registry.async_get(transfer_devices.account_device_id)
|
|
pot_device = device_registry.async_get(transfer_devices.pot_device_id)
|
|
assert account_device is not None
|
|
assert pot_device is not None
|
|
assert account_device.model == DEVICE_MODEL_ACCOUNT
|
|
assert pot_device.model == DEVICE_MODEL_POT
|
|
|
|
|
|
async def test_non_transfer_account_is_rejected(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
polling_config_entry: MockConfigEntry,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test an account product which does not support pot transfers is rejected."""
|
|
flex_device = device_registry.async_get_device_by_identifier(
|
|
(DOMAIN, cast(str, TEST_ACCOUNTS[1]["id"])), polling_config_entry.entry_id
|
|
)
|
|
assert flex_device is not None
|
|
assert flex_device.model == "Flex"
|
|
|
|
with pytest.raises(ServiceValidationError) as error:
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
SERVICE_DEPOSIT_INTO_POT,
|
|
{
|
|
ATTR_ACCOUNT: flex_device.id,
|
|
ATTR_POT: transfer_devices.pot_device_id,
|
|
ATTR_AMOUNT: 1,
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert error.value.translation_key == "invalid_transfer_account"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("service_name", "transfer_method"),
|
|
[
|
|
(SERVICE_DEPOSIT_INTO_POT, lambda account: account.pot_deposit),
|
|
(SERVICE_WITHDRAW_FROM_POT, lambda account: account.pot_withdraw),
|
|
],
|
|
)
|
|
async def test_transfer_and_refresh(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
transfer_devices: TransferDevices,
|
|
service_name: str,
|
|
transfer_method: Callable[[AsyncMock], AsyncMock],
|
|
) -> None:
|
|
"""Test transfers use exact minor units and refresh Monzo data."""
|
|
await _async_call_transfer(hass, transfer_devices, service_name)
|
|
|
|
transfer_method(monzo.user_account).assert_awaited_once_with(
|
|
transfer_devices.account_id,
|
|
transfer_devices.pot_id,
|
|
29,
|
|
)
|
|
assert monzo.user_account.accounts.await_count == 2
|
|
assert monzo.user_account.pots.await_count == 2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"amount",
|
|
[
|
|
pytest.param(0, id="zero"),
|
|
pytest.param(-1, id="negative"),
|
|
pytest.param(0.001, id="too-many-decimal-places"),
|
|
pytest.param("invalid", id="not-a-number"),
|
|
pytest.param(nan, id="non-finite"),
|
|
pytest.param("1e999999", id="decimal-overflow"),
|
|
pytest.param("1e-10000000", id="minor-units-underflow"),
|
|
],
|
|
)
|
|
async def test_invalid_amount(
|
|
hass: HomeAssistant,
|
|
transfer_devices: TransferDevices,
|
|
amount: object,
|
|
) -> None:
|
|
"""Test invalid transfer amounts are rejected by the action schema."""
|
|
with pytest.raises(vol.Invalid):
|
|
await _async_call_transfer(
|
|
hass,
|
|
transfer_devices,
|
|
SERVICE_DEPOSIT_INTO_POT,
|
|
amount,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("account_device", "pot_device", "translation_key"),
|
|
[
|
|
("pot_device_id", "pot_device_id", "pot_selected_as_account"),
|
|
("account_device_id", "account_device_id", "account_selected_as_pot"),
|
|
],
|
|
)
|
|
async def test_invalid_resource_type(
|
|
hass: HomeAssistant,
|
|
transfer_devices: TransferDevices,
|
|
account_device: str,
|
|
pot_device: str,
|
|
translation_key: str,
|
|
) -> None:
|
|
"""Test selected devices must represent an account and a pot."""
|
|
with pytest.raises(ServiceValidationError) as error:
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
SERVICE_DEPOSIT_INTO_POT,
|
|
{
|
|
ATTR_ACCOUNT: getattr(transfer_devices, account_device),
|
|
ATTR_POT: getattr(transfer_devices, pot_device),
|
|
ATTR_AMOUNT: 1,
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert error.value.translation_key == translation_key
|
|
|
|
|
|
async def test_missing_device(
|
|
hass: HomeAssistant,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test a missing selected device is rejected."""
|
|
with pytest.raises(ServiceValidationError):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
SERVICE_DEPOSIT_INTO_POT,
|
|
{
|
|
ATTR_ACCOUNT: "missing-device",
|
|
ATTR_POT: transfer_devices.pot_device_id,
|
|
ATTR_AMOUNT: 1,
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
|
|
async def test_devices_from_different_entries(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test the account and pot must belong to the same config entry."""
|
|
other_entry = MockConfigEntry(domain=DOMAIN)
|
|
other_entry.add_to_hass(hass)
|
|
other_pot = device_registry.async_get_or_create(
|
|
config_entry_id=other_entry.entry_id,
|
|
identifiers={(DOMAIN, "other-pot")},
|
|
name="Other pot",
|
|
)
|
|
|
|
with pytest.raises(ServiceValidationError):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
SERVICE_DEPOSIT_INTO_POT,
|
|
{
|
|
ATTR_ACCOUNT: transfer_devices.account_device_id,
|
|
ATTR_POT: other_pot.id,
|
|
ATTR_AMOUNT: 1,
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
|
|
async def test_pot_must_belong_to_selected_account(
|
|
hass: HomeAssistant,
|
|
device_registry: dr.DeviceRegistry,
|
|
polling_config_entry: MockConfigEntry,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test a pot can only be transferred to or from its owning account."""
|
|
joint_account_id = "acc_joint"
|
|
polling_config_entry.runtime_data.coordinator.data.accounts[joint_account_id] = {
|
|
"id": joint_account_id,
|
|
"name": "Joint Account",
|
|
"type": "uk_retail_joint",
|
|
}
|
|
joint_account = device_registry.async_get_or_create(
|
|
config_entry_id=polling_config_entry.entry_id,
|
|
identifiers={(DOMAIN, joint_account_id)},
|
|
name="Joint Account",
|
|
)
|
|
|
|
with pytest.raises(ServiceValidationError) as error:
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
SERVICE_DEPOSIT_INTO_POT,
|
|
{
|
|
ATTR_ACCOUNT: joint_account.id,
|
|
ATTR_POT: transfer_devices.pot_device_id,
|
|
ATTR_AMOUNT: 1,
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert error.value.translation_key == "pot_account_mismatch"
|
|
assert error.value.translation_placeholders == {
|
|
"account_name": "Joint Account",
|
|
"pot_name": "Savings",
|
|
"pot_account_name": "Current Account",
|
|
}
|
|
|
|
|
|
async def test_transfer_requires_admin(
|
|
hass: HomeAssistant,
|
|
hass_read_only_user: MockUser,
|
|
monzo: AsyncMock,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test pot transfers require administrator access."""
|
|
with pytest.raises(Unauthorized):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
SERVICE_DEPOSIT_INTO_POT,
|
|
{
|
|
ATTR_ACCOUNT: transfer_devices.account_device_id,
|
|
ATTR_POT: transfer_devices.pot_device_id,
|
|
ATTR_AMOUNT: 1,
|
|
},
|
|
blocking=True,
|
|
context=Context(user_id=hass_read_only_user.id),
|
|
)
|
|
|
|
monzo.user_account.pot_deposit.assert_not_awaited()
|
|
|
|
|
|
async def test_api_error(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test a Monzo API error is exposed as a Home Assistant error."""
|
|
monzo.user_account.pot_deposit.side_effect = InvalidMonzoAPIResponseError
|
|
|
|
with pytest.raises(HomeAssistantError) as error:
|
|
await _async_call_transfer(hass, transfer_devices, SERVICE_DEPOSIT_INTO_POT, 1)
|
|
|
|
assert error.value.translation_key == "transfer_status_unknown"
|
|
assert monzo.user_account.accounts.await_count == 1
|
|
assert monzo.user_account.pots.await_count == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"api_error",
|
|
[
|
|
pytest.param(ClientError(), id="client-error"),
|
|
pytest.param(TimeoutError(), id="timeout"),
|
|
],
|
|
)
|
|
async def test_transport_error(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
transfer_devices: TransferDevices,
|
|
api_error: Exception,
|
|
) -> None:
|
|
"""Test a transport error is exposed as a Home Assistant error."""
|
|
monzo.user_account.pot_deposit.side_effect = api_error
|
|
|
|
with pytest.raises(HomeAssistantError) as error:
|
|
await _async_call_transfer(hass, transfer_devices, SERVICE_DEPOSIT_INTO_POT, 1)
|
|
|
|
assert error.value.translation_key == "transfer_status_unknown"
|
|
assert monzo.user_account.accounts.await_count == 1
|
|
assert monzo.user_account.pots.await_count == 1
|
|
|
|
|
|
async def test_false_transfer_result_is_failure(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test a false transfer result is exposed as a Home Assistant error."""
|
|
monzo.user_account.pot_deposit.return_value = False
|
|
|
|
with pytest.raises(HomeAssistantError) as error:
|
|
await _async_call_transfer(hass, transfer_devices, SERVICE_DEPOSIT_INTO_POT, 1)
|
|
|
|
assert error.value.translation_key == "transfer_failed"
|
|
assert monzo.user_account.accounts.await_count == 1
|
|
assert monzo.user_account.pots.await_count == 1
|
|
|
|
|
|
async def test_api_rejection_details(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
transfer_devices: TransferDevices,
|
|
) -> None:
|
|
"""Test rejection details supplied by Monzo are exposed to the user."""
|
|
monzo.user_account.pot_deposit.side_effect = InvalidMonzoAPIResponseError(
|
|
{
|
|
"code": "bad_request.unsupported_account",
|
|
"message": "This account does not support pot transfers",
|
|
}
|
|
)
|
|
|
|
with pytest.raises(HomeAssistantError) as error:
|
|
await _async_call_transfer(hass, transfer_devices, SERVICE_DEPOSIT_INTO_POT, 1)
|
|
|
|
assert error.value.translation_key == "transfer_rejected"
|
|
assert error.value.translation_placeholders == {
|
|
"account_name": "Current Account",
|
|
"account_type": "uk_retail",
|
|
"reason": (
|
|
"bad_request.unsupported_account: "
|
|
"This account does not support pot transfers"
|
|
),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"api_error",
|
|
[
|
|
pytest.param(AuthorisationExpiredError, id="monzo-authorisation-expired"),
|
|
pytest.param(
|
|
OAuth2TokenRequestReauthError(request_info=Mock(), domain=DOMAIN),
|
|
id="oauth-refresh-rejected",
|
|
),
|
|
],
|
|
)
|
|
async def test_expired_authorisation_starts_reauthentication(
|
|
hass: HomeAssistant,
|
|
polling_config_entry: MockConfigEntry,
|
|
monzo: AsyncMock,
|
|
transfer_devices: TransferDevices,
|
|
api_error: Exception | type[Exception],
|
|
) -> None:
|
|
"""Test expired Monzo authorization starts reauthentication."""
|
|
monzo.user_account.pot_deposit.side_effect = api_error
|
|
|
|
with (
|
|
patch.object(polling_config_entry, "async_start_reauth") as start_reauth,
|
|
pytest.raises(HomeAssistantError),
|
|
):
|
|
await _async_call_transfer(hass, transfer_devices, SERVICE_DEPOSIT_INTO_POT, 1)
|
|
|
|
start_reauth.assert_called_once_with(hass)
|