From 4936e55979a0d4641cf35fe5ba0544d87b81e4bb Mon Sep 17 00:00:00 2001 From: Thomas Germain <12560542+thomasgermain@users.noreply.github.com> Date: Sun, 28 Jul 2019 19:55:46 +0200 Subject: [PATCH] Improve seventeentrack (#25454) * Code improvement + tests * review * review + moving to pytest test function * move test to async * remove code comment --- .../components/seventeentrack/sensor.py | 144 ++++---- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/seventeentrack/__init__.py | 1 + .../components/seventeentrack/test_sensor.py | 311 ++++++++++++++++++ 5 files changed, 382 insertions(+), 78 deletions(-) create mode 100644 tests/components/seventeentrack/__init__.py create mode 100644 tests/components/seventeentrack/test_sensor.py diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 4ab7e213c99..fa792f8ed1c 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -33,11 +33,14 @@ DATA_SUMMARY = 'summary_data' DEFAULT_ATTRIBUTION = 'Data provided by 17track.net' DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) -ENTITY_ID_TEMPLATE = 'package_{0}_{1}' +UNIQUE_ID_TEMPLATE = 'package_{0}_{1}' +ENTITY_ID_TEMPLATE = 'sensor.seventeentrack_package_{0}' -NOTIFICATION_DELIVERED_ID_SCAFFOLD = 'package_delivered_{0}' -NOTIFICATION_DELIVERED_TITLE = 'Package Delivered' -NOTIFICATION_DELIVERED_URL_SCAFFOLD = 'https://t.17track.net/track#nums={0}' +NOTIFICATION_DELIVERED_ID = 'package_delivered_{0}' +NOTIFICATION_DELIVERED_TITLE = 'Package {0} delivered' +NOTIFICATION_DELIVERED_MESSAGE = 'Package Delivered: {0}<br />' + \ + 'Visit 17.track for more information: ' \ + 'https://t.17track.net/track#nums={1}' VALUE_DELIVERED = 'Delivered' @@ -72,21 +75,11 @@ async def async_setup_platform( scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - data = SeventeenTrackData( - hass, client, async_add_entities, scan_interval, - config[CONF_SHOW_ARCHIVED], config[CONF_SHOW_DELIVERED]) + data = SeventeenTrackData(client, async_add_entities, scan_interval, + config[CONF_SHOW_ARCHIVED], + config[CONF_SHOW_DELIVERED]) await data.async_update() - sensors = [] - - for status, quantity in data.summary.items(): - sensors.append(SeventeenTrackSummarySensor(data, status, quantity)) - - for package in data.packages: - sensors.append(SeventeenTrackPackageSensor(data, package)) - - async_add_entities(sensors, True) - class SeventeenTrackSummarySensor(Entity): """Define a summary sensor.""" @@ -139,7 +132,7 @@ class SeventeenTrackSummarySensor(Entity): await self._data.async_update() package_data = [] - for package in self._data.packages: + for package in self._data.packages.values(): if package.status != self._status: continue @@ -175,14 +168,12 @@ class SeventeenTrackPackageSensor(Entity): self._friendly_name = package.friendly_name self._state = package.status self._tracking_number = package.tracking_number + self.entity_id = ENTITY_ID_TEMPLATE.format(self._tracking_number) @property def available(self): """Return whether the entity is available.""" - return bool([ - p for p in self._data.packages - if p.tracking_number == self._tracking_number - ]) + return self._data.packages.get(self._tracking_number) is not None @property def device_state_attributes(self): @@ -210,44 +201,24 @@ class SeventeenTrackPackageSensor(Entity): @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return ENTITY_ID_TEMPLATE.format( + return UNIQUE_ID_TEMPLATE.format( self._data.account_id, self._tracking_number) async def async_update(self): """Update the sensor.""" await self._data.async_update() - if not self._data.packages: + if not self.available: + self.hass.async_create_task(self._remove()) return - try: - package = next(( - p for p in self._data.packages - if p.tracking_number == self._tracking_number)) - except StopIteration: - # If the package no longer exists in the data, log a message and - # delete this entity: - _LOGGER.info( - 'Deleting entity for stale package: %s', self._tracking_number) - reg = await self.hass.helpers.entity_registry.async_get_registry() - self.hass.async_create_task(reg.async_remove(self.entity_id)) - self.hass.async_create_task(self.async_remove()) - return + package = self._data.packages.get(self._tracking_number, None) # If the user has elected to not see delivered packages and one gets # delivered, post a notification: if package.status == VALUE_DELIVERED and not self._data.show_delivered: - _LOGGER.info('Package delivered: %s', self._tracking_number) - self.hass.components.persistent_notification.create( - 'Package Delivered: {0}<br />' - 'Visit 17.track for more infomation: {1}' - ''.format( - self._tracking_number, - NOTIFICATION_DELIVERED_URL_SCAFFOLD.format( - self._tracking_number)), - title=NOTIFICATION_DELIVERED_TITLE, - notification_id=NOTIFICATION_DELIVERED_ID_SCAFFOLD.format( - self._tracking_number)) + self._notify_delivered() + self.hass.async_create_task(self._remove()) return self._attrs.update({ @@ -255,26 +226,53 @@ class SeventeenTrackPackageSensor(Entity): ATTR_LOCATION: package.location, }) self._state = package.status + self._friendly_name = package.friendly_name + + async def _remove(self): + """Remove entity itself.""" + await self.async_remove() + + reg = await self.hass.helpers.entity_registry.async_get_registry() + entity_id = reg.async_get_entity_id( + 'sensor', 'seventeentrack', + UNIQUE_ID_TEMPLATE.format( + self._data.account_id, self._tracking_number)) + if entity_id: + reg.async_remove(entity_id) + + def _notify_delivered(self): + """Notify when package is delivered.""" + _LOGGER.info('Package delivered: %s', self._tracking_number) + + identification = self._friendly_name if self._friendly_name else \ + self._tracking_number + message = NOTIFICATION_DELIVERED_MESSAGE.format(self._tracking_number, + identification) + title = NOTIFICATION_DELIVERED_TITLE.format(identification) + notification_id = NOTIFICATION_DELIVERED_TITLE\ + .format(self._tracking_number) + + self.hass.components.persistent_notification\ + .create(message, title=title, notification_id=notification_id) class SeventeenTrackData: """Define a data handler for 17track.net.""" - def __init__( - self, hass, client, async_add_entities, scan_interval, - show_archived, show_delivered): + def __init__(self, client, async_add_entities, scan_interval, + show_archived, show_delivered): """Initialize.""" self._async_add_entities = async_add_entities self._client = client - self._hass = hass self._scan_interval = scan_interval self._show_archived = show_archived self.account_id = client.profile.account_id - self.packages = [] + self.packages = {} self.show_delivered = show_delivered self.summary = {} self.async_update = Throttle(self._scan_interval)(self._async_update) + self.first_update = True async def _async_update(self): """Get updated data from 17track.net.""" @@ -285,46 +283,36 @@ class SeventeenTrackData: show_archived=self._show_archived) _LOGGER.debug('New package data received: %s', packages) - if not self.show_delivered: - packages = [p for p in packages if p.status != VALUE_DELIVERED] + new_packages = {p.tracking_number: p for p in packages} - packages_map = {p.tracking_number: p for p in packages} - # Add new packages: + to_add = set(new_packages) - set(self.packages) - already_tracked_nbr = [p.tracking_number for p in self.packages] - received_tracked_nbr = [p.tracking_number for p in packages] - - to_add = set(received_tracked_nbr) - set(already_tracked_nbr) _LOGGER.debug('Will add new tracking numbers: %s', to_add) - if self.packages and to_add: + if to_add: self._async_add_entities([ SeventeenTrackPackageSensor(self, - packages_map[tracking_number]) + new_packages[tracking_number]) for tracking_number in to_add ], True) - # Remove archived packages from the entity registry: - to_remove = set(received_tracked_nbr) - set(already_tracked_nbr) - _LOGGER.debug('Will remove tracking number: %s', to_remove) - reg = await self._hass.helpers.entity_registry.async_get_registry() - for tracking_number in to_remove: - entity_id = reg.async_get_entity_id( - 'sensor', 'seventeentrack', - ENTITY_ID_TEMPLATE.format( - self.account_id, tracking_number)) - if not entity_id: - continue - self._hass.async_create_task(reg.async_remove(entity_id)) - - self.packages = packages + self.packages = new_packages except SeventeenTrackError as err: _LOGGER.error('There was an error retrieving packages: %s', err) - self.packages = [] try: self.summary = await self._client.profile.summary( show_archived=self._show_archived) _LOGGER.debug('New summary data received: %s', self.summary) + + # creating summary sensors on first update + if self.first_update: + self.first_update = False + + self._async_add_entities([ + SeventeenTrackSummarySensor(self, status, quantity) + for status, quantity in self.summary.items() + ], True) + except SeventeenTrackError as err: _LOGGER.error('There was an error retrieving the summary: %s', err) self.summary = {} diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 140056ef632..2c16b083453 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -240,6 +240,9 @@ pushbullet.py==0.11.0 # homeassistant.components.canary py-canary==0.5.0 +# homeassistant.components.seventeentrack +py17track==2.2.2 + # homeassistant.components.tplink pyHS100==0.3.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1cf3965b61d..e9cdad52359 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -161,6 +161,7 @@ TEST_REQUIREMENTS = ( 'zeroconf', 'zigpy-homeassistant', 'bellows-homeassistant', + 'py17track', ) IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') diff --git a/tests/components/seventeentrack/__init__.py b/tests/components/seventeentrack/__init__.py new file mode 100644 index 00000000000..5cc3bf34871 --- /dev/null +++ b/tests/components/seventeentrack/__init__.py @@ -0,0 +1 @@ +"""Tests for the seventeentrack component.""" diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py new file mode 100644 index 00000000000..c97596d0cba --- /dev/null +++ b/tests/components/seventeentrack/test_sensor.py @@ -0,0 +1,311 @@ +"""Tests for the seventeentrack sensor.""" +import datetime +from typing import Union + +import pytest +import mock +from py17track.package import Package + +from homeassistant.components.seventeentrack.sensor \ + import CONF_SHOW_ARCHIVED, CONF_SHOW_DELIVERED +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.setup import async_setup_component +from homeassistant.util import utcnow +from tests.common import MockDependency, async_fire_time_changed + +VALID_CONFIG_MINIMAL = { + 'sensor': { + 'platform': 'seventeentrack', + CONF_USERNAME: 'test', + CONF_PASSWORD: 'test' + } +} + +INVALID_CONFIG = { + 'sensor': { + 'platform': 'seventeentrack', + 'boom': 'test', + } +} + +VALID_CONFIG_FULL = { + 'sensor': { + 'platform': 'seventeentrack', + CONF_USERNAME: 'test', + CONF_PASSWORD: 'test', + CONF_SHOW_ARCHIVED: True, + CONF_SHOW_DELIVERED: True + } +} + +VALID_CONFIG_FULL_NO_DELIVERED = { + 'sensor': { + 'platform': 'seventeentrack', + CONF_USERNAME: 'test', + CONF_PASSWORD: 'test', + CONF_SHOW_ARCHIVED: False, + CONF_SHOW_DELIVERED: False + } +} + +DEFAULT_SUMMARY = { + "Not Found": 0, + "In Transit": 0, + "Expired": 0, + "Ready to be Picked Up": 0, + "Undelivered": 0, + "Delivered": 0, + "Returned": 0 +} + +NEW_SUMMARY_DATA = { + "Not Found": 1, + "In Transit": 1, + "Expired": 1, + "Ready to be Picked Up": 1, + "Undelivered": 1, + "Delivered": 1, + "Returned": 1 +} + + +class ClientMock: + """Mock the py17track client to inject the ProfileMock.""" + + def __init__(self, websession) -> None: + """Mock the profile.""" + self.profile = ProfileMock() + + +class ProfileMock: + """ProfileMock will mock data coming from 17track.""" + + package_list = [] + login_result = True + summary_data = DEFAULT_SUMMARY + account_id = '123' + + @classmethod + def reset(cls): + """Reset data to defaults.""" + cls.package_list = [] + cls.login_result = True + cls.summary_data = DEFAULT_SUMMARY + cls.account_id = '123' + + def __init__(self) -> None: + """Override Account id.""" + self.account_id = self.__class__.account_id + + async def login(self, email: str, password: str) -> bool: + """Login mock.""" + return self.__class__.login_result + + async def packages(self, package_state: Union[int, str] = '', + show_archived: bool = False) -> list: + """Packages mock.""" + return self.__class__.package_list[:] + + async def summary(self, show_archived: bool = False) -> dict: + """Summary mock.""" + return self.__class__.summary_data + + +@pytest.fixture(autouse=True, name="mock_py17track") +def fixture_mock_py17track(): + """Mock py17track dependency.""" + with MockDependency('py17track'): + yield + + +@pytest.fixture(autouse=True, name="mock_client") +def fixture_mock_client(mock_py17track): + """Mock py17track client.""" + with mock.patch('py17track.Client', new=ClientMock): + yield + ProfileMock.reset() + + +async def _setup_seventeentrack(hass, config=None, summary_data=None): + """Set up component using config.""" + if not config: + config = VALID_CONFIG_MINIMAL + if not summary_data: + summary_data = {} + + ProfileMock.summary_data = summary_data + assert await async_setup_component(hass, 'sensor', config) + + +async def _goto_future(hass, future=None): + """Move to future.""" + if not future: + future = utcnow() + datetime.timedelta(minutes=10) + with mock.patch('homeassistant.util.utcnow', return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + +async def test_full_valid_config(hass): + """Ensure everything starts correctly.""" + assert await async_setup_component(hass, 'sensor', VALID_CONFIG_FULL) + assert len(hass.states.async_entity_ids()) == len( + ProfileMock.summary_data.keys()) + + +async def test_valid_config(hass): + """Ensure everything starts correctly.""" + assert await async_setup_component(hass, 'sensor', VALID_CONFIG_MINIMAL) + + assert len(hass.states.async_entity_ids()) == len( + ProfileMock.summary_data.keys()) + + +async def test_invalid_config(hass): + """Ensure nothing is created when config is wrong.""" + assert await async_setup_component(hass, 'sensor', INVALID_CONFIG) + + assert not hass.states.async_entity_ids() + + +async def test_add_package(hass): + """Ensure package is added correctly when user add a new package.""" + package = Package('456', 206, 'friendly name 1', 'info text 1', + 'location 1', 206, 2) + ProfileMock.package_list = [package] + + await _setup_seventeentrack(hass) + assert hass.states.get( + 'sensor.seventeentrack_package_456') is not None + assert len(hass.states.async_entity_ids()) == 1 + + package2 = Package('789', 206, 'friendly name 2', 'info text 2', + 'location 2', 206, 2) + ProfileMock.package_list = [package, package2] + + await _goto_future(hass) + + assert hass.states.get( + 'sensor.seventeentrack_package_789') is not None + assert len(hass.states.async_entity_ids()) == 2 + + +async def test_remove_package(hass): + """Ensure entity is not there anymore if package is not there.""" + package1 = Package('456', 206, 'friendly name 1', 'info text 1', + 'location 1', 206, 2) + package2 = Package('789', 206, 'friendly name 2', 'info text 2', + 'location 2', 206, 2) + + ProfileMock.package_list = [package1, package2] + + await _setup_seventeentrack(hass) + + assert hass.states.get( + 'sensor.seventeentrack_package_456') is not None + assert hass.states.get( + 'sensor.seventeentrack_package_789') is not None + assert len(hass.states.async_entity_ids()) == 2 + + ProfileMock.package_list = [package2] + + await _goto_future(hass) + + assert hass.states.get( + 'sensor.seventeentrack_package_456') is None + assert hass.states.get( + 'sensor.seventeentrack_package_789') is not None + assert len(hass.states.async_entity_ids()) == 1 + + +async def test_friendly_name_changed(hass): + """Test friendly name change.""" + package = Package('456', 206, 'friendly name 1', 'info text 1', + 'location 1', 206, 2) + ProfileMock.package_list = [package] + + await _setup_seventeentrack(hass) + + assert hass.states.get( + 'sensor.seventeentrack_package_456') is not None + assert len(hass.states.async_entity_ids()) == 1 + + package = Package('456', 206, 'friendly name 2', 'info text 1', + 'location 1', 206, 2) + ProfileMock.package_list = [package] + + await _goto_future(hass) + + assert hass.states.get( + 'sensor.seventeentrack_package_456') is not None + entity = hass.data['entity_components']['sensor'].get_entity( + 'sensor.seventeentrack_package_456') + assert entity.name == 'Seventeentrack Package: friendly name 2' + assert len(hass.states.async_entity_ids()) == 1 + + +async def test_delivered_not_shown(hass): + """Ensure delivered packages are not shown.""" + package = Package('456', 206, 'friendly name 1', 'info text 1', + 'location 1', 206, 2, 40) + ProfileMock.package_list = [package] + + hass.components.persistent_notification = mock.MagicMock() + await _setup_seventeentrack(hass, VALID_CONFIG_FULL_NO_DELIVERED) + assert not hass.states.async_entity_ids() + hass.components.persistent_notification.create.assert_called() + + +async def test_delivered_shown(hass): + """Ensure delivered packages are show when user choose to show them.""" + package = Package('456', 206, 'friendly name 1', 'info text 1', + 'location 1', 206, 2, 40) + ProfileMock.package_list = [package] + + hass.components.persistent_notification = mock.MagicMock() + await _setup_seventeentrack(hass, VALID_CONFIG_FULL) + + assert hass.states.get( + 'sensor.seventeentrack_package_456') is not None + assert len(hass.states.async_entity_ids()) == 1 + hass.components.persistent_notification.create.assert_not_called() + + +async def test_becomes_delivered_not_shown_notification(hass): + """Ensure notification is triggered when package becomes delivered.""" + package = Package('456', 206, 'friendly name 1', 'info text 1', + 'location 1', 206, 2) + ProfileMock.package_list = [package] + + await _setup_seventeentrack(hass, VALID_CONFIG_FULL_NO_DELIVERED) + + assert hass.states.get( + 'sensor.seventeentrack_package_456') is not None + assert len(hass.states.async_entity_ids()) == 1 + + package_delivered = Package('456', 206, 'friendly name 1', + 'info text 1', 'location 1', 206, 2, 40) + ProfileMock.package_list = [package_delivered] + + hass.components.persistent_notification = mock.MagicMock() + await _goto_future(hass) + + hass.components.persistent_notification.create.assert_called() + assert not hass.states.async_entity_ids() + + +async def test_summary_correctly_updated(hass): + """Ensure summary entities are not duplicated.""" + await _setup_seventeentrack(hass, summary_data=DEFAULT_SUMMARY) + + assert len(hass.states.async_entity_ids()) == 7 + for state in hass.states.async_all(): + assert state.state == '0' + + ProfileMock.summary_data = NEW_SUMMARY_DATA + + await _goto_future(hass) + + assert len(hass.states.async_entity_ids()) == 7 + for state in hass.states.async_all(): + assert state.state == '1'