mirror of
https://github.com/home-assistant/core.git
synced 2026-08-17 18:54:06 +01:00
921 lines
32 KiB
Python
921 lines
32 KiB
Python
"""Tests for Monzo webhooks and transaction event entities."""
|
|
|
|
import asyncio
|
|
from datetime import timedelta
|
|
from unittest.mock import AsyncMock, Mock, call, patch
|
|
|
|
from aiohttp import ClientError
|
|
from freezegun.api import FrozenDateTimeFactory
|
|
from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError, Webhook
|
|
import pytest
|
|
|
|
from homeassistant.components import cloud
|
|
from homeassistant.components.monzo.const import (
|
|
ATTR_DATA,
|
|
CONF_CLOUDHOOK_URL,
|
|
CONF_WEBHOOK_URL,
|
|
EVENT_TRANSACTION_CREATED,
|
|
MONZO_WEBHOOK_TRANSACTION_CREATED,
|
|
)
|
|
from homeassistant.components.monzo.webhook import WEBHOOK_RETRY_DELAY
|
|
from homeassistant.components.webhook import async_generate_path
|
|
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import OAuth2TokenRequestReauthError
|
|
from homeassistant.helpers.config_entry_oauth2_flow import (
|
|
async_get_config_entry_implementation,
|
|
)
|
|
from homeassistant.helpers.network import NoURLAvailableError
|
|
|
|
from . import setup_integration
|
|
from .conftest import WEBHOOK_ID, WEBHOOK_URL
|
|
|
|
from tests.common import (
|
|
MockConfigEntry,
|
|
async_fire_time_changed,
|
|
async_mock_cloud_connection_status,
|
|
)
|
|
from tests.typing import ClientSessionGenerator
|
|
|
|
CLOUDHOOK_URL = "https://hooks.nabu.casa/test-cloudhook"
|
|
NEW_CLOUDHOOK_URL = "https://hooks.nabu.casa/new-cloudhook"
|
|
NEW_WEBHOOK_URL = f"https://new.example.com/api/webhook/{WEBHOOK_ID}"
|
|
TRANSACTION = {
|
|
"account_id": "acc_curr",
|
|
"amount": -350,
|
|
"currency": "GBP",
|
|
"description": "Ozone Coffee Roasters",
|
|
"merchant": {
|
|
"id": "merchant-id",
|
|
"name": "The De Beauvoir Deli Co.",
|
|
"logo": "https://example.com/logo.png",
|
|
"address": {
|
|
"city": "London",
|
|
"latitude": 51.54151,
|
|
"longitude": -0.084824,
|
|
},
|
|
},
|
|
"unknown_future_field": {"nested": [1, 2, 3]},
|
|
}
|
|
|
|
|
|
async def test_registers_one_remote_webhook_per_account(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test every account is registered against the same callback URL."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
|
|
assert monzo.user_account.list_account_webhooks.await_args_list == [
|
|
call("acc_curr"),
|
|
call("acc_flex"),
|
|
]
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", WEBHOOK_URL),
|
|
call("acc_flex", WEBHOOK_URL),
|
|
]
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == WEBHOOK_URL
|
|
|
|
|
|
async def test_registers_non_https_remote_webhook(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test Monzo webhooks do not require HTTPS or a standard port."""
|
|
webhook_url = f"http://example.com:8123/api/webhook/{WEBHOOK_ID}"
|
|
with patch(
|
|
"homeassistant.components.monzo.webhook.webhook.async_generate_url",
|
|
return_value=webhook_url,
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", webhook_url),
|
|
call("acc_flex", webhook_url),
|
|
]
|
|
|
|
|
|
async def test_reuses_webhook_and_removes_exact_duplicate(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test an existing callback is reused without touching unrelated hooks."""
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[
|
|
Webhook("keep", "acc_curr", WEBHOOK_URL),
|
|
Webhook("duplicate", "acc_curr", WEBHOOK_URL),
|
|
Webhook("unrelated", "acc_curr", "https://other.example/webhook"),
|
|
],
|
|
[],
|
|
]
|
|
|
|
await setup_integration(hass, polling_config_entry)
|
|
|
|
monzo.user_account.delete_webhook.assert_awaited_once_with("duplicate")
|
|
monzo.user_account.register_webhook.assert_awaited_once_with(
|
|
"acc_flex", WEBHOOK_URL
|
|
)
|
|
|
|
|
|
async def test_reuses_previously_persisted_webhooks(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test persisted matching callbacks are reused on startup."""
|
|
polling_config_entry.add_to_hass(hass)
|
|
hass.config_entries.async_update_entry(
|
|
polling_config_entry,
|
|
data={**polling_config_entry.data, CONF_WEBHOOK_URL: WEBHOOK_URL},
|
|
)
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[Webhook("current", "acc_curr", WEBHOOK_URL)],
|
|
[Webhook("flex", "acc_flex", WEBHOOK_URL)],
|
|
]
|
|
|
|
assert await hass.config_entries.async_setup(polling_config_entry.entry_id)
|
|
|
|
monzo.user_account.register_webhook.assert_not_awaited()
|
|
monzo.user_account.delete_webhook.assert_not_awaited()
|
|
|
|
|
|
async def test_replaces_previous_owned_url(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a previously persisted callback is replaced account by account."""
|
|
old_url = "https://old.example/api/webhook/test-webhook-id"
|
|
polling_config_entry.add_to_hass(hass)
|
|
hass.config_entries.async_update_entry(
|
|
polling_config_entry,
|
|
data={**polling_config_entry.data, CONF_WEBHOOK_URL: old_url},
|
|
)
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[Webhook("old-current", "acc_curr", old_url)],
|
|
[Webhook("old-flex", "acc_flex", old_url)],
|
|
]
|
|
|
|
assert await hass.config_entries.async_setup(polling_config_entry.entry_id)
|
|
|
|
assert monzo.user_account.delete_webhook.await_args_list == [
|
|
call("old-current"),
|
|
call("old-flex"),
|
|
]
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", WEBHOOK_URL),
|
|
call("acc_flex", WEBHOOK_URL),
|
|
]
|
|
|
|
|
|
async def test_partial_registration_is_rolled_back(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test registrations are rolled back when a later account fails."""
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[],
|
|
InvalidMonzoAPIResponseError(),
|
|
]
|
|
|
|
await setup_integration(hass, polling_config_entry)
|
|
|
|
monzo.user_account.register_webhook.assert_awaited_once_with(
|
|
"acc_curr", WEBHOOK_URL
|
|
)
|
|
monzo.user_account.delete_webhook.assert_awaited_once_with("webhook-acc_curr")
|
|
assert CONF_WEBHOOK_URL not in polling_config_entry.data
|
|
|
|
|
|
async def test_rollback_failure_does_not_abort_setup(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test rollback failure is contained while registration is retried."""
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[],
|
|
InvalidMonzoAPIResponseError(),
|
|
]
|
|
monzo.user_account.delete_webhook.side_effect = ClientError
|
|
|
|
await setup_integration(hass, polling_config_entry)
|
|
|
|
assert polling_config_entry.state is ConfigEntryState.LOADED
|
|
monzo.user_account.delete_webhook.assert_awaited_once_with("webhook-acc_curr")
|
|
assert "Unable to roll back Monzo webhook webhook-acc_curr" in caplog.text
|
|
|
|
|
|
async def test_transaction_payload_is_preserved_and_data_is_refreshed(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
) -> None:
|
|
"""Test the complete transaction reaches the correct event entity."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.accounts.reset_mock()
|
|
monzo.user_account.pots.reset_mock()
|
|
client = await hass_client_no_auth()
|
|
|
|
response = await client.post(
|
|
async_generate_path(WEBHOOK_ID),
|
|
json={"type": MONZO_WEBHOOK_TRANSACTION_CREATED, ATTR_DATA: TRANSACTION},
|
|
)
|
|
assert response.status == 200
|
|
|
|
state = hass.states.get("event.current_account_transaction")
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == EVENT_TRANSACTION_CREATED
|
|
assert state.attributes[ATTR_DATA] == TRANSACTION
|
|
flex_state = hass.states.get("event.flex_transaction")
|
|
assert flex_state is not None
|
|
assert flex_state.state == "unknown"
|
|
monzo.user_account.accounts.assert_awaited_once_with()
|
|
monzo.user_account.pots.assert_awaited_once_with()
|
|
|
|
|
|
async def test_webhook_response_does_not_wait_for_refresh(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
) -> None:
|
|
"""Test slow polling does not delay the webhook response."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
refresh_started = asyncio.Event()
|
|
allow_refresh = asyncio.Event()
|
|
refresh_finished = asyncio.Event()
|
|
|
|
async def slow_accounts() -> list[dict]:
|
|
refresh_started.set()
|
|
await allow_refresh.wait()
|
|
return []
|
|
|
|
async def slow_pots() -> list[dict]:
|
|
refresh_finished.set()
|
|
return []
|
|
|
|
monzo.user_account.accounts.side_effect = slow_accounts
|
|
monzo.user_account.pots.side_effect = slow_pots
|
|
client = await hass_client_no_auth()
|
|
request_task = hass.async_create_task(
|
|
client.post(
|
|
async_generate_path(WEBHOOK_ID),
|
|
json={"type": MONZO_WEBHOOK_TRANSACTION_CREATED, ATTR_DATA: TRANSACTION},
|
|
)
|
|
)
|
|
|
|
await refresh_started.wait()
|
|
response = await asyncio.wait_for(request_task, 1)
|
|
assert response.status == 200
|
|
|
|
allow_refresh.set()
|
|
await refresh_finished.wait()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
pytest.param([], id="not-a-dictionary"),
|
|
pytest.param(
|
|
{"type": MONZO_WEBHOOK_TRANSACTION_CREATED, ATTR_DATA: []},
|
|
id="data-not-a-dictionary",
|
|
),
|
|
pytest.param(
|
|
{"type": MONZO_WEBHOOK_TRANSACTION_CREATED, ATTR_DATA: {}},
|
|
id="missing-account-id",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"type": MONZO_WEBHOOK_TRANSACTION_CREATED,
|
|
ATTR_DATA: {"account_id": "unknown"},
|
|
},
|
|
id="unknown-account",
|
|
),
|
|
pytest.param(
|
|
{"type": "unsupported.event", ATTR_DATA: TRANSACTION},
|
|
id="unsupported-event",
|
|
),
|
|
],
|
|
)
|
|
async def test_invalid_webhook_is_ignored(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
payload: object,
|
|
) -> None:
|
|
"""Test invalid and unsupported webhooks do not trigger an entity."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.accounts.reset_mock()
|
|
monzo.user_account.pots.reset_mock()
|
|
client = await hass_client_no_auth()
|
|
|
|
response = await client.post(async_generate_path(WEBHOOK_ID), json=payload)
|
|
assert response.status == 200
|
|
state = hass.states.get("event.current_account_transaction")
|
|
assert state is not None
|
|
assert state.state == "unknown"
|
|
monzo.user_account.accounts.assert_not_awaited()
|
|
monzo.user_account.pots.assert_not_awaited()
|
|
|
|
|
|
async def test_invalid_json_webhook_is_ignored(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
) -> None:
|
|
"""Test malformed JSON does not refresh Monzo data."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.accounts.reset_mock()
|
|
monzo.user_account.pots.reset_mock()
|
|
client = await hass_client_no_auth()
|
|
|
|
response = await client.post(
|
|
async_generate_path(WEBHOOK_ID),
|
|
data="{",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert response.status == 200
|
|
monzo.user_account.accounts.assert_not_awaited()
|
|
monzo.user_account.pots.assert_not_awaited()
|
|
|
|
|
|
async def test_unload_preserves_remote_and_removes_local_webhooks(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
) -> None:
|
|
"""Test unloading preserves remote subscriptions and removes the handler."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.delete_webhook.reset_mock()
|
|
monzo.user_account.accounts.reset_mock()
|
|
monzo.user_account.pots.reset_mock()
|
|
|
|
assert await hass.config_entries.async_unload(polling_config_entry.entry_id)
|
|
|
|
monzo.user_account.delete_webhook.assert_not_awaited()
|
|
client = await hass_client_no_auth()
|
|
response = await client.post(
|
|
async_generate_path(WEBHOOK_ID),
|
|
json={"type": MONZO_WEBHOOK_TRANSACTION_CREATED, ATTR_DATA: TRANSACTION},
|
|
)
|
|
assert response.status == 200
|
|
monzo.user_account.accounts.assert_not_awaited()
|
|
monzo.user_account.pots.assert_not_awaited()
|
|
|
|
|
|
async def test_uses_and_removes_cloudhook(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a cloudhook is reused for remote subscriptions and entry removal."""
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[],
|
|
[],
|
|
[Webhook("webhook-acc_curr", "acc_curr", CLOUDHOOK_URL)],
|
|
[Webhook("webhook-acc_flex", "acc_flex", CLOUDHOOK_URL)],
|
|
]
|
|
with (
|
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
|
patch.object(cloud, "async_is_connected", return_value=True),
|
|
patch.object(
|
|
cloud,
|
|
"async_get_or_create_cloudhook",
|
|
return_value=CLOUDHOOK_URL,
|
|
) as get_cloudhook,
|
|
patch.object(cloud, "async_delete_cloudhook") as delete_cloudhook,
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
|
|
get_cloudhook.assert_awaited_once_with(hass, WEBHOOK_ID)
|
|
assert polling_config_entry.data[CONF_CLOUDHOOK_URL] == CLOUDHOOK_URL
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == CLOUDHOOK_URL
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", CLOUDHOOK_URL),
|
|
call("acc_flex", CLOUDHOOK_URL),
|
|
]
|
|
|
|
await hass.config_entries.async_remove(polling_config_entry.entry_id)
|
|
|
|
delete_cloudhook.assert_awaited_once_with(hass, WEBHOOK_ID)
|
|
assert monzo.user_account.delete_webhook.await_args_list == [
|
|
call("webhook-acc_curr"),
|
|
call("webhook-acc_flex"),
|
|
]
|
|
|
|
|
|
async def test_removal_refreshes_expired_token_without_updating_entry(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test removal can refresh a token after the entry has been deleted."""
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[],
|
|
[],
|
|
[Webhook("webhook-acc_curr", "acc_curr", WEBHOOK_URL)],
|
|
[Webhook("webhook-acc_flex", "acc_flex", WEBHOOK_URL)],
|
|
]
|
|
await setup_integration(hass, polling_config_entry)
|
|
expired_token = {**polling_config_entry.data["token"], "expires_at": 0}
|
|
hass.config_entries.async_update_entry(
|
|
polling_config_entry,
|
|
data={**polling_config_entry.data, "token": expired_token},
|
|
)
|
|
implementation = await async_get_config_entry_implementation(
|
|
hass, polling_config_entry
|
|
)
|
|
refreshed_token = {**expired_token, "access_token": "refreshed-access-token"}
|
|
|
|
with (
|
|
patch.object(
|
|
implementation,
|
|
"async_refresh_token",
|
|
return_value=refreshed_token,
|
|
) as refresh_token,
|
|
patch(
|
|
"homeassistant.components.monzo.async_get_config_entry_implementation",
|
|
return_value=implementation,
|
|
),
|
|
):
|
|
await hass.config_entries.async_remove(polling_config_entry.entry_id)
|
|
|
|
refresh_token.assert_awaited_once_with(expired_token)
|
|
assert monzo.user_account.delete_webhook.await_args_list == [
|
|
call("webhook-acc_curr"),
|
|
call("webhook-acc_flex"),
|
|
]
|
|
|
|
|
|
async def test_remote_cleanup_failure_still_removes_cloudhook(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test remote cleanup failure does not prevent cloudhook removal."""
|
|
with (
|
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
|
patch.object(cloud, "async_is_connected", return_value=True),
|
|
patch.object(
|
|
cloud,
|
|
"async_get_or_create_cloudhook",
|
|
return_value=CLOUDHOOK_URL,
|
|
),
|
|
patch.object(cloud, "async_delete_cloudhook") as delete_cloudhook,
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.accounts.side_effect = AuthorisationExpiredError
|
|
|
|
await hass.config_entries.async_remove(polling_config_entry.entry_id)
|
|
|
|
delete_cloudhook.assert_awaited_once_with(hass, WEBHOOK_ID)
|
|
|
|
|
|
async def test_uses_external_url_until_cloud_connects(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test registration moves from the external URL when cloud connects."""
|
|
with (
|
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
|
patch.object(cloud, "async_is_connected", return_value=False),
|
|
patch.object(
|
|
cloud,
|
|
"async_get_or_create_cloudhook",
|
|
return_value=CLOUDHOOK_URL,
|
|
) as get_cloudhook,
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
get_cloudhook.assert_not_awaited()
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", WEBHOOK_URL),
|
|
call("acc_flex", WEBHOOK_URL),
|
|
]
|
|
|
|
with patch.object(cloud, "async_is_connected", return_value=True):
|
|
async_mock_cloud_connection_status(hass, True)
|
|
await hass.async_block_till_done()
|
|
|
|
get_cloudhook.assert_awaited_once_with(hass, WEBHOOK_ID)
|
|
assert monzo.user_account.register_webhook.await_args_list[-2:] == [
|
|
call("acc_curr", CLOUDHOOK_URL),
|
|
call("acc_flex", CLOUDHOOK_URL),
|
|
]
|
|
|
|
|
|
async def test_temporary_cloud_disconnect_preserves_cloudhook(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a temporary Cloud disconnect preserves its remote callback."""
|
|
with (
|
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
|
patch.object(cloud, "async_is_connected", return_value=True),
|
|
patch.object(
|
|
cloud,
|
|
"async_get_or_create_cloudhook",
|
|
return_value=CLOUDHOOK_URL,
|
|
),
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.list_account_webhooks.reset_mock()
|
|
|
|
async_mock_cloud_connection_status(hass, False)
|
|
await hass.async_block_till_done()
|
|
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == CLOUDHOOK_URL
|
|
monzo.user_account.list_account_webhooks.assert_not_awaited()
|
|
|
|
|
|
async def test_cloud_subscription_loss_falls_back_to_external_url(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test losing a Cloud subscription replaces its remote callback."""
|
|
with (
|
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
|
patch.object(cloud, "async_is_connected", return_value=True),
|
|
patch.object(
|
|
cloud,
|
|
"async_get_or_create_cloudhook",
|
|
return_value=CLOUDHOOK_URL,
|
|
),
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[Webhook("old-current", "acc_curr", CLOUDHOOK_URL)],
|
|
[Webhook("old-flex", "acc_flex", CLOUDHOOK_URL)],
|
|
]
|
|
monzo.user_account.register_webhook.reset_mock()
|
|
monzo.user_account.delete_webhook.reset_mock()
|
|
|
|
with patch.object(cloud, "async_active_subscription", return_value=False):
|
|
async_mock_cloud_connection_status(hass, False)
|
|
await hass.async_block_till_done()
|
|
|
|
assert polling_config_entry.data[CONF_CLOUDHOOK_URL] == CLOUDHOOK_URL
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == WEBHOOK_URL
|
|
assert monzo.user_account.delete_webhook.await_args_list == [
|
|
call("old-current"),
|
|
call("old-flex"),
|
|
]
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", WEBHOOK_URL),
|
|
call("acc_flex", WEBHOOK_URL),
|
|
]
|
|
|
|
|
|
async def test_cloudhook_change_is_persisted_and_reconciled(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test a changed cloudhook replaces the persisted remote callback."""
|
|
with (
|
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
|
patch.object(cloud, "async_is_connected", return_value=True),
|
|
patch.object(
|
|
cloud,
|
|
"async_get_or_create_cloudhook",
|
|
return_value=CLOUDHOOK_URL,
|
|
),
|
|
patch.object(cloud, "async_listen_cloudhook_change") as listen_cloudhook,
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
cloudhook_changed = listen_cloudhook.call_args.args[2]
|
|
monzo.user_account.list_account_webhooks.reset_mock()
|
|
cloudhook_changed({"cloudhook_url": CLOUDHOOK_URL})
|
|
await hass.async_block_till_done()
|
|
monzo.user_account.list_account_webhooks.assert_not_awaited()
|
|
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[Webhook("old-current", "acc_curr", CLOUDHOOK_URL)],
|
|
[Webhook("old-flex", "acc_flex", CLOUDHOOK_URL)],
|
|
]
|
|
monzo.user_account.register_webhook.reset_mock()
|
|
|
|
cloudhook_changed({"cloudhook_url": NEW_CLOUDHOOK_URL})
|
|
await hass.async_block_till_done()
|
|
|
|
assert polling_config_entry.data[CONF_CLOUDHOOK_URL] == NEW_CLOUDHOOK_URL
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == NEW_CLOUDHOOK_URL
|
|
assert monzo.user_account.delete_webhook.await_args_list == [
|
|
call("old-current"),
|
|
call("old-flex"),
|
|
]
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", NEW_CLOUDHOOK_URL),
|
|
call("acc_flex", NEW_CLOUDHOOK_URL),
|
|
]
|
|
|
|
|
|
async def test_removed_cloudhook_falls_back_to_external_url(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test deleting a cloudhook moves remote callbacks to the external URL."""
|
|
with (
|
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
|
patch.object(cloud, "async_is_connected", return_value=True),
|
|
patch.object(
|
|
cloud,
|
|
"async_get_or_create_cloudhook",
|
|
return_value=CLOUDHOOK_URL,
|
|
),
|
|
patch.object(cloud, "async_listen_cloudhook_change") as listen_cloudhook,
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[Webhook("old-current", "acc_curr", CLOUDHOOK_URL)],
|
|
[Webhook("old-flex", "acc_flex", CLOUDHOOK_URL)],
|
|
]
|
|
monzo.user_account.register_webhook.reset_mock()
|
|
|
|
with patch.object(cloud, "async_active_subscription", return_value=False):
|
|
listen_cloudhook.call_args.args[2](None)
|
|
await hass.async_block_till_done()
|
|
|
|
assert CONF_CLOUDHOOK_URL not in polling_config_entry.data
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == WEBHOOK_URL
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", WEBHOOK_URL),
|
|
call("acc_flex", WEBHOOK_URL),
|
|
]
|
|
|
|
|
|
async def test_external_url_change_is_reconciled(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test changing the external URL replaces remote callbacks."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.list_account_webhooks.reset_mock()
|
|
await hass.config.async_update(currency="USD")
|
|
await hass.async_block_till_done()
|
|
monzo.user_account.list_account_webhooks.assert_not_awaited()
|
|
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[Webhook("old-current", "acc_curr", WEBHOOK_URL)],
|
|
[Webhook("old-flex", "acc_flex", WEBHOOK_URL)],
|
|
]
|
|
monzo.user_account.register_webhook.reset_mock()
|
|
|
|
with patch(
|
|
"homeassistant.components.monzo.webhook.webhook.async_generate_url",
|
|
return_value=NEW_WEBHOOK_URL,
|
|
):
|
|
await hass.config.async_update(external_url="https://new.example.com")
|
|
await hass.async_block_till_done()
|
|
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == NEW_WEBHOOK_URL
|
|
assert monzo.user_account.delete_webhook.await_args_list == [
|
|
call("old-current"),
|
|
call("old-flex"),
|
|
]
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", NEW_WEBHOOK_URL),
|
|
call("acc_flex", NEW_WEBHOOK_URL),
|
|
]
|
|
|
|
|
|
async def test_external_url_removal_cleans_up_remote_webhooks(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test removing the external URL removes obsolete remote callbacks."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[Webhook("old-current", "acc_curr", WEBHOOK_URL)],
|
|
[Webhook("old-flex", "acc_flex", WEBHOOK_URL)],
|
|
]
|
|
monzo.user_account.delete_webhook.reset_mock()
|
|
|
|
with patch(
|
|
"homeassistant.components.monzo.webhook.webhook.async_generate_url",
|
|
side_effect=NoURLAvailableError,
|
|
):
|
|
await hass.config.async_update(external_url=None)
|
|
await hass.async_block_till_done()
|
|
|
|
assert CONF_WEBHOOK_URL not in polling_config_entry.data
|
|
assert monzo.user_account.delete_webhook.await_args_list == [
|
|
call("old-current"),
|
|
call("old-flex"),
|
|
]
|
|
|
|
|
|
async def test_external_url_cleanup_auth_failure_starts_reauthentication(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test obsolete webhook cleanup authenticates before discarding its URL."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.list_account_webhooks.side_effect = AuthorisationExpiredError
|
|
|
|
with patch(
|
|
"homeassistant.components.monzo.webhook.webhook.async_generate_url",
|
|
side_effect=NoURLAvailableError,
|
|
):
|
|
await hass.config.async_update(external_url=None)
|
|
await hass.async_block_till_done()
|
|
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == WEBHOOK_URL
|
|
flows = hass.config_entries.flow.async_progress()
|
|
assert len(flows) == 1
|
|
assert flows[0]["context"]["source"] == SOURCE_REAUTH
|
|
|
|
|
|
async def test_external_url_cleanup_oauth_failure_starts_reauthentication(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test a rejected token refresh starts reauthentication during cleanup."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.list_account_webhooks.reset_mock()
|
|
monzo.user_account.list_account_webhooks.side_effect = (
|
|
OAuth2TokenRequestReauthError(request_info=Mock(), domain="monzo")
|
|
)
|
|
|
|
with patch(
|
|
"homeassistant.components.monzo.webhook.webhook.async_generate_url",
|
|
side_effect=NoURLAvailableError,
|
|
):
|
|
await hass.config.async_update(external_url=None)
|
|
await hass.async_block_till_done()
|
|
|
|
freezer.tick(timedelta(seconds=WEBHOOK_RETRY_DELAY))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert polling_config_entry.data[CONF_WEBHOOK_URL] == WEBHOOK_URL
|
|
monzo.user_account.list_account_webhooks.assert_awaited_once()
|
|
flows = hass.config_entries.flow.async_progress()
|
|
assert len(flows) == 1
|
|
assert flows[0]["context"]["source"] == SOURCE_REAUTH
|
|
|
|
|
|
async def test_external_url_cleanup_failure_is_retried_once(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test repeated URL updates share one remote cleanup retry."""
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
InvalidMonzoAPIResponseError(),
|
|
InvalidMonzoAPIResponseError(),
|
|
[Webhook("old-current", "acc_curr", WEBHOOK_URL)],
|
|
[Webhook("old-flex", "acc_flex", WEBHOOK_URL)],
|
|
]
|
|
monzo.user_account.delete_webhook.reset_mock()
|
|
|
|
with patch(
|
|
"homeassistant.components.monzo.webhook.webhook.async_generate_url",
|
|
side_effect=NoURLAvailableError,
|
|
):
|
|
await hass.config.async_update(external_url=None)
|
|
await hass.async_block_till_done()
|
|
await hass.config.async_update(external_url=None)
|
|
await hass.async_block_till_done()
|
|
|
|
freezer.tick(timedelta(seconds=WEBHOOK_RETRY_DELAY))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert CONF_WEBHOOK_URL not in polling_config_entry.data
|
|
assert monzo.user_account.delete_webhook.await_args_list == [
|
|
call("old-current"),
|
|
call("old-flex"),
|
|
]
|
|
|
|
|
|
async def test_no_external_url_skips_remote_registration(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test setup continues without remote registration when no URL exists."""
|
|
with patch(
|
|
"homeassistant.components.monzo.webhook.webhook.async_generate_url",
|
|
side_effect=NoURLAvailableError,
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
|
|
monzo.user_account.list_account_webhooks.assert_not_awaited()
|
|
monzo.user_account.register_webhook.assert_not_awaited()
|
|
|
|
|
|
async def test_cloudhook_creation_failure_is_retried(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test unavailable cloud connection schedules webhook registration retry."""
|
|
with (
|
|
patch.object(cloud, "async_active_subscription", return_value=True),
|
|
patch.object(cloud, "async_is_connected", return_value=True),
|
|
patch.object(
|
|
cloud,
|
|
"async_get_or_create_cloudhook",
|
|
side_effect=[cloud.CloudNotAvailable, CLOUDHOOK_URL],
|
|
),
|
|
):
|
|
await setup_integration(hass, polling_config_entry)
|
|
monzo.user_account.register_webhook.assert_not_awaited()
|
|
|
|
freezer.tick(timedelta(seconds=WEBHOOK_RETRY_DELAY))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert monzo.user_account.register_webhook.await_args_list == [
|
|
call("acc_curr", CLOUDHOOK_URL),
|
|
call("acc_flex", CLOUDHOOK_URL),
|
|
]
|
|
|
|
|
|
async def test_registration_auth_failure_starts_reauthentication(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Test an expired token starts config entry reauthentication."""
|
|
monzo.user_account.list_account_webhooks.side_effect = AuthorisationExpiredError
|
|
|
|
await setup_integration(hass, polling_config_entry)
|
|
await hass.async_block_till_done()
|
|
|
|
flows = hass.config_entries.flow.async_progress()
|
|
assert len(flows) == 1
|
|
assert flows[0]["context"]["source"] == SOURCE_REAUTH
|
|
|
|
|
|
async def test_registration_oauth_failure_starts_reauthentication(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test a rejected token refresh starts reauthentication during registration."""
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
[],
|
|
OAuth2TokenRequestReauthError(request_info=Mock(), domain="monzo"),
|
|
]
|
|
|
|
await setup_integration(hass, polling_config_entry)
|
|
await hass.async_block_till_done()
|
|
|
|
freezer.tick(timedelta(seconds=WEBHOOK_RETRY_DELAY))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert monzo.user_account.list_account_webhooks.await_count == 2
|
|
monzo.user_account.delete_webhook.assert_awaited_once_with("webhook-acc_curr")
|
|
flows = hass.config_entries.flow.async_progress()
|
|
assert len(flows) == 1
|
|
assert flows[0]["context"]["source"] == SOURCE_REAUTH
|
|
|
|
|
|
async def test_registration_failure_is_retried(
|
|
hass: HomeAssistant,
|
|
monzo: AsyncMock,
|
|
polling_config_entry: MockConfigEntry,
|
|
freezer: FrozenDateTimeFactory,
|
|
) -> None:
|
|
"""Test a transient invalid response schedules another registration attempt."""
|
|
monzo.user_account.list_account_webhooks.side_effect = [
|
|
InvalidMonzoAPIResponseError(),
|
|
[],
|
|
[],
|
|
]
|
|
|
|
await setup_integration(hass, polling_config_entry)
|
|
assert monzo.user_account.register_webhook.await_count == 0
|
|
|
|
freezer.tick(timedelta(seconds=WEBHOOK_RETRY_DELAY))
|
|
async_fire_time_changed(hass)
|
|
await hass.async_block_till_done()
|
|
|
|
assert monzo.user_account.register_webhook.await_count == 2
|