From d302b0d14e9df9cc46e7e035a0d2be5290182b40 Mon Sep 17 00:00:00 2001
From: Aaron Godfrey <me@aarongodfrey.dev>
Date: Tue, 8 Mar 2022 00:00:39 -0600
Subject: [PATCH] Fix todoist parsing due dates for calendar events (#65403)

---
 CODEOWNERS                                   |  1 +
 homeassistant/components/todoist/calendar.py | 22 +++++-----
 homeassistant/components/todoist/types.py    | 14 +++++++
 requirements_test_all.txt                    |  3 ++
 tests/components/todoist/__init__.py         |  1 +
 tests/components/todoist/test_calendar.py    | 44 ++++++++++++++++++++
 6 files changed, 75 insertions(+), 10 deletions(-)
 create mode 100644 homeassistant/components/todoist/types.py
 create mode 100644 tests/components/todoist/__init__.py
 create mode 100644 tests/components/todoist/test_calendar.py

diff --git a/CODEOWNERS b/CODEOWNERS
index cd946ea5382..6d145a064a1 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1022,6 +1022,7 @@ homeassistant/components/time_date/* @fabaff
 tests/components/time_date/* @fabaff
 homeassistant/components/tmb/* @alemuro
 homeassistant/components/todoist/* @boralyl
+tests/components/todoist/* @boralyl
 homeassistant/components/tolo/* @MatthiasLohr
 tests/components/tolo/* @MatthiasLohr
 homeassistant/components/totalconnect/* @austinmroczek
diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py
index 560ad3efe1f..4582b1de0f0 100644
--- a/homeassistant/components/todoist/calendar.py
+++ b/homeassistant/components/todoist/calendar.py
@@ -1,7 +1,7 @@
 """Support for Todoist task management (https://todoist.com)."""
 from __future__ import annotations
 
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 import logging
 
 from todoist.api import TodoistAPI
@@ -55,6 +55,7 @@ from .const import (
     SUMMARY,
     TASKS,
 )
+from .types import DueDate
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -219,7 +220,7 @@ def setup_platform(
                 due_date = datetime(due.year, due.month, due.day)
             # Format it in the manner Todoist expects
             due_date = dt.as_utc(due_date)
-            date_format = "%Y-%m-%dT%H:%M%S"
+            date_format = "%Y-%m-%dT%H:%M:%S"
             _due["date"] = datetime.strftime(due_date, date_format)
 
         if _due:
@@ -258,15 +259,15 @@ def setup_platform(
     )
 
 
-def _parse_due_date(data: dict, gmt_string) -> datetime | None:
-    """Parse the due date dict into a datetime object."""
-    # Add time information to date only strings.
-    if len(data["date"]) == 10:
-        return datetime.fromisoformat(data["date"]).replace(tzinfo=dt.UTC)
+def _parse_due_date(data: DueDate, timezone_offset: int) -> datetime | None:
+    """Parse the due date dict into a datetime object in UTC.
+
+    This function will always return a timezone aware datetime if it can be parsed.
+    """
     if not (nowtime := dt.parse_datetime(data["date"])):
         return None
     if nowtime.tzinfo is None:
-        data["date"] += gmt_string
+        nowtime = nowtime.replace(tzinfo=timezone(timedelta(hours=timezone_offset)))
     return dt.as_utc(nowtime)
 
 
@@ -441,7 +442,7 @@ class TodoistProjectData:
         task[START] = dt.utcnow()
         if data[DUE] is not None:
             task[END] = _parse_due_date(
-                data[DUE], self._api.state["user"]["tz_info"]["gmt_string"]
+                data[DUE], self._api.state["user"]["tz_info"]["hours"]
             )
 
             if self._due_date_days is not None and (
@@ -564,8 +565,9 @@ class TodoistProjectData:
         for task in project_task_data:
             if task["due"] is None:
                 continue
+            # @NOTE: _parse_due_date always returns the date in UTC time.
             due_date = _parse_due_date(
-                task["due"], self._api.state["user"]["tz_info"]["gmt_string"]
+                task["due"], self._api.state["user"]["tz_info"]["hours"]
             )
             if not due_date:
                 continue
diff --git a/homeassistant/components/todoist/types.py b/homeassistant/components/todoist/types.py
new file mode 100644
index 00000000000..b9409e44daa
--- /dev/null
+++ b/homeassistant/components/todoist/types.py
@@ -0,0 +1,14 @@
+"""Types for the Todoist component."""
+from __future__ import annotations
+
+from typing import TypedDict
+
+
+class DueDate(TypedDict):
+    """Dict representing a due date in a todoist api response."""
+
+    date: str
+    is_recurring: bool
+    lang: str
+    string: str
+    timezone: str | None
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index cfe57484344..f3bdb7d7549 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -1446,6 +1446,9 @@ tesla-powerwall==0.3.17
 # homeassistant.components.tesla_wall_connector
 tesla-wall-connector==1.0.1
 
+# homeassistant.components.todoist
+todoist-python==8.0.0
+
 # homeassistant.components.tolo
 tololib==0.1.0b3
 
diff --git a/tests/components/todoist/__init__.py b/tests/components/todoist/__init__.py
new file mode 100644
index 00000000000..e3b605f8f14
--- /dev/null
+++ b/tests/components/todoist/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Todoist integration."""
diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py
new file mode 100644
index 00000000000..2f0832fe739
--- /dev/null
+++ b/tests/components/todoist/test_calendar.py
@@ -0,0 +1,44 @@
+"""Unit tests for the Todoist calendar platform."""
+from datetime import datetime
+
+from homeassistant.components.todoist.calendar import _parse_due_date
+from homeassistant.components.todoist.types import DueDate
+from homeassistant.util import dt
+
+
+def test_parse_due_date_invalid():
+    """Test None is returned if the due date can't be parsed."""
+    data: DueDate = {
+        "date": "invalid",
+        "is_recurring": False,
+        "lang": "en",
+        "string": "",
+        "timezone": None,
+    }
+    assert _parse_due_date(data, timezone_offset=-8) is None
+
+
+def test_parse_due_date_with_no_time_data():
+    """Test due date is parsed correctly when it has no time data."""
+    data: DueDate = {
+        "date": "2022-02-02",
+        "is_recurring": False,
+        "lang": "en",
+        "string": "Feb 2 2:00 PM",
+        "timezone": None,
+    }
+    actual = _parse_due_date(data, timezone_offset=-8)
+    assert datetime(2022, 2, 2, 8, 0, 0, tzinfo=dt.UTC) == actual
+
+
+def test_parse_due_date_without_timezone_uses_offset():
+    """Test due date uses user local timezone offset when it has no timezone."""
+    data: DueDate = {
+        "date": "2022-02-02T14:00:00",
+        "is_recurring": False,
+        "lang": "en",
+        "string": "Feb 2 2:00 PM",
+        "timezone": None,
+    }
+    actual = _parse_due_date(data, timezone_offset=-8)
+    assert datetime(2022, 2, 2, 22, 0, 0, tzinfo=dt.UTC) == actual
-- 
GitLab