Repository: python-discord/bot

Issue:
Write unit tests for `bot/utils/time.py`
Write unit tests for [`bot/utils/time.py`](../blob/master/bot/utils/time.py). This file already has some unit tests, but they are written for `pytest`. The tests are currently located in [`tests/utils/test_time.py`](../blob/master/tests/utils/test_time.py), but should be moved to the appropriate location in the folder hierarchy, `tests/bot/utils/test_time.py` after they have been migrated to the `unittest` framework.

## Implementation details
Please make sure to read the general information in the [meta issue](553) and the [testing README](../blob/master/tests/README.md). We are aiming for a 100% [branch coverage](https://coverage.readthedocs.io/en/stable/branch.html) for this file, but if you think that is not possible, please discuss that in this issue.

## Additional information
If you want to work on this issue, **please make sure that you get assigned to it** by one of the core devs before starting to work on it. We would like to prevent the situation that multiple people are working on the same issue. To get assigned, leave a comment showing your interesting in tackling this issue.



Discussion:
I would like to work on this please, since I've encountered `bot.utils.time.py` while working on another PR.

Resolving patch:
diff --git a/bot/utils/time.py b/bot/utils/time.py
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -115,7 +115,7 @@ def format_infraction(timestamp: str) -> str:
 
 def format_infraction_with_duration(
     expiry: Optional[str],
-    date_from: datetime.datetime = None,
+    date_from: Optional[datetime.datetime] = None,
     max_units: int = 2
 ) -> Optional[str]:
     """
@@ -140,10 +140,15 @@ def format_infraction_with_duration(
     return f"{expiry_formatted}{duration_formatted}"
 
 
-def until_expiration(expiry: Optional[str], max_units: int = 2) -> Optional[str]:
+def until_expiration(
+    expiry: Optional[str],
+    now: Optional[datetime.datetime] = None,
+    max_units: int = 2
+) -> Optional[str]:
     """
     Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta.
 
+    Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry.
     Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it.
     `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
     By default, max_units is 2.
@@ -151,7 +156,7 @@ def until_expiration(expiry: Optional[str], max_units: int = 2) -> Optional[str]
     if not expiry:
         return None
 
-    now = datetime.datetime.utcnow()
+    now = now or datetime.datetime.utcnow()
     since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0)
 
     if since < now:


Test patch:
diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py
new file mode 100644
--- /dev/null
+++ b/tests/bot/utils/test_time.py
@@ -0,0 +1,162 @@
+import asyncio
+import unittest
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+from dateutil.relativedelta import relativedelta
+
+from bot.utils import time
+from tests.helpers import AsyncMock
+
+
+class TimeTests(unittest.TestCase):
+    """Test helper functions in bot.utils.time."""
+
+    def test_humanize_delta_handle_unknown_units(self):
+        """humanize_delta should be able to handle unknown units, and will not abort."""
+        # Does not abort for unknown units, as the unit name is checked
+        # against the attribute of the relativedelta instance.
+        self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours')
+
+    def test_humanize_delta_handle_high_units(self):
+        """humanize_delta should be able to handle very high units."""
+        # Very high maximum units, but it only ever iterates over
+        # each value the relativedelta might have.
+        self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours')
+
+    def test_humanize_delta_should_normal_usage(self):
+        """Testing humanize delta."""
+        test_cases = (
+            (relativedelta(days=2), 'seconds', 1, '2 days'),
+            (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'),
+            (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'),
+            (relativedelta(days=2, hours=2), 'days', 2, '2 days'),
+        )
+
+        for delta, precision, max_units, expected in test_cases:
+            with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected):
+                self.assertEqual(time.humanize_delta(delta, precision, max_units), expected)
+
+    def test_humanize_delta_raises_for_invalid_max_units(self):
+        """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units."""
+        test_cases = (-1, 0)
+
+        for max_units in test_cases:
+            with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error:
+                time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units)
+                self.assertEqual(str(error), 'max_units must be positive')
+
+    def test_parse_rfc1123(self):
+        """Testing parse_rfc1123."""
+        self.assertEqual(
+            time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'),
+            datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
+        )
+
+    def test_format_infraction(self):
+        """Testing format_infraction."""
+        self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01')
+
+    @patch('asyncio.sleep', new_callable=AsyncMock)
+    def test_wait_until(self, mock):
+        """Testing wait_until."""
+        start = datetime(2019, 1, 1, 0, 0)
+        then = datetime(2019, 1, 1, 0, 10)
+
+        # No return value
+        self.assertIs(asyncio.run(time.wait_until(then, start)), None)
+
+        mock.assert_called_once_with(10 * 60)
+
+    def test_format_infraction_with_duration_none_expiry(self):
+        """format_infraction_with_duration should work for None expiry."""
+        test_cases = (
+            (None, None, None, None),
+
+            # To make sure that date_from and max_units are not touched
+            (None, 'Why hello there!', None, None),
+            (None, None, float('inf'), None),
+            (None, 'Why hello there!', float('inf'), None),
+        )
+
+        for expiry, date_from, max_units, expected in test_cases:
+            with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
+                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+
+    def test_format_infraction_with_duration_custom_units(self):
+        """format_infraction_with_duration should work for custom max_units."""
+        test_cases = (
+            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6,
+             '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'),
+            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20,
+             '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)')
+        )
+
+        for expiry, date_from, max_units, expected in test_cases:
+            with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
+                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+
+    def test_format_infraction_with_duration_normal_usage(self):
+        """format_infraction_with_duration should work for normal usage, across various durations."""
+        test_cases = (
+            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'),
+            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'),
+            ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'),
+            ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'),
+            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'),
+            ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'),
+            ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'),
+            ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'),
+            ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2,
+             '2019-11-23 23:59 (9 minutes and 55 seconds)'),
+            (None, datetime(2019, 11, 23, 23, 49, 5), 2, None),
+        )
+
+        for expiry, date_from, max_units, expected in test_cases:
+            with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
+                self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+
+    def test_until_expiration_with_duration_none_expiry(self):
+        """until_expiration should work for None expiry."""
+        test_cases = (
+            (None, None, None, None),
+
+            # To make sure that now and max_units are not touched
+            (None, 'Why hello there!', None, None),
+            (None, None, float('inf'), None),
+            (None, 'Why hello there!', float('inf'), None),
+        )
+
+        for expiry, now, max_units, expected in test_cases:
+            with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
+                self.assertEqual(time.until_expiration(expiry, now, max_units), expected)
+
+    def test_until_expiration_with_duration_custom_units(self):
+        """until_expiration should work for custom max_units."""
+        test_cases = (
+            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'),
+            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes')
+        )
+
+        for expiry, now, max_units, expected in test_cases:
+            with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
+                self.assertEqual(time.until_expiration(expiry, now, max_units), expected)
+
+    def test_until_expiration_normal_usage(self):
+        """until_expiration should work for normal usage, across various durations."""
+        test_cases = (
+            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'),
+            ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'),
+            ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'),
+            ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'),
+            ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'),
+            ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'),
+            ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'),
+            ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'),
+            ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'),
+            (None, datetime(2019, 11, 23, 23, 49, 5), 2, None),
+        )
+
+        for expiry, now, max_units, expected in test_cases:
+            with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
+                self.assertEqual(time.until_expiration(expiry, now, max_units), expected)