1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-18 06:20:17 +01:00
Files
core/tests/components/mobile_app/test_notify.py
T
2026-04-29 17:06:13 +02:00

838 lines
26 KiB
Python

"""Notify platform tests for mobile_app."""
import asyncio
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta
from http import HTTPStatus
from unittest.mock import patch
from aiohttp import ClientError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.mobile_app.const import DOMAIN
from homeassistant.components.notify import (
ATTR_MESSAGE,
ATTR_TITLE,
DOMAIN as NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, MockUser, snapshot_platform
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator
@pytest.fixture
async def setup_push_receiver(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_admin_user: MockUser
) -> None:
"""Fixture that sets up a mocked push receiver."""
push_url = "https://mobile-push.home-assistant.dev/push"
now = datetime.now() + timedelta(hours=24)
iso_time = now.strftime("%Y-%m-%dT%H:%M:%SZ")
aioclient_mock.post(
push_url,
json={
"rateLimits": {
"attempts": 1,
"successful": 1,
"errors": 0,
"total": 1,
"maximum": 150,
"remaining": 149,
"resetsAt": iso_time,
}
},
)
entry = MockConfigEntry(
data={
"app_data": {"push_token": "PUSH_TOKEN", "push_url": push_url},
"app_id": "io.homeassistant.mobile_app",
"app_name": "mobile_app tests",
"app_version": "1.0",
"device_id": "4d5e6f",
"device_name": "Test",
"manufacturer": "Home Assistant",
"model": "mobile_app",
"os_name": "Linux",
"os_version": "5.0.6",
"secret": "123abc",
"supports_encryption": False,
"user_id": hass_admin_user.id,
"webhook_id": "mock-webhook_id",
},
domain=DOMAIN,
source="registration",
title="mobile_app test entry",
version=1,
)
entry.add_to_hass(hass)
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
loaded_late_entry = MockConfigEntry(
data={
"app_data": {"push_token": "PUSH_TOKEN2", "push_url": f"{push_url}2"},
"app_id": "io.homeassistant.mobile_app",
"app_name": "mobile_app tests",
"app_version": "1.0",
"device_id": "4d5e6f2",
"device_name": "Loaded Late",
"manufacturer": "Home Assistant",
"model": "mobile_app",
"os_name": "Linux",
"os_version": "5.0.6",
"secret": "123abc2",
"supports_encryption": False,
"user_id": "1a2b3c2",
"webhook_id": "webhook_id_2",
},
domain=DOMAIN,
source="registration",
title="mobile_app 2 test entry",
version=1,
)
loaded_late_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(loaded_late_entry.entry_id)
await hass.async_block_till_done()
assert hass.services.has_service("notify", "mobile_app_loaded_late")
assert await hass.config_entries.async_remove(loaded_late_entry.entry_id)
await hass.async_block_till_done()
assert hass.services.has_service("notify", "mobile_app_test")
assert not hass.services.has_service("notify", "mobile_app_loaded_late")
loaded_late_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(loaded_late_entry.entry_id)
await hass.async_block_till_done()
assert hass.services.has_service("notify", "mobile_app_test")
assert hass.services.has_service("notify", "mobile_app_loaded_late")
@pytest.fixture
async def setup_websocket_channel_only_push(
hass: HomeAssistant, hass_admin_user: MockUser
) -> None:
"""Set up local push."""
entry = MockConfigEntry(
data={
"app_data": {"push_websocket_channel": True},
"app_id": "io.homeassistant.mobile_app",
"app_name": "mobile_app tests",
"app_version": "1.0",
"device_id": "websocket-push-device-id",
"device_name": "Websocket Push Name",
"manufacturer": "Home Assistant",
"model": "mobile_app",
"os_name": "Linux",
"os_version": "5.0.6",
"secret": "123abc2",
"supports_encryption": False,
"user_id": hass_admin_user.id,
"webhook_id": "websocket-push-webhook-id",
},
domain=DOMAIN,
source="registration",
title="websocket push test entry",
version=1,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert hass.services.has_service("notify", "mobile_app_websocket_push_name")
@pytest.fixture
async def notify_only() -> AsyncGenerator[None]:
"""Enable only the notify platform."""
with patch(
"homeassistant.components.mobile_app.PLATFORMS",
[Platform.NOTIFY],
):
yield
async def test_notify_works(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver
) -> None:
"""Test notify works."""
assert hass.services.has_service("notify", "mobile_app_test") is True
await hass.services.async_call(
"notify",
"mobile_app_test",
{
"message": "Hello world",
"title": "Demo",
"target": ["mock-webhook_id"],
"data": {"field1": "value1"},
},
blocking=True,
)
assert len(aioclient_mock.mock_calls) == 1
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["push_token"] == "PUSH_TOKEN"
assert call_json["message"] == "Hello world"
assert call_json["title"] == "Demo"
assert call_json["data"] == {"field1": "value1"}
assert call_json["registration_info"]["app_id"] == "io.homeassistant.mobile_app"
assert call_json["registration_info"]["app_version"] == "1.0"
assert call_json["registration_info"]["webhook_id"] == "mock-webhook_id"
async def test_notify_ws_works(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
setup_push_receiver,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test notify works."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "mock-webhook_id",
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
# Subscribe twice, it should forward all messages to 2nd subscription
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "mock-webhook_id",
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
new_sub_id = sub_result["id"]
await hass.services.async_call(
"notify", "mobile_app_test", {"message": "Hello world"}, blocking=True
)
assert len(aioclient_mock.mock_calls) == 0
msg_result = await client.receive_json()
assert msg_result["event"] == {"message": "Hello world"}
assert msg_result["id"] == new_sub_id # This is the new subscription
# Unsubscribe, now it should go over http
await client.send_json_auto_id(
{
"type": "unsubscribe_events",
"subscription": new_sub_id,
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
await hass.services.async_call(
"notify", "mobile_app_test", {"message": "Hello world 2"}, blocking=True
)
assert len(aioclient_mock.mock_calls) == 1
# Test non-existing webhook ID
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "non-existing",
}
)
sub_result = await client.receive_json()
assert not sub_result["success"]
assert sub_result["error"] == {
"code": "not_found",
"message": "Webhook ID not found",
}
# Test webhook ID linked to other user
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "webhook_id_2",
}
)
sub_result = await client.receive_json()
assert not sub_result["success"]
assert sub_result["error"] == {
"code": "unauthorized",
"message": "User not linked to this webhook ID",
}
async def test_notify_ws_confirming_works(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
setup_push_receiver,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test notify confirming works."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "mock-webhook_id",
"support_confirm": True,
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
sub_id = sub_result["id"]
# Sent a message that will be delivered locally
await hass.services.async_call(
"notify", "mobile_app_test", {"message": "Hello world"}, blocking=True
)
msg_result = await client.receive_json()
confirm_id = msg_result["event"].pop("hass_confirm_id")
assert confirm_id is not None
assert msg_result["event"] == {"message": "Hello world"}
# Try to confirm with incorrect confirm ID
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_confirm",
"webhook_id": "mock-webhook_id",
"confirm_id": "incorrect-confirm-id",
}
)
result = await client.receive_json()
assert not result["success"]
assert result["error"] == {
"code": "not_found",
"message": "Push notification channel not found",
}
# Confirm with correct confirm ID
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_confirm",
"webhook_id": "mock-webhook_id",
"confirm_id": confirm_id,
}
)
result = await client.receive_json()
assert result["success"]
# Drop local push channel and try to confirm another message
await client.send_json_auto_id(
{
"type": "unsubscribe_events",
"subscription": sub_id,
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_confirm",
"webhook_id": "mock-webhook_id",
"confirm_id": confirm_id,
}
)
result = await client.receive_json()
assert not result["success"]
assert result["error"] == {
"code": "not_found",
"message": "Push notification channel not found",
}
async def test_notify_ws_not_confirming(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
setup_push_receiver,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test we go via cloud when failed to confirm."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "mock-webhook_id",
"support_confirm": True,
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
await hass.services.async_call(
"notify", "mobile_app_test", {"message": "Hello world 1"}, blocking=True
)
with patch(
"homeassistant.components.mobile_app.push_notification.PUSH_CONFIRM_TIMEOUT", 0
):
await hass.services.async_call(
"notify", "mobile_app_test", {"message": "Hello world 2"}, blocking=True
)
await hass.async_block_till_done()
await hass.async_block_till_done()
# When we fail, all unconfirmed ones and failed one are sent via cloud
assert len(aioclient_mock.mock_calls) == 2
# All future ones also go via cloud
await hass.services.async_call(
"notify", "mobile_app_test", {"message": "Hello world 3"}, blocking=True
)
assert len(aioclient_mock.mock_calls) == 3
async def test_local_push_only(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
setup_websocket_channel_only_push,
) -> None:
"""Test a local only push registration."""
with pytest.raises(
HomeAssistantError,
match=r"Device.*websocket-push-webhook-id.*not connected to local push notifications",
):
await hass.services.async_call(
"notify",
"mobile_app_websocket_push_name",
{"message": "Not connected"},
blocking=True,
)
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "websocket-push-webhook-id",
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
sub_id = sub_result["id"]
await hass.services.async_call(
"notify",
"mobile_app_websocket_push_name",
{"message": "Hello world 1"},
blocking=True,
)
msg = await client.receive_json()
assert msg["id"] == sub_id
assert msg["type"] == "event"
assert msg["event"] == {"message": "Hello world 1"}
@pytest.mark.parametrize(
"target", [["webhook_id_2", "mock-webhook_id", "websocket-push-webhook-id"], None]
)
async def test_notify_multiple_targets(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
setup_push_receiver,
setup_websocket_channel_only_push,
target: list[str] | None,
) -> None:
"""Test notify to multiple targets.
Messages will be sent to three targerts, one (with webhook id `webhook_id_2`) will be remote target
and will send the notification via HTTP request, the other two (`mock-webhook_id` and`websocket-push-webhook-id`)
will be local push only and will be sent via websocket.
"""
# Setup mock for non-local push notification target
# with webhook_id "webhook_id_2"
aioclient_mock.post(
"https://mobile-push.home-assistant.dev/push2",
json={
"rateLimits": {
"attempts": 1,
"successful": 1,
"errors": 0,
"total": 1,
"maximum": 150,
"remaining": 149,
"resetsAt": (datetime.now() + timedelta(hours=24)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
),
}
},
)
client = await hass_ws_client(hass)
# Setup local push notification channels
local_push_sub_ids = []
for webhook_id in ("mock-webhook_id", "websocket-push-webhook-id"):
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": webhook_id,
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
local_push_sub_ids.append(sub_result["id"])
await hass.services.async_call(
"notify",
"notify",
{
"message": "Hello world",
"target": target,
},
blocking=True,
)
# Assert that the notification has been sent to the non-local push notification target
assert len(aioclient_mock.mock_calls) == 1
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["push_token"] == "PUSH_TOKEN2"
assert call_json["message"] == "Hello world"
assert call_json["registration_info"]["app_id"] == "io.homeassistant.mobile_app"
assert call_json["registration_info"]["app_version"] == "1.0"
assert call_json["registration_info"]["webhook_id"] == "webhook_id_2"
# Assert that the notification has been sent to the two local push notification targets
for sub_id in local_push_sub_ids:
msg_result = await client.receive_json()
assert msg_result["event"] == {"message": "Hello world"}
msg_id = msg_result["id"]
assert msg_id == sub_id
@pytest.mark.parametrize(
"target", [["webhook_id_2", "mock-webhook_id", "websocket-push-webhook-id"], None]
)
async def test_notify_multiple_targets_if_any_disconnected(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
setup_push_receiver,
setup_websocket_channel_only_push,
target: list[str] | None,
) -> None:
"""Notify works with disconnected targets.
Test that although one target is disconnected,
notify still works to other targets and the exception is still raised.
"""
# Setup mock for non-local push notification target
# with webhook_id "webhook_id_2"
aioclient_mock.post(
"https://mobile-push.home-assistant.dev/push2",
json={
"rateLimits": {
"attempts": 1,
"successful": 1,
"errors": 0,
"total": 1,
"maximum": 150,
"remaining": 149,
"resetsAt": (datetime.now() + timedelta(hours=24)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
),
}
},
)
client = await hass_ws_client(hass)
# Setup the local push notification channel
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "mock-webhook_id",
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
sub_id = sub_result["id"]
with pytest.raises(
HomeAssistantError,
match=r".*websocket-push-webhook-id.*not connected to local push notifications",
):
await hass.services.async_call(
"notify",
"notify",
{
"message": "Hello world",
"target": target,
},
blocking=True,
)
# Assert that the notification has been sent to the non-local push notification target
assert len(aioclient_mock.mock_calls) == 1
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["push_token"] == "PUSH_TOKEN2"
assert call_json["message"] == "Hello world"
assert call_json["registration_info"]["app_id"] == "io.homeassistant.mobile_app"
assert call_json["registration_info"]["app_version"] == "1.0"
assert call_json["registration_info"]["webhook_id"] == "webhook_id_2"
# Assert that the notification has been sent to the local
# push notification target that has been setup
msg_result = await client.receive_json()
assert msg_result["event"] == {"message": "Hello world"}
assert msg_result["id"] == sub_id
# Check that there are no more messages to receive (timeout expected)
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(client.receive_json(), timeout=0.1)
@pytest.mark.usefixtures("notify_only")
async def test_notify_platform(
hass: HomeAssistant,
hass_admin_user: MockUser,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test setup of the Mobile app notify platform."""
config_entry = MockConfigEntry(
data={
"app_data": {
"push_token": "PUSH_TOKEN",
"push_url": "https://mobile-push.home-assistant.dev/push",
},
"app_id": "io.homeassistant.mobile_app",
"app_name": "mobile_app tests",
"app_version": "1.0",
"device_id": "4d5e6f",
"device_name": "Test",
"manufacturer": "Home Assistant",
"model": "mobile_app",
"os_name": "Linux",
"os_version": "5.0.6",
"secret": "123abc",
"supports_encryption": False,
"user_id": hass_admin_user.id,
"webhook_id": "mock-webhook_id",
},
domain=DOMAIN,
source="registration",
title="mobile_app test entry",
version=1,
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.usefixtures("setup_push_receiver")
@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z")
async def test_send_message(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test sending message via notify.send_message action."""
state = hass.states.get("notify.test")
assert state
assert state.state == STATE_UNKNOWN
await hass.services.async_call(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
{
ATTR_ENTITY_ID: "notify.test",
ATTR_MESSAGE: "Hello world",
ATTR_TITLE: "test",
},
blocking=True,
)
assert len(aioclient_mock.mock_calls) == 1
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["push_token"] == "PUSH_TOKEN"
assert call_json["message"] == "Hello world"
assert call_json["title"] == "test"
assert call_json["registration_info"]["app_id"] == "io.homeassistant.mobile_app"
assert call_json["registration_info"]["app_version"] == "1.0"
assert call_json["registration_info"]["webhook_id"] == "mock-webhook_id"
state = hass.states.get("notify.test")
assert state
assert state.state == "1970-01-01T00:00:00+00:00"
@pytest.mark.usefixtures("setup_websocket_channel_only_push")
@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z")
async def test_send_message_local_push(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test sending message via notify.send_message action through local push."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "mobile_app/push_notification_channel",
"webhook_id": "websocket-push-webhook-id",
}
)
sub_result = await client.receive_json()
assert sub_result["success"]
sub_id = sub_result["id"]
await hass.services.async_call(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
{
ATTR_ENTITY_ID: "notify.websocket_push_name",
ATTR_MESSAGE: "Hello world",
ATTR_TITLE: "test",
},
blocking=True,
)
msg = await client.receive_json()
assert msg["id"] == sub_id
assert msg["type"] == "event"
assert msg["event"] == {"message": "Hello world", "title": "test"}
state = hass.states.get("notify.websocket_push_name")
assert state
assert state.state == "1970-01-01T00:00:00+00:00"
@pytest.mark.parametrize(
("exc", "status", "error_msg"),
[
(
None,
HTTPStatus.TOO_MANY_REQUESTS,
"rate_limit_exceeded_sending_notification",
),
(
None,
HTTPStatus.BAD_REQUEST,
"error_sending_notification",
),
(
TimeoutError,
HTTPStatus.OK,
"timeout_sending_notification",
),
(
ClientError,
HTTPStatus.OK,
"error_sending_notification",
),
],
)
@pytest.mark.usefixtures("setup_push_receiver")
@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z")
async def test_send_message_exceptions(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
exc: Exception | None,
status: HTTPStatus,
error_msg: str,
) -> None:
"""Test sending message via notify.send_message action with exceptions."""
aioclient_mock.clear_requests()
aioclient_mock.post(
"https://mobile-push.home-assistant.dev/push",
json={
"message": "Unknown error",
"rateLimits": {
"attempts": 1,
"successful": 1,
"errors": 0,
"total": 1,
"maximum": 150,
"remaining": 149,
"resetsAt": "1970-01-02T00:00:00Z",
},
},
status=status,
exc=exc,
)
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
{
ATTR_ENTITY_ID: "notify.test",
ATTR_MESSAGE: "Hello world",
ATTR_TITLE: "test",
},
blocking=True,
)
assert err.value.translation_key == error_msg
assert err.value.translation_placeholders == {
"device_name": "mobile_app test entry"
}
@pytest.mark.usefixtures("setup_websocket_channel_only_push")
@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z")
async def test_send_message_local_push_exception(hass: HomeAssistant) -> None:
"""Test sending message via notify.send_message action through local push with exceptions."""
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
{
ATTR_ENTITY_ID: "notify.websocket_push_name",
ATTR_MESSAGE: "Hello world",
ATTR_TITLE: "test",
},
blocking=True,
)
assert (
err.value.translation_key == "device_not_connected_for_local_push_notifications"
)
assert err.value.translation_placeholders == {
"device_name": "websocket push test entry"
}