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'