From fc3843f5e2de3a22d1d5170e747ee905524ec65b Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 2 Nov 2022 17:11:44 +0200 Subject: [PATCH] Add config flow to `pushbullet` (#74240) Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + CODEOWNERS | 2 + .../components/pushbullet/__init__.py | 78 +++++++ homeassistant/components/pushbullet/api.py | 32 +++ .../components/pushbullet/config_flow.py | 63 +++++ homeassistant/components/pushbullet/const.py | 12 + .../components/pushbullet/manifest.json | 3 +- homeassistant/components/pushbullet/notify.py | 172 +++++++------- homeassistant/components/pushbullet/sensor.py | 138 +++++------ .../components/pushbullet/strings.json | 25 ++ .../pushbullet/translations/en.json | 25 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/pushbullet/__init__.py | 4 + tests/components/pushbullet/conftest.py | 28 +++ .../pushbullet/fixtures/channels.json | 14 ++ .../components/pushbullet/fixtures/chats.json | 18 ++ .../pushbullet/fixtures/user_info.json | 10 + .../components/pushbullet/test_config_flow.py | 134 +++++++++++ tests/components/pushbullet/test_init.py | 84 +++++++ tests/components/pushbullet/test_notify.py | 219 +++++------------- 21 files changed, 755 insertions(+), 310 deletions(-) create mode 100644 homeassistant/components/pushbullet/api.py create mode 100644 homeassistant/components/pushbullet/config_flow.py create mode 100644 homeassistant/components/pushbullet/const.py create mode 100644 homeassistant/components/pushbullet/strings.json create mode 100644 homeassistant/components/pushbullet/translations/en.json create mode 100644 tests/components/pushbullet/conftest.py create mode 100644 tests/components/pushbullet/fixtures/channels.json create mode 100644 tests/components/pushbullet/fixtures/chats.json create mode 100644 tests/components/pushbullet/fixtures/user_info.json create mode 100644 tests/components/pushbullet/test_config_flow.py create mode 100644 tests/components/pushbullet/test_init.py diff --git a/.coveragerc b/.coveragerc index 9d33acf6c75..b782f8444db 100644 --- a/.coveragerc +++ b/.coveragerc @@ -999,6 +999,7 @@ omit = homeassistant/components/proxmoxve/* homeassistant/components/proxy/camera.py homeassistant/components/pulseaudio_loopback/switch.py + homeassistant/components/pushbullet/api.py homeassistant/components/pushbullet/notify.py homeassistant/components/pushbullet/sensor.py homeassistant/components/pushover/notify.py diff --git a/CODEOWNERS b/CODEOWNERS index 4012868c712..cb4e68f1535 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -880,6 +880,8 @@ build.json @home-assistant/supervisor /tests/components/pure_energie/ @klaasnicolaas /homeassistant/components/push/ @dgomes /tests/components/push/ @dgomes +/homeassistant/components/pushbullet/ @engrbm87 +/tests/components/pushbullet/ @engrbm87 /homeassistant/components/pushover/ @engrbm87 /tests/components/pushover/ @engrbm87 /homeassistant/components/pvoutput/ @frenck diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index 153fa389fcc..bed0e94ccd9 100644 --- a/homeassistant/components/pushbullet/__init__.py +++ b/homeassistant/components/pushbullet/__init__.py @@ -1 +1,79 @@ """The pushbullet component.""" +from __future__ import annotations + +import logging + +from pushbullet import InvalidKeyError, PushBullet, PushbulletError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_NAME, + EVENT_HOMEASSISTANT_START, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType + +from .api import PushBulletNotificationProvider +from .const import DATA_HASS_CONFIG, DOMAIN + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the pushbullet component.""" + + hass.data[DATA_HASS_CONFIG] = config + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up pushbullet from a config entry.""" + + try: + pushbullet = await hass.async_add_executor_job( + PushBullet, entry.data[CONF_API_KEY] + ) + except InvalidKeyError: + _LOGGER.error("Invalid API key for Pushbullet") + return False + except PushbulletError as err: + raise ConfigEntryNotReady from err + + pb_provider = PushBulletNotificationProvider(hass, pushbullet) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = pb_provider + + def start_listener(event: Event) -> None: + """Start the listener thread.""" + _LOGGER.debug("Starting listener for pushbullet") + pb_provider.start() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_listener) + + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_NAME: entry.data[CONF_NAME], "entry_id": entry.entry_id}, + hass.data[DATA_HASS_CONFIG], + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN].pop( + entry.entry_id + ) + await hass.async_add_executor_job(pb_provider.close) + return unload_ok diff --git a/homeassistant/components/pushbullet/api.py b/homeassistant/components/pushbullet/api.py new file mode 100644 index 00000000000..ff6a57aa931 --- /dev/null +++ b/homeassistant/components/pushbullet/api.py @@ -0,0 +1,32 @@ +"""Pushbullet Notification provider.""" + +from typing import Any + +from pushbullet import Listener, PushBullet + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import DATA_UPDATED + + +class PushBulletNotificationProvider(Listener): + """Provider for an account, leading to one or more sensors.""" + + def __init__(self, hass: HomeAssistant, pushbullet: PushBullet) -> None: + """Start to retrieve pushes from the given Pushbullet instance.""" + self.hass = hass + self.pushbullet = pushbullet + self.data: dict[str, Any] = {} + super().__init__(account=pushbullet, on_push=self.update_data) + self.daemon = True + + def update_data(self, data: dict[str, Any]) -> None: + """Update the current data. + + Currently only monitors pushes but might be extended to monitor + different kinds of Pushbullet events. + """ + if data["type"] == "push": + self.data = data["push"] + dispatcher_send(self.hass, DATA_UPDATED) diff --git a/homeassistant/components/pushbullet/config_flow.py b/homeassistant/components/pushbullet/config_flow.py new file mode 100644 index 00000000000..bfa12a911b6 --- /dev/null +++ b/homeassistant/components/pushbullet/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for pushbullet integration.""" +from __future__ import annotations + +from typing import Any + +from pushbullet import InvalidKeyError, PushBullet, PushbulletError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector + +from .const import DEFAULT_NAME, DOMAIN + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(), + vol.Required(CONF_API_KEY): selector.TextSelector(), + } +) + + +class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for pushbullet integration.""" + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Handle import from config.""" + import_config[CONF_NAME] = import_config.get(CONF_NAME, DEFAULT_NAME) + return await self.async_step_user(import_config) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + + self._async_abort_entries_match({CONF_NAME: user_input[CONF_NAME]}) + + try: + pushbullet = await self.hass.async_add_executor_job( + PushBullet, user_input[CONF_API_KEY] + ) + except InvalidKeyError: + errors[CONF_API_KEY] = "invalid_api_key" + except PushbulletError: + errors["base"] = "cannot_connect" + + if not errors: + await self.async_set_unique_id(pushbullet.user_info["iden"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=CONFIG_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/pushbullet/const.py b/homeassistant/components/pushbullet/const.py new file mode 100644 index 00000000000..de81f56e862 --- /dev/null +++ b/homeassistant/components/pushbullet/const.py @@ -0,0 +1,12 @@ +"""Constants for the pushbullet integration.""" + +from typing import Final + +DOMAIN: Final = "pushbullet" +DEFAULT_NAME: Final = "Pushbullet" +DATA_HASS_CONFIG: Final = "pushbullet_hass_config" +DATA_UPDATED: Final = "pushbullet_data_updated" + +ATTR_URL: Final = "url" +ATTR_FILE: Final = "file" +ATTR_FILE_URL: Final = "file_url" diff --git a/homeassistant/components/pushbullet/manifest.json b/homeassistant/components/pushbullet/manifest.json index 7931cca70cc..7fcaa59fbb8 100644 --- a/homeassistant/components/pushbullet/manifest.json +++ b/homeassistant/components/pushbullet/manifest.json @@ -3,7 +3,8 @@ "name": "Pushbullet", "documentation": "https://www.home-assistant.io/integrations/pushbullet", "requirements": ["pushbullet.py==0.11.0"], - "codeowners": [], + "codeowners": ["@engrbm87"], + "config_flow": true, "iot_class": "cloud_polling", "loggers": ["pushbullet"] } diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 6f851f8000e..fcc9d00dc7a 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -1,8 +1,13 @@ """Pushbullet platform for notify component.""" +from __future__ import annotations + import logging import mimetypes +from typing import Any -from pushbullet import InvalidKeyError, PushBullet, PushError +from pushbullet import PushBullet, PushError +from pushbullet.channel import Channel +from pushbullet.device import Device import voluptuous as vol from homeassistant.components.notify import ( @@ -13,59 +18,69 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) -ATTR_URL = "url" -ATTR_FILE = "file" -ATTR_FILE_URL = "file_url" -ATTR_LIST = "list" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) -def get_service(hass, config, discovery_info=None): +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> PushBulletNotificationService | None: """Get the Pushbullet notification service.""" - - try: - pushbullet = PushBullet(config[CONF_API_KEY]) - except InvalidKeyError: - _LOGGER.error("Wrong API key supplied") + if discovery_info is None: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) return None - return PushBulletNotificationService(pushbullet) + pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet + return PushBulletNotificationService(hass, pushbullet) class PushBulletNotificationService(BaseNotificationService): """Implement the notification service for Pushbullet.""" - def __init__(self, pb): # pylint: disable=invalid-name + def __init__(self, hass: HomeAssistant, pushbullet: PushBullet) -> None: """Initialize the service.""" - self.pushbullet = pb - self.pbtargets = {} - self.refresh() + self.hass = hass + self.pushbullet = pushbullet - def refresh(self): - """Refresh devices, contacts, etc. - - pbtargets stores all targets available from this Pushbullet instance - into a dict. These are Pushbullet objects!. It sacrifices a bit of - memory for faster processing at send_message. - - As of sept 2015, contacts were replaced by chats. This is not - implemented in the module yet. - """ - self.pushbullet.refresh() - self.pbtargets = { + @property + def pbtargets(self) -> dict[str, dict[str, Device | Channel]]: + """Return device and channel detected targets.""" + return { "device": {tgt.nickname.lower(): tgt for tgt in self.pushbullet.devices}, "channel": { tgt.channel_tag.lower(): tgt for tgt in self.pushbullet.channels }, } - def send_message(self, message=None, **kwargs): + def send_message(self, message: str, **kwargs: Any) -> None: """Send a message to a specified target. If no target specified, a 'normal' push will be sent to all devices @@ -73,24 +88,25 @@ class PushBulletNotificationService(BaseNotificationService): Email is special, these are assumed to always exist. We use a special call which doesn't require a push object. """ - targets = kwargs.get(ATTR_TARGET) - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - data = kwargs.get(ATTR_DATA) - refreshed = False + targets: list[str] = kwargs.get(ATTR_TARGET, []) + title: str = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + data: dict[str, Any] = kwargs[ATTR_DATA] or {} if not targets: # Backward compatibility, notify all devices in own account. self._push_data(message, title, data, self.pushbullet) - _LOGGER.info("Sent notification to self") + _LOGGER.debug("Sent notification to self") return + # refresh device and channel targets + self.pushbullet.refresh() + # Main loop, process all targets specified. for target in targets: try: ttype, tname = target.split("/", 1) - except ValueError: - _LOGGER.error("Invalid target syntax: %s", target) - continue + except ValueError as err: + raise ValueError(f"Invalid target syntax: '{target}'") from err # Target is email, send directly, don't use a target object. # This also seems to work to send to all devices in own account. @@ -107,71 +123,57 @@ class PushBulletNotificationService(BaseNotificationService): _LOGGER.info("Sent sms notification to %s", tname) continue - # Refresh if name not found. While awaiting periodic refresh - # solution in component, poor mans refresh. if ttype not in self.pbtargets: - _LOGGER.error("Invalid target syntax: %s", target) - continue + raise ValueError(f"Invalid target syntax: {target}") tname = tname.lower() - if tname not in self.pbtargets[ttype] and not refreshed: - self.refresh() - refreshed = True + if tname not in self.pbtargets[ttype]: + raise ValueError(f"Target: {target} doesn't exist") # Attempt push_note on a dict value. Keys are types & target # name. Dict pbtargets has all *actual* targets. - try: - self._push_data(message, title, data, self.pbtargets[ttype][tname]) - _LOGGER.info("Sent notification to %s/%s", ttype, tname) - except KeyError: - _LOGGER.error("No such target: %s/%s", ttype, tname) - continue + self._push_data(message, title, data, self.pbtargets[ttype][tname]) + _LOGGER.debug("Sent notification to %s/%s", ttype, tname) - def _push_data(self, message, title, data, pusher, email=None, phonenumber=None): + def _push_data( + self, + message: str, + title: str, + data: dict[str, Any], + pusher: PushBullet, + email: str | None = None, + phonenumber: str | None = None, + ): """Create the message content.""" + kwargs = {"body": message, "title": title} + if email: + kwargs["email"] = email - if data is None: - data = {} - data_list = data.get(ATTR_LIST) - url = data.get(ATTR_URL) - filepath = data.get(ATTR_FILE) - file_url = data.get(ATTR_FILE_URL) try: - email_kwargs = {} - if email: - email_kwargs["email"] = email - if phonenumber: - device = pusher.devices[0] - pusher.push_sms(device, phonenumber, message) - elif url: - pusher.push_link(title, url, body=message, **email_kwargs) - elif filepath: + if phonenumber and pusher.devices: + pusher.push_sms(pusher.devices[0], phonenumber, message) + return + if url := data.get(ATTR_URL): + pusher.push_link(url=url, **kwargs) + return + if filepath := data.get(ATTR_FILE): if not self.hass.config.is_allowed_path(filepath): - _LOGGER.error("Filepath is not valid or allowed") - return + raise ValueError("Filepath is not valid or allowed") with open(filepath, "rb") as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) - if filedata.get("file_type") == "application/x-empty": - _LOGGER.error("Can not send an empty file") - return - filedata.update(email_kwargs) - pusher.push_file(title=title, body=message, **filedata) - elif file_url: - if not file_url.startswith("http"): - _LOGGER.error("URL should start with http or https") - return + if filedata.get("file_type") == "application/x-empty": + raise ValueError("Cannot send an empty file") + kwargs.update(filedata) + pusher.push_file(**kwargs) + elif (file_url := data.get(ATTR_FILE_URL)) and vol.Url(file_url): pusher.push_file( - title=title, - body=message, file_name=file_url, file_url=file_url, file_type=(mimetypes.guess_type(file_url)[0]), - **email_kwargs, + **kwargs, ) - elif data_list: - pusher.push_list(title, data_list, **email_kwargs) else: - pusher.push_note(title, message, **email_kwargs) + pusher.push_note(**kwargs) except PushError as err: - _LOGGER.error("Notify failed: %s", err) + raise HomeAssistantError(f"Notify failed: {err}") from err diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 51a18f1aaea..aef97991c66 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,10 +1,6 @@ """Pushbullet platform for sensor component.""" from __future__ import annotations -import logging -import threading - -from pushbullet import InvalidKeyError, Listener, PushBullet import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,18 +8,25 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .api import PushBulletNotificationProvider +from .const import DATA_UPDATED, DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="application_name", name="Application name", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="body", @@ -32,26 +35,32 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="notification_id", name="Notification ID", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="notification_tag", name="Notification tag", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="package_name", name="Package name", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="receiver_email", name="Receiver email", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="sender_email", name="Sender email", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="source_device_iden", name="Sender device ID", + entity_registry_enabled_default=False, ), SensorEntityDescription( key="title", @@ -60,6 +69,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="type", name="Type", + entity_registry_enabled_default=False, ), ) @@ -75,94 +85,88 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Pushbullet Sensor platform.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - try: - pushbullet = PushBullet(config.get(CONF_API_KEY)) - except InvalidKeyError: - _LOGGER.error("Wrong API key for Pushbullet supplied") - return - pbprovider = PushBulletNotificationProvider(pushbullet) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Pushbullet sensors from config entry.""" + + pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][entry.entry_id] - monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [ - PushBulletNotificationSensor(pbprovider, description) + PushBulletNotificationSensor(entry.data[CONF_NAME], pb_provider, description) for description in SENSOR_TYPES - if description.key in monitored_conditions ] - add_entities(entities) + + async_add_entities(entities) class PushBulletNotificationSensor(SensorEntity): """Representation of a Pushbullet Sensor.""" + _attr_should_poll = False + _attr_has_entity_name = True + def __init__( self, - pb, # pylint: disable=invalid-name + name: str, + pb_provider: PushBulletNotificationProvider, description: SensorEntityDescription, - ): + ) -> None: """Initialize the Pushbullet sensor.""" self.entity_description = description - self.pushbullet = pb + self.pb_provider = pb_provider + self._attr_unique_id = ( + f"{pb_provider.pushbullet.user_info['iden']}-{description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, pb_provider.pushbullet.user_info["iden"])}, + name=name, + entry_type=DeviceEntryType.SERVICE, + ) - self._attr_name = f"Pushbullet {description.key}" - - def update(self) -> None: + @callback + def async_update_callback(self) -> None: """Fetch the latest data from the sensor. This will fetch the 'sensor reading' into self._state but also all attributes into self._state_attributes. """ try: - self._attr_native_value = self.pushbullet.data[self.entity_description.key] - self._attr_extra_state_attributes = self.pushbullet.data + self._attr_native_value = self.pb_provider.data[self.entity_description.key] + self._attr_extra_state_attributes = self.pb_provider.data except (KeyError, TypeError): pass + self.async_write_ha_state() - -class PushBulletNotificationProvider: - """Provider for an account, leading to one or more sensors.""" - - def __init__(self, pushbullet): - """Start to retrieve pushes from the given Pushbullet instance.""" - - self.pushbullet = pushbullet - self._data = None - self.listener = None - self.thread = threading.Thread(target=self.retrieve_pushes) - self.thread.daemon = True - self.thread.start() - - def on_push(self, data): - """Update the current data. - - Currently only monitors pushes but might be extended to monitor - different kinds of Pushbullet events. - """ - if data["type"] == "push": - self._data = data["push"] - - @property - def data(self): - """Return the current data stored in the provider.""" - return self._data - - def retrieve_pushes(self): - """Retrieve_pushes. - - Spawn a new Listener and links it to self.on_push. - """ - - self.listener = Listener(account=self.pushbullet, on_push=self.on_push) - _LOGGER.debug("Getting pushes") - try: - self.listener.run_forever() - finally: - self.listener.close() + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, DATA_UPDATED, self.async_update_callback + ) + ) diff --git a/homeassistant/components/pushbullet/strings.json b/homeassistant/components/pushbullet/strings.json new file mode 100644 index 00000000000..92d22d117dc --- /dev/null +++ b/homeassistant/components/pushbullet/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Pushbullet YAML configuration is being removed", + "description": "Configuring Pushbullet using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushbullet YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/pushbullet/translations/en.json b/homeassistant/components/pushbullet/translations/en.json new file mode 100644 index 00000000000..97175ddf0b0 --- /dev/null +++ b/homeassistant/components/pushbullet/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "name": "Name" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring Pushbullet using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushbullet YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "The Pushbullet YAML configuration is being removed" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5279f343691..5214436be42 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -304,6 +304,7 @@ FLOWS = { "prusalink", "ps4", "pure_energie", + "pushbullet", "pushover", "pvoutput", "pvpc_hourly_pricing", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 57452743af1..a955193624c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4085,7 +4085,7 @@ "pushbullet": { "name": "Pushbullet", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "pushover": { diff --git a/tests/components/pushbullet/__init__.py b/tests/components/pushbullet/__init__.py index c7f7911950c..74c89f33f2b 100644 --- a/tests/components/pushbullet/__init__.py +++ b/tests/components/pushbullet/__init__.py @@ -1 +1,5 @@ """Tests for the pushbullet component.""" + +from homeassistant.const import CONF_API_KEY, CONF_NAME + +MOCK_CONFIG = {CONF_NAME: "pushbullet", CONF_API_KEY: "MYAPIKEY"} diff --git a/tests/components/pushbullet/conftest.py b/tests/components/pushbullet/conftest.py new file mode 100644 index 00000000000..5fadff3a825 --- /dev/null +++ b/tests/components/pushbullet/conftest.py @@ -0,0 +1,28 @@ +"""Conftest for pushbullet integration.""" + +from pushbullet import PushBullet +import pytest +from requests_mock import Mocker + +from tests.common import load_fixture + + +@pytest.fixture(autouse=True) +def requests_mock_fixture(requests_mock: Mocker) -> None: + """Fixture to provide a aioclient mocker.""" + requests_mock.get( + PushBullet.DEVICES_URL, + text=load_fixture("devices.json", "pushbullet"), + ) + requests_mock.get( + PushBullet.ME_URL, + text=load_fixture("user_info.json", "pushbullet"), + ) + requests_mock.get( + PushBullet.CHATS_URL, + text=load_fixture("chats.json", "pushbullet"), + ) + requests_mock.get( + PushBullet.CHANNELS_URL, + text=load_fixture("channels.json", "pushbullet"), + ) diff --git a/tests/components/pushbullet/fixtures/channels.json b/tests/components/pushbullet/fixtures/channels.json new file mode 100644 index 00000000000..b95ca50b7c2 --- /dev/null +++ b/tests/components/pushbullet/fixtures/channels.json @@ -0,0 +1,14 @@ +{ + "channels": [ + { + "active": true, + "created": 1412047948.579029, + "description": "Sample channel.", + "iden": "ujxPklLhvyKsjAvkMyTVh6", + "image_url": "https://dl.pushbulletusercontent.com/abc123/image.jpg", + "modified": 1412047948.579031, + "name": "Sample channel", + "tag": "sample-channel" + } + ] +} diff --git a/tests/components/pushbullet/fixtures/chats.json b/tests/components/pushbullet/fixtures/chats.json new file mode 100644 index 00000000000..4c52bcc58cc --- /dev/null +++ b/tests/components/pushbullet/fixtures/chats.json @@ -0,0 +1,18 @@ +{ + "chats": [ + { + "active": true, + "created": 1412047948.579029, + "iden": "ujpah72o0sjAoRtnM0jc", + "modified": 1412047948.579031, + "with": { + "email": "someone@example.com", + "email_normalized": "someone@example.com", + "iden": "ujlMns72k", + "image_url": "https://dl.pushbulletusercontent.com/acb123/example.jpg", + "name": "Someone", + "type": "user" + } + } + ] +} diff --git a/tests/components/pushbullet/fixtures/user_info.json b/tests/components/pushbullet/fixtures/user_info.json new file mode 100644 index 00000000000..3a17cccbf07 --- /dev/null +++ b/tests/components/pushbullet/fixtures/user_info.json @@ -0,0 +1,10 @@ +{ + "created": 1381092887.398433, + "email": "example@email.com", + "email_normalized": "example@email.com", + "iden": "ujpah72o0", + "image_url": "https://static.pushbullet.com/missing-image/55a7dc-45", + "max_upload_size": 26214400, + "modified": 1441054560.741007, + "name": "Some name" +} diff --git a/tests/components/pushbullet/test_config_flow.py b/tests/components/pushbullet/test_config_flow.py new file mode 100644 index 00000000000..a19c424c8be --- /dev/null +++ b/tests/components/pushbullet/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test pushbullet config flow.""" +from unittest.mock import patch + +from pushbullet import InvalidKeyError, PushbulletError +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.pushbullet.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from . import MOCK_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def pushbullet_setup_fixture(): + """Patch pushbullet setup entry.""" + with patch( + "homeassistant.components.pushbullet.async_setup_entry", return_value=True + ): + yield + + +async def test_flow_user(hass: HomeAssistant, requests_mock_fixture) -> None: + """Test user initialized flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "pushbullet" + assert result["data"] == MOCK_CONFIG + + +async def test_flow_user_already_configured( + hass: HomeAssistant, requests_mock_fixture +) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + unique_id="ujpah72o0", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_name_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + unique_id="MYAPIKEY", + ) + + entry.add_to_hass(hass) + + new_config = MOCK_CONFIG.copy() + new_config[CONF_API_KEY] = "NEWKEY" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=new_config, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_invalid_key(hass: HomeAssistant) -> None: + """Test user initialized flow with invalid api key.""" + + with patch( + "homeassistant.components.pushbullet.config_flow.PushBullet", + side_effect=InvalidKeyError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_flow_conn_error(hass: HomeAssistant) -> None: + """Test user initialized flow with conn error.""" + + with patch( + "homeassistant.components.pushbullet.config_flow.PushBullet", + side_effect=PushbulletError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_import(hass: HomeAssistant, requests_mock_fixture) -> None: + """Test user initialized flow with unreachable server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "pushbullet" + assert result["data"] == MOCK_CONFIG diff --git a/tests/components/pushbullet/test_init.py b/tests/components/pushbullet/test_init.py new file mode 100644 index 00000000000..6f8e3776b35 --- /dev/null +++ b/tests/components/pushbullet/test_init.py @@ -0,0 +1,84 @@ +"""Test pushbullet integration.""" +from unittest.mock import patch + +from pushbullet import InvalidKeyError, PushbulletError + +from homeassistant.components.pushbullet.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant + +from . import MOCK_CONFIG + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry_success( + hass: HomeAssistant, requests_mock_fixture +) -> None: + """Test pushbullet successful setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.pushbullet.api.PushBulletNotificationProvider.start" + ) as mock_start: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_start.assert_called_once() + + +async def test_setup_entry_failed_invalid_key(hass: HomeAssistant) -> None: + """Test pushbullet failed setup due to invalid key.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.pushbullet.PushBullet", + side_effect=InvalidKeyError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_failed_conn_error(hass: HomeAssistant) -> None: + """Test pushbullet failed setup due to conn error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.pushbullet.PushBullet", + side_effect=PushbulletError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_async_unload_entry(hass: HomeAssistant, requests_mock_fixture) -> None: + """Test pushbullet unload entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/pushbullet/test_notify.py b/tests/components/pushbullet/test_notify.py index a9186652f64..2773679f22a 100644 --- a/tests/components/pushbullet/test_notify.py +++ b/tests/components/pushbullet/test_notify.py @@ -1,109 +1,65 @@ -"""The tests for the pushbullet notification platform.""" +"""Test pushbullet notification platform.""" from http import HTTPStatus -import json -from unittest.mock import patch -from pushbullet import PushBullet -import pytest +from requests_mock import Mocker -import homeassistant.components.notify as notify -from homeassistant.setup import async_setup_component +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.pushbullet.const import DOMAIN -from tests.common import assert_setup_component, load_fixture +from . import MOCK_CONFIG + +from tests.common import MockConfigEntry -@pytest.fixture -def mock_pushbullet(): - """Mock pushbullet.""" - with patch.object( - PushBullet, - "_get_data", - return_value=json.loads(load_fixture("devices.json", "pushbullet")), - ): - yield - - -async def test_pushbullet_config(hass, mock_pushbullet): - """Test setup.""" - config = { - notify.DOMAIN: { - "name": "test", - "platform": "pushbullet", - "api_key": "MYFAKEKEY", - } - } - with assert_setup_component(1) as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert handle_config[notify.DOMAIN] - - -async def test_pushbullet_config_bad(hass): - """Test set up the platform with bad/missing configuration.""" - config = {notify.DOMAIN: {"platform": "pushbullet"}} - with assert_setup_component(0) as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert not handle_config[notify.DOMAIN] - - -async def test_pushbullet_push_default(hass, requests_mock, mock_pushbullet): +async def test_pushbullet_push_default(hass, requests_mock: Mocker): """Test pushbullet push to default target.""" - config = { - notify.DOMAIN: { - "name": "test", - "platform": "pushbullet", - "api_key": "MYFAKEKEY", - } - } - with assert_setup_component(1) as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert handle_config[notify.DOMAIN] requests_mock.register_uri( "POST", "https://api.pushbullet.com/v2/pushes", status_code=HTTPStatus.OK, json={"mock_response": "Ok"}, ) - data = {"title": "Test Title", "message": "Test Message"} - await hass.services.async_call(notify.DOMAIN, "test", data) + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + data = {"title": "Test Title", "message": "Test Message"} + await hass.services.async_call(NOTIFY_DOMAIN, "pushbullet", data) await hass.async_block_till_done() - assert requests_mock.called - assert requests_mock.call_count == 1 expected_body = {"body": "Test Message", "title": "Test Title", "type": "note"} + assert requests_mock.last_request assert requests_mock.last_request.json() == expected_body -async def test_pushbullet_push_device(hass, requests_mock, mock_pushbullet): +async def test_pushbullet_push_device(hass, requests_mock): """Test pushbullet push to default target.""" - config = { - notify.DOMAIN: { - "name": "test", - "platform": "pushbullet", - "api_key": "MYFAKEKEY", - } - } - with assert_setup_component(1) as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert handle_config[notify.DOMAIN] requests_mock.register_uri( "POST", "https://api.pushbullet.com/v2/pushes", status_code=HTTPStatus.OK, json={"mock_response": "Ok"}, ) + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + data = { "title": "Test Title", "message": "Test Message", "target": ["device/DESKTOP"], } - await hass.services.async_call(notify.DOMAIN, "test", data) + await hass.services.async_call(NOTIFY_DOMAIN, "pushbullet", data) await hass.async_block_till_done() - assert requests_mock.called - assert requests_mock.call_count == 1 expected_body = { "body": "Test Message", @@ -114,35 +70,29 @@ async def test_pushbullet_push_device(hass, requests_mock, mock_pushbullet): assert requests_mock.last_request.json() == expected_body -async def test_pushbullet_push_devices(hass, requests_mock, mock_pushbullet): +async def test_pushbullet_push_devices(hass, requests_mock): """Test pushbullet push to default target.""" - config = { - notify.DOMAIN: { - "name": "test", - "platform": "pushbullet", - "api_key": "MYFAKEKEY", - } - } - with assert_setup_component(1) as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert handle_config[notify.DOMAIN] requests_mock.register_uri( "POST", "https://api.pushbullet.com/v2/pushes", status_code=HTTPStatus.OK, json={"mock_response": "Ok"}, ) + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + data = { "title": "Test Title", "message": "Test Message", "target": ["device/DESKTOP", "device/My iPhone"], } - await hass.services.async_call(notify.DOMAIN, "test", data) + await hass.services.async_call(NOTIFY_DOMAIN, "pushbullet", data) await hass.async_block_till_done() - assert requests_mock.called - assert requests_mock.call_count == 2 - assert len(requests_mock.request_history) == 2 expected_body = { "body": "Test Message", @@ -150,45 +100,39 @@ async def test_pushbullet_push_devices(hass, requests_mock, mock_pushbullet): "title": "Test Title", "type": "note", } - assert requests_mock.request_history[0].json() == expected_body + assert requests_mock.request_history[-2].json() == expected_body expected_body = { "body": "Test Message", "device_iden": "identity2", "title": "Test Title", "type": "note", } - assert requests_mock.request_history[1].json() == expected_body + assert requests_mock.request_history[-1].json() == expected_body -async def test_pushbullet_push_email(hass, requests_mock, mock_pushbullet): +async def test_pushbullet_push_email(hass, requests_mock): """Test pushbullet push to default target.""" - config = { - notify.DOMAIN: { - "name": "test", - "platform": "pushbullet", - "api_key": "MYFAKEKEY", - } - } - with assert_setup_component(1) as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert handle_config[notify.DOMAIN] requests_mock.register_uri( "POST", "https://api.pushbullet.com/v2/pushes", status_code=HTTPStatus.OK, json={"mock_response": "Ok"}, ) + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + data = { "title": "Test Title", "message": "Test Message", "target": ["email/user@host.net"], } - await hass.services.async_call(notify.DOMAIN, "test", data) + await hass.services.async_call(NOTIFY_DOMAIN, "pushbullet", data) await hass.async_block_till_done() - assert requests_mock.called - assert requests_mock.call_count == 1 - assert len(requests_mock.request_history) == 1 expected_body = { "body": "Test Message", @@ -196,38 +140,30 @@ async def test_pushbullet_push_email(hass, requests_mock, mock_pushbullet): "title": "Test Title", "type": "note", } - assert requests_mock.request_history[0].json() == expected_body + assert requests_mock.last_request.json() == expected_body -async def test_pushbullet_push_mixed(hass, requests_mock, mock_pushbullet): +async def test_pushbullet_push_mixed(hass, requests_mock): """Test pushbullet push to default target.""" - config = { - notify.DOMAIN: { - "name": "test", - "platform": "pushbullet", - "api_key": "MYFAKEKEY", - } - } - with assert_setup_component(1) as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert handle_config[notify.DOMAIN] requests_mock.register_uri( "POST", "https://api.pushbullet.com/v2/pushes", status_code=HTTPStatus.OK, json={"mock_response": "Ok"}, ) + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) data = { "title": "Test Title", "message": "Test Message", "target": ["device/DESKTOP", "email/user@host.net"], } - await hass.services.async_call(notify.DOMAIN, "test", data) + await hass.services.async_call(NOTIFY_DOMAIN, "pushbullet", data) await hass.async_block_till_done() - assert requests_mock.called - assert requests_mock.call_count == 2 - assert len(requests_mock.request_history) == 2 expected_body = { "body": "Test Message", @@ -235,40 +171,11 @@ async def test_pushbullet_push_mixed(hass, requests_mock, mock_pushbullet): "title": "Test Title", "type": "note", } - assert requests_mock.request_history[0].json() == expected_body + assert requests_mock.request_history[-2].json() == expected_body expected_body = { "body": "Test Message", "email": "user@host.net", "title": "Test Title", "type": "note", } - assert requests_mock.request_history[1].json() == expected_body - - -async def test_pushbullet_push_no_file(hass, requests_mock, mock_pushbullet): - """Test pushbullet push to default target.""" - config = { - notify.DOMAIN: { - "name": "test", - "platform": "pushbullet", - "api_key": "MYFAKEKEY", - } - } - with assert_setup_component(1) as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert handle_config[notify.DOMAIN] - requests_mock.register_uri( - "POST", - "https://api.pushbullet.com/v2/pushes", - status_code=HTTPStatus.OK, - json={"mock_response": "Ok"}, - ) - data = { - "title": "Test Title", - "message": "Test Message", - "target": ["device/DESKTOP", "device/My iPhone"], - "data": {"file": "not_a_file"}, - } - assert not await hass.services.async_call(notify.DOMAIN, "test", data) - await hass.async_block_till_done() + assert requests_mock.request_history[-1].json() == expected_body