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