diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 12b75a40bae..60c40b1c03c 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=1) -PLATFORMS: list[Platform] = [Platform.CALENDAR] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py new file mode 100644 index 00000000000..c0d3ec6e2ce --- /dev/null +++ b/homeassistant/components/todoist/todo.py @@ -0,0 +1,111 @@ +"""A todo platform for Todoist.""" + +import asyncio +from typing import cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TodoistCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Todoist todo platform config entry.""" + coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id] + projects = await coordinator.async_get_projects() + async_add_entities( + TodoistTodoListEntity(coordinator, entry.entry_id, project.id, project.name) + for project in projects + ) + + +class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): + """A Todoist TodoListEntity.""" + + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__( + self, + coordinator: TodoistCoordinator, + config_entry_id: str, + project_id: str, + project_name: str, + ) -> None: + """Initialize TodoistTodoListEntity.""" + super().__init__(coordinator=coordinator) + self._project_id = project_id + self._attr_unique_id = f"{config_entry_id}-{project_id}" + self._attr_name = project_name + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data is None: + self._attr_todo_items = None + else: + items = [] + for task in self.coordinator.data: + if task.project_id != self._project_id: + continue + if task.is_completed: + status = TodoItemStatus.COMPLETED + else: + status = TodoItemStatus.NEEDS_ACTION + items.append( + TodoItem( + summary=task.content, + uid=task.id, + status=status, + ) + ) + self._attr_todo_items = items + super()._handle_coordinator_update() + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Create a To-do item.""" + if item.status != TodoItemStatus.NEEDS_ACTION: + raise ValueError("Only active tasks may be created.") + await self.coordinator.api.add_task( + content=item.summary or "", + project_id=self._project_id, + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + if item.summary: + await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if item.status is not None: + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.api.close_task(task_id=uid) + else: + await self.coordinator.api.reopen_task(task_id=uid) + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete a To-do item.""" + await asyncio.gather( + *[self.coordinator.api.delete_task(task_id=uid) for uid in uids] + ) + await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 6543e5b678f..28f22e1061a 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -9,15 +9,17 @@ from requests.models import Response from todoist_api_python.models import Collaborator, Due, Label, Project, Task from homeassistant.components.todoist import DOMAIN -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +PROJECT_ID = "project-id-1" SUMMARY = "A task" TOKEN = "some-token" +TODAY = dt_util.now().strftime("%Y-%m-%d") @pytest.fixture @@ -37,38 +39,49 @@ def mock_due() -> Due: ) -@pytest.fixture(name="task") -def mock_task(due: Due) -> Task: +def make_api_task( + id: str | None = None, + content: str | None = None, + is_completed: bool = False, + due: Due | None = None, + project_id: str | None = None, +) -> Task: """Mock a todoist Task instance.""" return Task( assignee_id="1", assigner_id="1", comment_count=0, - is_completed=False, - content=SUMMARY, + is_completed=is_completed, + content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=due, - id="1", + due=due or Due(is_recurring=False, date=TODAY, string="today"), + id=id or "1", labels=["Label1"], order=1, parent_id=None, priority=1, - project_id="12345", + project_id=project_id or PROJECT_ID, section_id=None, url="https://todoist.com", sync_id=None, ) +@pytest.fixture(name="tasks") +def mock_tasks(due: Due) -> list[Task]: + """Mock a todoist Task instance.""" + return [make_api_task(due=due)] + + @pytest.fixture(name="api") -def mock_api(task) -> AsyncMock: +def mock_api(tasks: list[Task]) -> AsyncMock: """Mock the api state.""" api = AsyncMock() api.get_projects.return_value = [ Project( - id="12345", + id=PROJECT_ID, color="blue", comment_count=0, is_favorite=False, @@ -88,7 +101,7 @@ def mock_api(task) -> AsyncMock: api.get_collaborators.return_value = [ Collaborator(email="user@gmail.com", id="1", name="user") ] - api.get_tasks.return_value = [task] + api.get_tasks.return_value = tasks return api @@ -121,15 +134,25 @@ def mock_todoist_domain() -> str: return DOMAIN +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, + platforms: list[Platform], api: AsyncMock, todoist_config_entry: MockConfigEntry | None, ) -> None: """Mock setup of the todoist integration.""" if todoist_config_entry is not None: todoist_config_entry.add_to_hass(hass) - with patch("homeassistant.components.todoist.TodoistAPIAsync", return_value=api): + with patch( + "homeassistant.components.todoist.TodoistAPIAsync", return_value=api + ), patch("homeassistant.components.todoist.PLATFORMS", platforms): assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() yield diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 45300e2e66c..761eeb07c61 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -18,13 +18,13 @@ from homeassistant.components.todoist.const import ( PROJECT_NAME, SERVICE_NEW_TASK, ) -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util -from .conftest import SUMMARY +from .conftest import PROJECT_ID, SUMMARY from tests.typing import ClientSessionGenerator @@ -34,6 +34,12 @@ TZ_NAME = "America/Regina" TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Override platforms.""" + return [Platform.CALENDAR] + + @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" @@ -97,7 +103,7 @@ async def test_calendar_entity_unique_id( ) -> None: """Test unique id is set to project id.""" entity = entity_registry.async_get("calendar.name") - assert entity.unique_id == "12345" + assert entity.unique_id == PROJECT_ID @pytest.mark.parametrize( @@ -256,7 +262,7 @@ async def test_create_task_service_call(hass: HomeAssistant, api: AsyncMock) -> await hass.async_block_till_done() api.add_task.assert_called_with( - "task", project_id="12345", labels=["Label1"], assignee_id="1" + "task", project_id=PROJECT_ID, labels=["Label1"], assignee_id="1" ) diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py index cc64464df1d..0e80be5410f 100644 --- a/tests/components/todoist/test_init.py +++ b/tests/components/todoist/test_init.py @@ -1,7 +1,6 @@ """Unit tests for the Todoist integration.""" -from collections.abc import Generator from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest @@ -12,15 +11,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.fixture(autouse=True) -def mock_platforms() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.todoist.PLATFORMS", return_value=[] - ) as mock_setup_entry: - yield mock_setup_entry - - async def test_load_unload( hass: HomeAssistant, setup_integration: None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py new file mode 100644 index 00000000000..bbfaf6c493b --- /dev/null +++ b/tests/components/todoist/test_todo.py @@ -0,0 +1,256 @@ +"""Unit tests for the Todoist todo platform.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import PROJECT_ID, make_api_task + + +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.TODO] + + +@pytest.mark.parametrize( + ("tasks", "expected_state"), + [ + ([], "0"), + ([make_api_task(id="12345", content="Soda", is_completed=False)], "1"), + ([make_api_task(id="12345", content="Soda", is_completed=True)], "0"), + ( + [ + make_api_task(id="12345", content="Milk", is_completed=False), + make_api_task(id="54321", content="Soda", is_completed=False), + ], + "2", + ), + ( + [ + make_api_task( + id="12345", + content="Soda", + is_completed=False, + project_id="other-project-id", + ) + ], + "0", + ), + ], +) +async def test_todo_item_state( + hass: HomeAssistant, + setup_integration: None, + expected_state: str, +) -> None: + """Test for a To-do List entity state.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == expected_state + + +@pytest.mark.parametrize(("tasks"), [[]]) +async def test_create_todo_list_item( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for creating a To-do Item.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + api.add_task = AsyncMock() + # Fake API response when state is refreshed after create + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=False) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "Soda"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + args = api.add_task.call_args + assert args + assert args.kwargs.get("content") == "Soda" + assert args.kwargs.get("project_id") == PROJECT_ID + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize(("tasks"), [[]]) +async def test_create_completed_item_unsupported( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for creating a To-do Item that is already completed.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + api.add_task = AsyncMock() + + with pytest.raises(ValueError, match="Only active tasks"): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "Soda", "status": "completed"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] +) +async def test_update_todo_item_status( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for updating a To-do Item that changes the status.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + api.close_task = AsyncMock() + api.reopen_task = AsyncMock() + + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=True) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "status": "completed"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.close_task.called + args = api.close_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + assert not api.reopen_task.called + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + # Fake API response when state is refreshed after reopen + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=False) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "status": "needs_action"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.reopen_task.called + args = api.reopen_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] +) +async def test_update_todo_item_summary( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for updating a To-do Item that changes the summary.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + api.update_task = AsyncMock() + + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=True) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "summary": "Milk"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.update_task.called + args = api.update_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + assert args.kwargs.get("content") == "Milk" + + +@pytest.mark.parametrize( + ("tasks"), + [ + [ + make_api_task(id="task-id-1", content="Soda", is_completed=False), + make_api_task(id="task-id-2", content="Milk", is_completed=False), + ] + ], +) +async def test_delete_todo_item( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for deleting a To-do Item.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "2" + + api.delete_task = AsyncMock() + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [] + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + {"uid": ["task-id-1", "task-id-2"]}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.delete_task.call_count == 2 + args = api.delete_task.call_args_list + assert args[0].kwargs.get("task_id") == "task-id-1" + assert args[1].kwargs.get("task_id") == "task-id-2" + + await async_update_entity(hass, "todo.name") + state = hass.states.get("todo.name") + assert state + assert state.state == "0"