diff --git a/homeassistant/components/ghost/quality_scale.yaml b/homeassistant/components/ghost/quality_scale.yaml index 6603b309204..55bc8670dc9 100644 --- a/homeassistant/components/ghost/quality_scale.yaml +++ b/homeassistant/components/ghost/quality_scale.yaml @@ -72,9 +72,7 @@ rules: repair-issues: status: exempt comment: No repair scenarios identified for this integration. - stale-devices: - status: todo - comment: Remove newsletter entities when newsletter is removed + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/ghost/sensor.py b/homeassistant/components/ghost/sensor.py index 9986edc9dee..9fd3ea977c6 100644 --- a/homeassistant/components/ghost/sensor.py +++ b/homeassistant/components/ghost/sensor.py @@ -12,7 +12,9 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -210,36 +212,67 @@ async def async_setup_entry( async_add_entities(entities) + # Remove stale newsletter entities left over from previous runs. + entity_registry = er.async_get(hass) + prefix = f"{entry.unique_id}_newsletter_" + active_newsletters = { + newsletter_id + for newsletter_id, newsletter in coordinator.data.newsletters.items() + if newsletter.get("status") == "active" + } + for entity_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ): + if ( + entity_entry.unique_id.startswith(prefix) + and entity_entry.unique_id[len(prefix) :] not in active_newsletters + ): + entity_registry.async_remove(entity_entry.entity_id) + newsletter_added: set[str] = set() @callback - def _async_add_newsletter_entities() -> None: - """Add newsletter entities when new newsletters appear.""" + def _async_update_newsletter_entities() -> None: + """Add new and remove stale newsletter entities.""" nonlocal newsletter_added - new_newsletters = { + active_newsletters = { newsletter_id for newsletter_id, newsletter in coordinator.data.newsletters.items() if newsletter.get("status") == "active" - } - newsletter_added + } - if not new_newsletters: - return + new_newsletters = active_newsletters - newsletter_added - async_add_entities( - GhostNewsletterSensorEntity( - coordinator, - entry, - newsletter_id, - coordinator.data.newsletters[newsletter_id].get("name", "Newsletter"), + if new_newsletters: + async_add_entities( + GhostNewsletterSensorEntity( + coordinator, + entry, + newsletter_id, + coordinator.data.newsletters[newsletter_id].get( + "name", "Newsletter" + ), + ) + for newsletter_id in new_newsletters ) - for newsletter_id in new_newsletters - ) - newsletter_added |= new_newsletters + newsletter_added.update(new_newsletters) - _async_add_newsletter_entities() + removed_newsletters = newsletter_added - active_newsletters + if removed_newsletters: + entity_registry = er.async_get(hass) + for newsletter_id in removed_newsletters: + unique_id = f"{entry.unique_id}_newsletter_{newsletter_id}" + entity_id = entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, unique_id + ) + if entity_id: + entity_registry.async_remove(entity_id) + newsletter_added -= removed_newsletters + + _async_update_newsletter_entities() entry.async_on_unload( - coordinator.async_add_listener(_async_add_newsletter_entities) + coordinator.async_add_listener(_async_update_newsletter_entities) ) @@ -310,9 +343,10 @@ class GhostNewsletterSensorEntity( @property def available(self) -> bool: """Return True if the entity is available.""" - if not super().available or self.coordinator.data is None: - return False - return self._newsletter_id in self.coordinator.data.newsletters + return ( + super().available + and self._newsletter_id in self.coordinator.data.newsletters + ) @property def native_value(self) -> int | None: diff --git a/tests/components/ghost/test_sensor.py b/tests/components/ghost/test_sensor.py index 2b85217219c..30675ed9260 100644 --- a/tests/components/ghost/test_sensor.py +++ b/tests/components/ghost/test_sensor.py @@ -76,13 +76,14 @@ async def test_revenue_sensors_not_created_without_stripe( assert hass.states.get("sensor.test_ghost_arr") is None -async def test_newsletter_sensor_not_found( +async def test_newsletter_sensor_removed_when_stale( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_ghost_api: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test newsletter sensor when newsletter is removed.""" + """Test newsletter sensor is removed when newsletter disappears.""" await setup_integration(hass, mock_config_entry) # Verify newsletter sensor exists @@ -97,10 +98,35 @@ async def test_newsletter_sensor_not_found( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - # Sensor should now be unavailable (newsletter not found) - state = hass.states.get("sensor.test_ghost_weekly_subscribers") - assert state is not None - assert state.state == STATE_UNAVAILABLE + # Entity should be removed from state and registry + assert hass.states.get("sensor.test_ghost_weekly_subscribers") is None + assert entity_registry.async_get("sensor.test_ghost_weekly_subscribers") is None + + +async def test_newsletter_sensor_removed_on_reload( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ghost_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test stale newsletter sensor is removed when integration reloads.""" + await setup_integration(hass, mock_config_entry) + + # Verify newsletter sensor exists + assert entity_registry.async_get("sensor.test_ghost_weekly_subscribers") is not None + + # Unload the integration + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Newsletter is gone when integration reloads + mock_ghost_api.get_newsletters.return_value = [] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Entity should be removed from registry + assert entity_registry.async_get("sensor.test_ghost_weekly_subscribers") is None async def test_entities_unavailable_on_update_failure(