From 3e5d2ee0a6949473c010716394391fe572b70729 Mon Sep 17 00:00:00 2001 From: lollo Date: Thu, 8 Oct 2026 00:10:07 +0200 Subject: [PATCH] Follow re-paired mobile apps, and repair rather than snooze a missing action - Rebuild the mobile app cache and each recipient's discovered devices when a mobile_app device or its notify action comes or goes (debounced), instead of only at startup. - A failed push to a device whose notify action no longer exists raises the recipient_mobile_app_not_found repair instead of a one day snooze, and the repair is cleared once the action is back. - Purging snoozes also drops device snoozes whose action has gone. Points 2 and 3 of #268. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HpGm4J89JSyWt3WC6CTe27 --- custom_components/supernotify/engine.py | 6 ++ custom_components/supernotify/hass_api.py | 57 ++++++++++++- custom_components/supernotify/people.py | 85 +++++++++++++------ custom_components/supernotify/snoozer.py | 11 ++- .../supernotify/transports/mobile_push.py | 14 ++- tests/components/supernotify/test_engine.py | 12 +++ tests/components/supernotify/test_hass_api.py | 52 ++++++++++++ tests/components/supernotify/test_people.py | 38 +++++++++ tests/components/supernotify/test_snoozer.py | 16 ++++ .../transports/test_transport_mobile_push.py | 7 +- 10 files changed, 262 insertions(+), 36 deletions(-) diff --git a/custom_components/supernotify/engine.py b/custom_components/supernotify/engine.py index 001a3c4ea..ff0d9a33c 100644 --- a/custom_components/supernotify/engine.py +++ b/custom_components/supernotify/engine.py @@ -163,6 +163,7 @@ async def initialize(self) -> None: # Every entity - switches, binary_sensors and counters - is a real platform entity, added # once this method returns (see __init__.py), and keeps its own state current self.context.hass_api.subscribe_event("mobile_app_notification_action", self.on_mobile_action) + self.context.hass_api.subscribe_mobile_app_changes(self.async_mobile_apps_changed) housekeeping_schedule = self.housekeeping.get(CONF_HOUSEKEEPING_TIME) if housekeeping_schedule: @@ -426,6 +427,11 @@ def on_mobile_action(self, event: Event) -> None: self.context.snoozer.handle_command_event(event, self.context.people_registry.enabled_recipients()) @callback + async def async_mobile_apps_changed(self) -> None: + _LOGGER.info("SUPERNOTIFY Mobile app devices changed, rediscovering") + self.context.people_registry.refresh_mobile_devices() + self.context.snoozer.purge_snoozes() + async def async_nightly_tasks(self, now: dt.datetime) -> None: _LOGGER.info("SUPERNOTIFY Housekeeping starting as scheduled at %s", now) await self.context.archive.cleanup() diff --git a/custom_components/supernotify/hass_api.py b/custom_components/supernotify/hass_api.py index 08da6a76a..acd268b6c 100644 --- a/custom_components/supernotify/hass_api.py +++ b/custom_components/supernotify/hass_api.py @@ -17,6 +17,7 @@ CONF_DEVICE_ID, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.event import async_track_state_change_event, async_track_time_change, async_track_time_interval from homeassistant.helpers.storage import Store @@ -25,7 +26,7 @@ if TYPE_CHECKING: import asyncio - from collections.abc import Callable, Iterable + from collections.abc import Awaitable, Callable, Iterable import aiohttp from anyio import Path @@ -50,8 +51,9 @@ from homeassistant.components.trace.const import DATA_TRACE from homeassistant.components.trace.models import ActionTrace from homeassistant.components.trace.util import async_store_trace +from homeassistant.const import ATTR_DOMAIN, ATTR_SERVICE, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED from homeassistant.core import Context as HomeAssistantContext -from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.core import Event, HomeAssistant, SupportsResponse, callback from homeassistant.exceptions import ConditionError, ConditionErrorContainer, HomeAssistantError, IntegrationError from homeassistant.helpers import condition as condition_helper from homeassistant.helpers import device_registry as dr @@ -81,6 +83,8 @@ ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" +MOBILE_APP_DOMAIN = "mobile_app" + _LOGGER = logging.getLogger(__name__) @@ -211,9 +215,41 @@ def disconnect(self) -> None: _LOGGER.error("SUPERNOTIFY Failed to unsubscribe: %s", e) _LOGGER.debug("SUPERNOTIFY Disconnection complete") - def subscribe_event(self, event: EventType | str, callback: Callable) -> None: + def subscribe_event(self, event: EventType[Any] | str, callback: Callable) -> None: self.unsubscribes.append(self._hass.bus.async_listen(event, callback)) + def subscribe_mobile_app_changes(self, on_change: Callable[[], Awaitable[None]], cooldown: float = 10) -> None: + """Call `on_change` once things settle after a mobile_app device, or its notify action, comes or goes + + Re-pairing or renaming a phone changes its notify action, and HA registers that action a + little after the device itself, so both kinds of change are debounced into one call + """ + debouncer: Debouncer = Debouncer(self._hass, _LOGGER, cooldown=cooldown, immediate=False, function=on_change) + + @callback + def on_device(event: Event) -> None: + device_id: str | None = event.data.get("device_id") + if event.data.get("action") == "remove": + relevant: bool = device_id in self._mobile_apps_by_device_id + else: + dev_reg: DeviceRegistry | None = self._device_registry() + device = dev_reg.async_get(device_id) if dev_reg and device_id else None + relevant = device is not None and any(i and i[0] == MOBILE_APP_DOMAIN for i in device.identifiers) + if relevant: + debouncer.async_schedule_call() + + @callback + def on_service(event: Event) -> None: + if event.data.get(ATTR_DOMAIN) == "notify" and str(event.data.get(ATTR_SERVICE, "")).startswith( + f"{MOBILE_APP_DOMAIN}_" + ): + debouncer.async_schedule_call() + + self.subscribe_event(dr.EVENT_DEVICE_REGISTRY_UPDATED, on_device) + self.subscribe_event(EVENT_SERVICE_REGISTERED, on_service) + self.subscribe_event(EVENT_SERVICE_REMOVED, on_service) + self.unsubscribes.append(debouncer.async_cancel) + def subscribe_state(self, entity_ids: str | Iterable[str], callback: Callable) -> None: self.unsubscribes.append(async_track_state_change_event(self._hass, entity_ids, callback)) @@ -235,6 +271,11 @@ def is_state(self, entity_id: str, state: str) -> bool: def has_service(self, domain: str, service: str) -> bool: return self._hass.services.has_service(domain, service) + def mobile_app_action_exists(self, mobile_app_id: str) -> bool: + """Whether a mobile app can be notified, by its notify action or its notify entity""" + action: str = mobile_app_id.removeprefix("notify.") + return self.has_service("notify", action) or self.get_state(f"notify.{action}") is not None + def entity_ids_for_domain(self, domain: str) -> list[str]: return self._hass.states.async_entity_ids(domain) @@ -689,6 +730,9 @@ def raise_issue( is_fixable=is_fixable, ) + def delete_issue(self, issue_id: str) -> None: + ir.async_delete_issue(self._hass, DOMAIN, issue_id) + def mobile_app_by_tracker(self, device_tracker: str) -> TrackedDeviceDetails | None: return self._mobile_apps_by_tracker.get(device_tracker) @@ -709,9 +753,14 @@ def build_mobile_app_cache(self) -> None: _LOGGER.warning("SUPERNOTIFY Unable to discover devices for - no entity registry found") return + # rebuilt from scratch, so a device that has gone, or been re-paired under a new name, goes too + self._mobile_apps_by_tracker.clear() + self._mobile_apps_by_app_id.clear() + self._mobile_apps_by_device_id.clear() + self._mobile_apps_by_user_id.clear() found: int = 0 complete: int = 0 - for mobile_app_info in self.discover_devices("mobile_app"): + for mobile_app_info in self.discover_devices(MOBILE_APP_DOMAIN): try: mobile_app_id: str = f"mobile_app_{slugify(mobile_app_info.device_name)}" device_tracker: str | None = None diff --git a/custom_components/supernotify/people.py b/custom_components/supernotify/people.py index 1b8d8cd62..ba5d532ff 100644 --- a/custom_components/supernotify/people.py +++ b/custom_components/supernotify/people.py @@ -209,6 +209,11 @@ def __init__(self, config: dict[str, Any] | None, default_mobile_discovery: bool c[CONF_MOBILE_APP_ID]: c for c in config.get(CONF_MOBILE_DEVICES, []) } self.disabled_mobile_app_ids: list[str] = [k for k, v in self.mobile_devices.items() if not v.get(CONF_ENABLED, True)] + # as configured, to start again from each time the discovered devices are refreshed + self._configured_mobile_devices: dict[str, dict[str, str | list[str] | None]] = { + k: dict(v) for k, v in self.mobile_devices.items() + } + self._mobile_app_issues: set[str] = set() _LOGGER.debug("SUPERNOTIFY Recipient config %s -> %s", config, self.as_dict(redact=True)) def initialize(self, people_registry: PeopleRegistry) -> None: @@ -218,6 +223,30 @@ def initialize(self, people_registry: PeopleRegistry) -> None: self._target.extend(ATTR_EMAIL, self.email) if self.phone_number: self._target.extend(ATTR_PHONE, self.phone_number) + self.resolve_mobile_devices(people_registry) + if not self.user_id or not self.alias: + attrs: dict[str, Any] | None = people_registry.person_attributes(self.entity_id) + if attrs: + if attrs.get(ATTR_USER_ID) and isinstance(attrs.get(ATTR_USER_ID), str): + self.user_id = attrs.get(ATTR_USER_ID) + if attrs.get(ATTR_ALIAS) and isinstance(attrs.get(ATTR_ALIAS), str): + self.alias = attrs.get(ATTR_ALIAS) + if not self.alias and attrs.get(ATTR_FRIENDLY_NAME) and isinstance(attrs.get(ATTR_FRIENDLY_NAME), str): + self.alias = attrs.get(ATTR_FRIENDLY_NAME) + _LOGGER.debug("SUPERNOTIFY Person attrs found for %s: %s,%s", self.entity_id, self.alias, self.user_id) + else: + _LOGGER.debug("SUPERNOTIFY No person attrs found for %s", self.entity_id) + _LOGGER.debug("SUPERNOTIFY Recipient %s target: %s", self.entity_id, self._target.as_dict(redact=True)) + + def on_notification(self, context: Context | None = None) -> None: + # Record that a notification has occurred for this person + if self.notify_entity is not None: + self.notify_entity.record_notification(context) + + def resolve_mobile_devices(self, people_registry: PeopleRegistry) -> None: + """The configured mobile devices plus those discovered now - again whenever one is re-paired or renamed""" + previous: list[str] = list(self.mobile_devices) + self.mobile_devices = {k: dict(v) for k, v in self._configured_mobile_devices.items()} if self.mobile_discovery: # a known user_id (explicitly configured, or already backfilled below from a prior # initialize()) resolves devices directly, without needing a Person entity at all @@ -246,37 +275,30 @@ def initialize(self, people_registry: PeopleRegistry) -> None: ) else: _LOGGER.info("SUPERNOTIFY Unable to find mobile devices for %s", self.entity_id) + self._target.remove(ATTR_MOBILE_APP_ID, previous) if self.mobile_devices: self._target.extend(ATTR_MOBILE_APP_ID, list(self.enabled_mobile_devices.keys())) - for mobile_app_id in self.enabled_mobile_devices: - action: str = mobile_app_id.removeprefix("notify.") - hass_api = people_registry.hass_api - if not hass_api.has_service("notify", action) and hass_api.get_state(f"notify.{action}") is None: + self.check_mobile_actions(people_registry.hass_api) + + def check_mobile_actions(self, hass_api: HomeAssistantAPI) -> None: + """Raise a repair for an enabled mobile device with no notify action, and clear it once that's resolved""" + missing: set[str] = set() + for mobile_app_id in self.enabled_mobile_devices: + action: str = mobile_app_id.removeprefix("notify.") + if not hass_api.mobile_app_action_exists(action): + issue_id: str = f"recipient_{self.name}_mobile_app_{action}_not_found" + missing.add(issue_id) + if issue_id not in self._mobile_app_issues: _LOGGER.warning("SUPERNOTIFY No notify action found for %s mobile device %s", self.entity_id, action) - hass_api.raise_issue( - f"recipient_{self.name}_mobile_app_{action}_not_found", - issue_key="recipient_mobile_app_not_found", - issue_map={"recipient": self.entity_id, "mobile_app": action}, - learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/people/", - ) - if not self.user_id or not self.alias: - attrs: dict[str, Any] | None = people_registry.person_attributes(self.entity_id) - if attrs: - if attrs.get(ATTR_USER_ID) and isinstance(attrs.get(ATTR_USER_ID), str): - self.user_id = attrs.get(ATTR_USER_ID) - if attrs.get(ATTR_ALIAS) and isinstance(attrs.get(ATTR_ALIAS), str): - self.alias = attrs.get(ATTR_ALIAS) - if not self.alias and attrs.get(ATTR_FRIENDLY_NAME) and isinstance(attrs.get(ATTR_FRIENDLY_NAME), str): - self.alias = attrs.get(ATTR_FRIENDLY_NAME) - _LOGGER.debug("SUPERNOTIFY Person attrs found for %s: %s,%s", self.entity_id, self.alias, self.user_id) - else: - _LOGGER.debug("SUPERNOTIFY No person attrs found for %s", self.entity_id) - _LOGGER.debug("SUPERNOTIFY Recipient %s target: %s", self.entity_id, self._target.as_dict(redact=True)) - - def on_notification(self, context: Context | None = None) -> None: - # Record that a notification has occurred for this person - if self.notify_entity is not None: - self.notify_entity.record_notification(context) + hass_api.raise_issue( + issue_id, + issue_key="recipient_mobile_app_not_found", + issue_map={"recipient": self.entity_id, "mobile_app": action}, + learn_more_url="https://supernotify.rhizomatics.org.uk/configuration/people/", + ) + for issue_id in self._mobile_app_issues - missing: + hass_api.delete_issue(issue_id) + self._mobile_app_issues = missing @property def enabled_mobile_devices(self) -> dict[str, dict[str, str | list[str] | None]]: @@ -407,6 +429,13 @@ async def initialize(self) -> None: self.people[recipient.entity_id] = recipient + def refresh_mobile_devices(self) -> None: + """Rediscover every recipient's mobile devices, after one was added, removed, re-paired or renamed""" + self.hass_api.build_mobile_app_cache() + for recipient in self.people.values(): + recipient.resolve_mobile_devices(self) + self.async_refresh_entity(recipient.name) + def register_entity(self, name: str, entity: SupernotifyRecipientBinarySensor) -> None: """Called by SupernotifyRecipientBinarySensor.async_added_to_hass().""" self._entities[name] = entity diff --git a/custom_components/supernotify/snoozer.py b/custom_components/supernotify/snoozer.py index cce9397ce..1e78fb4b3 100644 --- a/custom_components/supernotify/snoozer.py +++ b/custom_components/supernotify/snoozer.py @@ -313,12 +313,21 @@ def register_snooze( ) def purge_snoozes(self) -> None: - to_del: list[str] = [k for k, v in self.snoozes.items() if not v.active()] + to_del: list[str] = [k for k, v in self.snoozes.items() if not v.active() or self._mobile_app_gone(v)] for k in to_del: del self.snoozes[k] if to_del: self._persist() + def _mobile_app_gone(self, snooze: Snooze) -> bool: + """A snooze on a mobile device whose notify action no longer exists, e.g. re-paired under a new name""" + return ( + snooze.target_type == QualifiedTargetType.MOBILE + and isinstance(snooze.target, str) + and self.hass_api is not None + and not self.hass_api.mobile_app_action_exists(snooze.target) + ) + def clear(self) -> int: cleared: int = len(self.snoozes) self.snoozes.clear() diff --git a/custom_components/supernotify/transports/mobile_push.py b/custom_components/supernotify/transports/mobile_push.py index 952cae352..fb19d2faf 100644 --- a/custom_components/supernotify/transports/mobile_push.py +++ b/custom_components/supernotify/transports/mobile_push.py @@ -437,12 +437,22 @@ async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = Non simple_target = ( mobile_target if not Target.is_notify_entity(mobile_target) else mobile_target.replace("notify.", "") ) - _LOGGER.warning("SUPERNOTIFY Failed to send to %s, snoozing for a day", simple_target) + # a device whose notify action has gone (removed, or re-paired under another name) + # won't come back by waiting, so it gets a repair rather than a snooze that hides it + action_gone: bool = not self.context.hass_api.mobile_app_action_exists(simple_target) + if action_gone: + _LOGGER.warning("SUPERNOTIFY No notify action for %s, raising a repair", simple_target) + else: + _LOGGER.warning("SUPERNOTIFY Failed to send to %s, snoozing for a day", simple_target) if self.people_registry: # tie the mobile device back to a recipient for the snoozing API for recipient in self.people_registry.enabled_recipients(): for md in recipient.mobile_devices: - if md in (simple_target, mobile_target): + if md not in (simple_target, mobile_target): + continue + if action_gone: + recipient.check_mobile_actions(self.context.hass_api) + else: self.context.snoozer.register_snooze( CommandType.SNOOZE, target_type=QualifiedTargetType.MOBILE, diff --git a/tests/components/supernotify/test_engine.py b/tests/components/supernotify/test_engine.py index 291839827..d19701dd9 100644 --- a/tests/components/supernotify/test_engine.py +++ b/tests/components/supernotify/test_engine.py @@ -706,3 +706,15 @@ async def test_send_message_exception_handling(mock_hass: Mock) -> None: with patch("custom_components.supernotify.engine.Notification", side_effect=RuntimeError("boom")): await uut.async_send_message("test message") assert uut.failures == 1 + + +async def test_async_mobile_apps_changed_refreshes_recipients_and_purges_snoozes(mock_hass: Mock) -> None: + uut = SupernotifyEngine(mock_hass, recipients=RECIPIENTS) + await uut.initialize() + uut.context.people_registry.refresh_mobile_devices = Mock() # type: ignore[method-assign] + uut.context.snoozer.purge_snoozes = Mock() # type: ignore[method-assign] + + await uut.async_mobile_apps_changed() + + uut.context.people_registry.refresh_mobile_devices.assert_called_once() + uut.context.snoozer.purge_snoozes.assert_called_once() diff --git a/tests/components/supernotify/test_hass_api.py b/tests/components/supernotify/test_hass_api.py index a7abdf59d..b1f7483ba 100644 --- a/tests/components/supernotify/test_hass_api.py +++ b/tests/components/supernotify/test_hass_api.py @@ -19,6 +19,8 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import async_fire_time_changed from custom_components.supernotify.delivery import Delivery from custom_components.supernotify.hass_api import ( @@ -845,3 +847,53 @@ def handle(self, event: Event[EventStateChangedData]) -> None: assert rendered == [] assert "SUPERNOTIFY Unsubscribing: _remove_listener" in caplog.text assert "SUPERNOTIFY Unsubscribing: EventBus._async_remove_listener" in caplog.text + + +async def test_subscribe_mobile_app_changes_is_debounced_and_ignores_other_changes(hass: HomeAssistant) -> None: + from datetime import timedelta + + hass_api = HomeAssistantAPI(hass) + on_change = AsyncMock() + hass_api.subscribe_mobile_app_changes(on_change, cooldown=1) + + def settle() -> None: + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + + # not a mobile app, and not a mobile app's notify action + register_device(hass_api, device_id="50001111222233334444555566667777", domain="unit_testing", domain_id="hub") + hass.services.async_register("notify", "smtp", lambda _call: None) + await hass.async_block_till_done() + settle() + await hass.async_block_till_done() + on_change.assert_not_called() + + # a phone paired, with its notify action registered just after, is one change + device = register_mobile_app(hass_api, person="person.debounce_test", device_name="New Phone") + assert device is not None + await hass.async_block_till_done() + settle() + await hass.async_block_till_done() + assert on_change.await_count == 1 + + # and so is it going away + hass.services.async_remove("notify", "mobile_app_new_phone") + hass_api._device_registry().async_remove_device(device.id) # type: ignore[union-attr] + await hass.async_block_till_done() + settle() + await hass.async_block_till_done() + assert on_change.await_count == 2 + + hass_api.disconnect() + + +def test_build_mobile_app_cache_drops_a_device_that_has_gone(hass: HomeAssistant) -> None: + hass_api = HomeAssistantAPI(hass) + device = register_mobile_app(hass_api, person="person.gone_test", device_name="Old Phone") + assert device is not None + assert hass_api.mobile_app_by_id("mobile_app_old_phone") is not None + + hass_api._device_registry().async_remove_device(device.id) # type: ignore[union-attr] + hass_api.build_mobile_app_cache() + + assert hass_api.mobile_app_by_id("mobile_app_old_phone") is None + assert hass_api.mobile_app_by_device_id(device.id) is None diff --git a/tests/components/supernotify/test_people.py b/tests/components/supernotify/test_people.py index ecab92372..06e11d026 100644 --- a/tests/components/supernotify/test_people.py +++ b/tests/components/supernotify/test_people.py @@ -384,3 +384,41 @@ async def test_repair_raised_for_mobile_device_with_no_notify_action(hass: HomeA assert issue.translation_key == "recipient_mobile_app_not_found" assert issue.translation_placeholders == {"recipient": "person.test_user", "mobile_app": "mobile_app_jphone"} assert issue.is_fixable is False + + +async def test_refresh_mobile_devices_follows_a_re_paired_phone(hass: HomeAssistant) -> None: + from homeassistant.helpers import device_registry as dr + from homeassistant.helpers import issue_registry as ir + + ctx = TestingContext( + homeassistant=hass, + recipients=""" + - person: person.test_user + mobile_devices: + - mobile_app_id: mobile_app_ipad11 +""", + components={"person": {}}, + transport_types=[MobilePushTransport], + ) + watch = register_mobile_app(ctx.hass_api, person="person.test_user", device_name="Bobs Watch") + assert watch is not None + await ctx.test_initialize() + bob: Recipient = ctx.people_registry.people["person.test_user"] + assert list(bob.enabled_mobile_devices) == unordered("mobile_app_ipad11", "mobile_app_bobs_watch") + + def issues() -> list[str]: + return [i for (domain, i) in ir.async_get(hass).issues if domain == "supernotify" and "mobile_app" in i] + + assert issues() == ["recipient_test_user_mobile_app_mobile_app_ipad11_not_found"] + + # the watch is re-paired under a new name, and the configured iPad's app turns up + dr.async_get(hass).async_remove_device(watch.id) + hass.services.async_remove("notify", "mobile_app_bobs_watch") + register_mobile_app(ctx.hass_api, person="person.test_user", device_name="Orologio") + hass.services.async_register("notify", "mobile_app_ipad11", lambda _call: None) + + ctx.people_registry.refresh_mobile_devices() + + assert list(bob.enabled_mobile_devices) == unordered("mobile_app_ipad11", "mobile_app_orologio") + assert bob.target("mobile_push").mobile_app_ids == unordered("mobile_app_ipad11", "mobile_app_orologio") + assert issues() == [] diff --git a/tests/components/supernotify/test_snoozer.py b/tests/components/supernotify/test_snoozer.py index 8871a5ca2..4e4f697d7 100644 --- a/tests/components/supernotify/test_snoozer.py +++ b/tests/components/supernotify/test_snoozer.py @@ -346,3 +346,19 @@ def test_snoozer_export_skips_expired_snoozes() -> None: exported = uut.export() assert len(exported) == 1 assert exported[0]["target"] == "email" + + +async def test_snoozer_purge_drops_snooze_on_a_mobile_app_that_has_gone( + hass: HomeAssistant, unmocked_hass_api: HomeAssistantAPI +) -> None: + hass.services.async_register("notify", "mobile_app_still_here", lambda _call: None) + uut = Snoozer() + await uut.initialize(unmocked_hass_api) + for target in ("mobile_app_still_here", "mobile_app_re_paired"): + uut.register_snooze( + CommandType.SNOOZE, QualifiedTargetType.MOBILE, target, RecipientType.USER, "person.bob", timedelta(days=1) + ) + + uut.purge_snoozes() + + assert [s.target for s in uut.snoozes.values()] == ["mobile_app_still_here"] diff --git a/tests/components/supernotify/transports/test_transport_mobile_push.py b/tests/components/supernotify/transports/test_transport_mobile_push.py index 5657309b6..a1688cc58 100644 --- a/tests/components/supernotify/transports/test_transport_mobile_push.py +++ b/tests/components/supernotify/transports/test_transport_mobile_push.py @@ -761,4 +761,9 @@ async def test_missing_mobile_action_is_not_an_error(hass: HomeAssistant) -> Non envelope = uut.delivered_envelopes[0] assert envelope.delivery_error is None assert envelope.failed_calls[0].exception == "Action notify.mobile_app_nophone not found" - assert list(ctx.snoozer.snoozes) == ["MOBILE_mobile_app_nophone_person.bidey_in"] + # gone rather than unreachable, so a repair says so, instead of a snooze hiding it + assert ctx.snoozer.snoozes == {} + from homeassistant.helpers import issue_registry as ir + + issues = [i for (domain, i) in ir.async_get(hass).issues if domain == DOMAIN and "mobile_app" in i] + assert issues == ["recipient_bidey_in_mobile_app_mobile_app_nophone_not_found"]