diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 78a9cb89624..12b75a40bae 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -1 +1,44 @@ -"""The todoist component.""" +"""The todoist integration.""" + +import datetime +import logging + +from todoist_api_python.api_async import TodoistAPIAsync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TodoistCoordinator + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(minutes=1) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up todoist from a config entry.""" + + token = entry.data[CONF_TOKEN] + api = TodoistAPIAsync(token) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 544144018dd..40ceb71ee5f 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -17,8 +17,10 @@ from homeassistant.components.calendar import ( CalendarEntity, CalendarEvent, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -106,6 +108,23 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( SCAN_INTERVAL = timedelta(minutes=1) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Todoist calendar platform config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + projects = await coordinator.async_get_projects() + labels = await coordinator.async_get_labels() + + entities = [] + for project in projects: + project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} + entities.append(TodoistProjectEntity(coordinator, project_data, labels)) + + async_add_entities(entities) + async_register_services(hass, coordinator) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -119,7 +138,7 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) - coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) await coordinator.async_refresh() async def _shutdown_coordinator(_: Event) -> None: @@ -177,12 +196,29 @@ async def async_setup_platform( async_add_entities(project_devices, update_before_add=True) + async_register_services(hass, coordinator) + + +def async_register_services( + hass: HomeAssistant, coordinator: TodoistCoordinator +) -> None: + """Register services.""" + + if hass.services.has_service(DOMAIN, SERVICE_NEW_TASK): + return + session = async_get_clientsession(hass) async def handle_new_task(call: ServiceCall) -> None: """Call when a user creates a new Todoist Task from Home Assistant.""" - project_name = call.data[PROJECT_NAME] - project_id = project_id_lookup[project_name] + project_name = call.data[PROJECT_NAME].lower() + projects = await coordinator.async_get_projects() + project_id: str | None = None + for project in projects: + if project_name == project.name.lower(): + project_id = project.id + if project_id is None: + raise HomeAssistantError(f"Invalid project name '{project_name}'") # Create the task content = call.data[CONTENT] @@ -192,7 +228,7 @@ async def async_setup_platform( data["labels"] = task_labels if ASSIGNEE in call.data: - collaborators = await api.get_collaborators(project_id) + collaborators = await coordinator.api.get_collaborators(project_id) collaborator_id_lookup = { collab.name.lower(): collab.id for collab in collaborators } @@ -225,7 +261,7 @@ async def async_setup_platform( date_format = "%Y-%m-%dT%H:%M:%S" data["due_datetime"] = datetime.strftime(due_date, date_format) - api_task = await api.add_task(content, **data) + api_task = await coordinator.api.add_task(content, **data) # @NOTE: The rest-api doesn't support reminders, this works manually using # the sync api, in order to keep functional parity with the component. @@ -263,7 +299,7 @@ async def async_setup_platform( } ] } - headers = create_headers(token=token, with_content=True) + headers = create_headers(token=coordinator.token, with_content=True) return await session.post(sync_url, headers=headers, json=reminder_data) if _reminder_due: diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py new file mode 100644 index 00000000000..0a41ecb0463 --- /dev/null +++ b/homeassistant/components/todoist/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for todoist integration.""" + +from http import HTTPStatus +import logging +from typing import Any + +from requests.exceptions import HTTPError +from todoist_api_python.api_async import TodoistAPIAsync +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SETTINGS_URL = "https://todoist.com/app/settings/integrations" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for todoist.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors: dict[str, str] = {} + if user_input is not None: + api = TodoistAPIAsync(user_input[CONF_TOKEN]) + try: + await api.get_tasks() + except HTTPError as err: + if err.response.status_code == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_access_token" + else: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_TOKEN]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Todoist", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"settings_url": SETTINGS_URL}, + ) diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index b573d1d1127..702c43883ea 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging from todoist_api_python.api_async import TodoistAPIAsync -from todoist_api_python.models import Task +from todoist_api_python.models import Label, Project, Task from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,10 +18,14 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): logger: logging.Logger, update_interval: timedelta, api: TodoistAPIAsync, + token: str, ) -> None: """Initialize the Todoist coordinator.""" super().__init__(hass, logger, name="Todoist", update_interval=update_interval) self.api = api + self._projects: list[Project] | None = None + self._labels: list[Label] | None = None + self.token = token async def _async_update_data(self) -> list[Task]: """Fetch tasks from the Todoist API.""" @@ -29,3 +33,15 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): return await self.api.get_tasks() except Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def async_get_projects(self) -> list[Project]: + """Return todoist projects fetched at most once.""" + if self._projects is None: + self._projects = await self.api.get_projects() + return self._projects + + async def async_get_labels(self) -> list[Label]: + """Return todoist labels fetched at most once.""" + if self._labels is None: + self._labels = await self.api.get_labels() + return self._labels diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index a83cdbe1b09..72d76108353 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -2,6 +2,7 @@ "domain": "todoist", "name": "Todoist", "codeowners": ["@boralyl"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 1ed092e5cf6..123b5d07ed7 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -1,4 +1,23 @@ { + "config": { + "step": { + "user": { + "data": { + "token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "Please entry your API token from your [Todoist Settings page]({settings_url})" + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, "services": { "new_task": { "name": "New task", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1557df8f33b..98935086b88 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -473,6 +473,7 @@ FLOWS = { "tibber", "tile", "tilt_ble", + "todoist", "tolo", "tomorrowio", "toon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7cad78a49fc..779ee92e9fe 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5808,7 +5808,7 @@ "todoist": { "name": "Todoist", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "tolo": { diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py new file mode 100644 index 00000000000..6543e5b678f --- /dev/null +++ b/tests/components/todoist/conftest.py @@ -0,0 +1,135 @@ +"""Common fixtures for the todoist tests.""" +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest +from requests.exceptions import HTTPError +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.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + +SUMMARY = "A task" +TOKEN = "some-token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.todoist.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="due") +def mock_due() -> Due: + """Mock a todoist Task Due date/time.""" + return Due( + is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" + ) + + +@pytest.fixture(name="task") +def mock_task(due: Due) -> Task: + """Mock a todoist Task instance.""" + return Task( + assignee_id="1", + assigner_id="1", + comment_count=0, + is_completed=False, + content=SUMMARY, + created_at="2021-10-01T00:00:00", + creator_id="1", + description="A task", + due=due, + id="1", + labels=["Label1"], + order=1, + parent_id=None, + priority=1, + project_id="12345", + section_id=None, + url="https://todoist.com", + sync_id=None, + ) + + +@pytest.fixture(name="api") +def mock_api(task) -> AsyncMock: + """Mock the api state.""" + api = AsyncMock() + api.get_projects.return_value = [ + Project( + id="12345", + color="blue", + comment_count=0, + is_favorite=False, + name="Name", + is_shared=False, + url="", + is_inbox_project=False, + is_team_inbox=False, + order=1, + parent_id=None, + view_style="list", + ) + ] + api.get_labels.return_value = [ + Label(id="1", name="Label1", color="1", order=1, is_favorite=False) + ] + api.get_collaborators.return_value = [ + Collaborator(email="user@gmail.com", id="1", name="user") + ] + api.get_tasks.return_value = [task] + return api + + +@pytest.fixture(name="todoist_api_status") +def mock_api_status() -> HTTPStatus | None: + """Fixture to inject an http status error.""" + return None + + +@pytest.fixture(autouse=True) +def mock_api_side_effect( + api: AsyncMock, todoist_api_status: HTTPStatus | None +) -> MockConfigEntry: + """Mock todoist configuration.""" + if todoist_api_status: + response = Response() + response.status_code = todoist_api_status + api.get_tasks.side_effect = HTTPError(response=response) + + +@pytest.fixture(name="todoist_config_entry") +def mock_todoist_config_entry() -> MockConfigEntry: + """Mock todoist configuration.""" + return MockConfigEntry(domain=DOMAIN, unique_id=TOKEN, data={CONF_TOKEN: TOKEN}) + + +@pytest.fixture(name="todoist_domain") +def mock_todoist_domain() -> str: + """Mock todoist configuration.""" + return DOMAIN + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + 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): + assert await async_setup_component(hass, DOMAIN, {}) + yield diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 921439fab45..45300e2e66c 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -7,7 +7,7 @@ import urllib import zoneinfo import pytest -from todoist_api_python.models import Collaborator, Due, Label, Project, Task +from todoist_api_python.models import Due from homeassistant import setup from homeassistant.components.todoist.const import ( @@ -24,9 +24,10 @@ 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 tests.typing import ClientSessionGenerator -SUMMARY = "A task" # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round TZ_NAME = "America/Regina" @@ -39,69 +40,6 @@ def set_time_zone(hass: HomeAssistant): hass.config.set_time_zone(TZ_NAME) -@pytest.fixture(name="due") -def mock_due() -> Due: - """Mock a todoist Task Due date/time.""" - return Due( - is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" - ) - - -@pytest.fixture(name="task") -def mock_task(due: Due) -> Task: - """Mock a todoist Task instance.""" - return Task( - assignee_id="1", - assigner_id="1", - comment_count=0, - is_completed=False, - content=SUMMARY, - created_at="2021-10-01T00:00:00", - creator_id="1", - description="A task", - due=due, - id="1", - labels=["Label1"], - order=1, - parent_id=None, - priority=1, - project_id="12345", - section_id=None, - url="https://todoist.com", - sync_id=None, - ) - - -@pytest.fixture(name="api") -def mock_api(task) -> AsyncMock: - """Mock the api state.""" - api = AsyncMock() - api.get_projects.return_value = [ - Project( - id="12345", - color="blue", - comment_count=0, - is_favorite=False, - name="Name", - is_shared=False, - url="", - is_inbox_project=False, - is_team_inbox=False, - order=1, - parent_id=None, - view_style="list", - ) - ] - api.get_labels.return_value = [ - Label(id="1", name="Label1", color="1", order=1, is_favorite=False) - ] - api.get_collaborators.return_value = [ - Collaborator(email="user@gmail.com", id="1", name="user") - ] - api.get_tasks.return_value = [task] - return api - - def get_events_url(entity: str, start: str, end: str) -> str: """Create a url to get events during the specified time range.""" return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" @@ -127,8 +65,8 @@ def mock_todoist_config() -> dict[str, Any]: return {} -@pytest.fixture(name="setup_integration", autouse=True) -async def mock_setup_integration( +@pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_platform( hass: HomeAssistant, api: AsyncMock, todoist_config: dict[str, Any], @@ -215,7 +153,7 @@ async def test_update_entity_for_calendar_with_due_date_in_the_future( assert state.attributes["end_time"] == expected_end_time -@pytest.mark.parametrize("setup_integration", [None]) +@pytest.mark.parametrize("setup_platform", [None]) async def test_failed_coordinator_update(hass: HomeAssistant, api: AsyncMock) -> None: """Test a failed data coordinator update is handled correctly.""" api.get_tasks.side_effect = Exception("API error") @@ -417,3 +355,44 @@ async def test_task_due_datetime( ) assert response.status == HTTPStatus.OK assert await response.json() == [] + + +@pytest.mark.parametrize( + ("due", "setup_platform"), + [ + ( + Due( + date="2023-03-30", + is_recurring=False, + string="Mar 30 6:00 PM", + datetime="2023-03-31T00:00:00Z", + timezone="America/Regina", + ), + None, + ) + ], +) +async def test_config_entry( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test for a calendar created with a config entry.""" + + await async_update_entity(hass, "calendar.name") + state = hass.states.get("calendar.name") + assert state + + client = await hass_client() + response = await client.get( + get_events_url( + "calendar.name", "2023-03-30T08:00:00.000Z", "2023-03-31T08:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [ + get_events_response( + {"dateTime": "2023-03-30T18:00:00-06:00"}, + {"dateTime": "2023-03-31T18:00:00-06:00"}, + ) + ] diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py new file mode 100644 index 00000000000..4175902da31 --- /dev/null +++ b/tests/components/todoist/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the todoist config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.todoist.const import DOMAIN +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TOKEN + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.fixture(autouse=True) +async def patch_api( + api: AsyncMock, +) -> None: + """Mock setup of the todoist integration.""" + with patch( + "homeassistant.components.todoist.config_flow.TodoistAPIAsync", return_value=api + ): + yield + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Todoist" + assert result2.get("data") == { + CONF_TOKEN: TOKEN, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED]) +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "invalid_access_token"} + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED]) +async def test_unknown_error(hass: HomeAssistant, api: AsyncMock) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + api.get_tasks.side_effect = ValueError("unexpected") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "unknown"} + + +async def test_already_configured(hass: HomeAssistant, setup_integration: None) -> None: + """Test that only a single instance can be configured.""" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py new file mode 100644 index 00000000000..cc64464df1d --- /dev/null +++ b/tests/components/todoist/test_init.py @@ -0,0 +1,47 @@ +"""Unit tests for the Todoist integration.""" +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.todoist.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +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, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Test loading and unloading of the config entry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert todoist_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(todoist_config_entry.entry_id) + assert todoist_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) +async def test_init_failure( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + assert todoist_config_entry.state == ConfigEntryState.SETUP_RETRY