core/tests/components/todoist/test_calendar.py

522 lines
16 KiB
Python

"""Unit tests for the Todoist calendar platform."""
from datetime import timedelta
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, patch
import urllib
import zoneinfo
from freezegun.api import FrozenDateTimeFactory
import pytest
from todoist_api_python.models import Due
from homeassistant import setup
from homeassistant.components.todoist.const import (
ASSIGNEE,
CONTENT,
DOMAIN,
LABELS,
PROJECT_NAME,
SECTION_NAME,
SERVICE_NEW_TASK,
)
from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
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 PROJECT_ID, SECTION_ID, SUMMARY
from tests.typing import ClientSessionGenerator
# Set our timezone to CST/Regina so we can check calculations
# This keeps UTC-6 all year round
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)
async def set_time_zone(hass: HomeAssistant):
"""Set the time zone for the tests."""
await hass.config.async_set_time_zone(TZ_NAME)
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)}"
def get_events_response(start: dict[str, str], end: dict[str, str]) -> dict[str, Any]:
"""Return an event response with a single task."""
return {
"start": start,
"end": end,
"summary": SUMMARY,
"description": None,
"location": None,
"uid": None,
"recurrence_id": None,
"rrule": None,
}
@pytest.fixture(name="todoist_config")
def mock_todoist_config() -> dict[str, Any]:
"""Mock todoist configuration."""
return {}
@pytest.fixture(name="setup_platform", autouse=True)
async def mock_setup_platform(
hass: HomeAssistant,
api: AsyncMock,
todoist_config: dict[str, Any],
) -> None:
"""Mock setup of the todoist integration."""
with patch(
"homeassistant.components.todoist.calendar.TodoistAPIAsync"
) as todoist_api:
todoist_api.return_value = api
assert await setup.async_setup_component(
hass,
"calendar",
{
"calendar": {
"platform": DOMAIN,
CONF_TOKEN: "token",
**todoist_config,
}
},
)
await hass.async_block_till_done()
await async_update_entity(hass, "calendar.name")
yield
async def test_calendar_entity_unique_id(
hass: HomeAssistant, api: AsyncMock, entity_registry: er.EntityRegistry
) -> None:
"""Test unique id is set to project id."""
entity = entity_registry.async_get("calendar.name")
assert entity.unique_id == PROJECT_ID
@pytest.mark.parametrize(
"todoist_config",
[{"custom_projects": [{"name": "All projects", "labels": ["Label1"]}]}],
)
async def test_update_entity_for_custom_project_with_labels_on(
hass: HomeAssistant,
api: AsyncMock,
) -> None:
"""Test that the calendar's state is on for a custom project using labels."""
await async_update_entity(hass, "calendar.all_projects")
state = hass.states.get("calendar.all_projects")
assert state.attributes["labels"] == ["Label1"]
assert state.state == "on"
@pytest.mark.parametrize("due", [None])
async def test_update_entity_for_custom_project_no_due_date_on(
hass: HomeAssistant,
api: AsyncMock,
) -> None:
"""Test that a task without an explicit due date is considered to be in an on state."""
await async_update_entity(hass, "calendar.name")
state = hass.states.get("calendar.name")
assert state.state == "on"
@pytest.mark.parametrize(
"due",
[
Due(
# Note: This runs before the test fixture that sets the timezone
date=(dt_util.now(TIMEZONE) + timedelta(days=3)).strftime("%Y-%m-%d"),
is_recurring=False,
string="3 days from today",
)
],
)
async def test_update_entity_for_calendar_with_due_date_in_the_future(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
api: AsyncMock,
) -> None:
"""Test that a task with a due date in the future has on state and correct end_time."""
await async_update_entity(hass, "calendar.name")
state = hass.states.get("calendar.name")
assert state.state == "on"
# The end time should be in the user's timezone
expected_end_time = (dt_util.now() + timedelta(days=3)).strftime(
"%Y-%m-%d 00:00:00"
)
assert state.attributes["end_time"] == expected_end_time
@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")
assert await setup.async_setup_component(
hass,
"calendar",
{
"calendar": {
"platform": DOMAIN,
CONF_TOKEN: "token",
"custom_projects": [{"name": "All projects", "labels": ["Label1"]}],
}
},
)
await hass.async_block_till_done()
await async_update_entity(hass, "calendar.all_projects")
state = hass.states.get("calendar.all_projects")
assert state is None
@pytest.mark.parametrize(
"todoist_config",
[{"custom_projects": [{"name": "All projects"}]}],
)
async def test_calendar_custom_project_unique_id(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test unique id is None for any custom projects."""
entity = entity_registry.async_get("calendar.all_projects")
assert entity is None
@pytest.mark.parametrize(
("due", "start", "end", "expected_response"),
[
(
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-28T00:00:00.000Z",
"2023-04-01T00:00:00.000Z",
[get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})],
),
(
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-30T06:00:00.000Z",
"2023-03-31T06:00:00.000Z",
[get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})],
),
(
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-29T08:00:00.000Z",
"2023-03-30T08:00:00.000Z",
[get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})],
),
(
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-30T08:00:00.000Z",
"2023-03-31T08:00:00.000Z",
[get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})],
),
(
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-31T08:00:00.000Z",
"2023-04-01T08:00:00.000Z",
[],
),
(
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-29T06:00:00.000Z",
"2023-03-30T06:00:00.000Z",
[],
),
],
ids=("included", "exact", "overlap_start", "overlap_end", "after", "before"),
)
async def test_all_day_event(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
start: str,
end: str,
expected_response: dict[str, Any],
) -> None:
"""Test for an all day calendar event."""
client = await hass_client()
response = await client.get(
get_events_url("calendar.name", start, end),
)
assert response.status == HTTPStatus.OK
assert await response.json() == expected_response
async def test_create_task_service_call(hass: HomeAssistant, api: AsyncMock) -> None:
"""Test api is called correctly after a new task service call."""
await hass.services.async_call(
DOMAIN,
SERVICE_NEW_TASK,
{ASSIGNEE: "user", CONTENT: "task", LABELS: ["Label1"], PROJECT_NAME: "Name"},
)
await hass.async_block_till_done()
api.add_task.assert_called_with(
"task", project_id=PROJECT_ID, labels=["Label1"], assignee_id="1"
)
async def test_create_task_service_call_raises(
hass: HomeAssistant, api: AsyncMock
) -> None:
"""Test adding an item to an invalid project raises an error."""
with pytest.raises(ServiceValidationError, match="project_invalid"):
await hass.services.async_call(
DOMAIN,
SERVICE_NEW_TASK,
{
ASSIGNEE: "user",
CONTENT: "task",
LABELS: ["Label1"],
PROJECT_NAME: "Missing Project",
},
blocking=True,
)
async def test_create_task_service_call_with_section(
hass: HomeAssistant, api: AsyncMock
) -> None:
"""Test api is called correctly when section is included."""
await hass.services.async_call(
DOMAIN,
SERVICE_NEW_TASK,
{
ASSIGNEE: "user",
CONTENT: "task",
LABELS: ["Label1"],
PROJECT_NAME: "Name",
SECTION_NAME: "Section Name",
},
)
await hass.async_block_till_done()
api.add_task.assert_called_with(
"task",
project_id=PROJECT_ID,
section_id=SECTION_ID,
labels=["Label1"],
assignee_id="1",
)
@pytest.mark.parametrize(
("due"),
[
# These are all equivalent due dates for the same time in different
# timezone formats.
Due(
date="2023-03-30",
is_recurring=False,
string="Mar 30 6:00 PM",
datetime="2023-03-31T00:00:00Z",
timezone="America/Regina",
),
Due(
date="2023-03-30",
is_recurring=False,
string="Mar 30 7:00 PM",
datetime="2023-03-31T00:00:00Z",
timezone="America/Los_Angeles",
),
Due(
date="2023-03-30",
is_recurring=False,
string="Mar 30 6:00 PM",
datetime="2023-03-30T18:00:00",
),
],
ids=("in_local_timezone", "in_other_timezone", "floating"),
)
async def test_task_due_datetime(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test for task due at a specific time, using different time formats."""
client = await hass_client()
has_task_response = [
get_events_response(
{"dateTime": "2023-03-30T18:00:00-06:00"},
{"dateTime": "2023-03-31T18:00:00-06:00"},
)
]
# Completely includes the start/end of the task
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() == has_task_response
# Overlap with the start of the event
response = await client.get(
get_events_url(
"calendar.name", "2023-03-29T20:00:00.000Z", "2023-03-31T02:00:00.000Z"
),
)
assert response.status == HTTPStatus.OK
assert await response.json() == has_task_response
# Overlap with the end of the event
response = await client.get(
get_events_url(
"calendar.name", "2023-03-31T20:00:00.000Z", "2023-04-01T02:00:00.000Z"
),
)
assert response.status == HTTPStatus.OK
assert await response.json() == has_task_response
# Task is active, but range does not include start/end
response = await client.get(
get_events_url(
"calendar.name", "2023-03-31T10:00:00.000Z", "2023-03-31T11:00:00.000Z"
),
)
assert response.status == HTTPStatus.OK
assert await response.json() == has_task_response
# Query is before the task starts (no results)
response = await client.get(
get_events_url(
"calendar.name", "2023-03-28T00:00:00.000Z", "2023-03-29T00:00:00.000Z"
),
)
assert response.status == HTTPStatus.OK
assert await response.json() == []
# Query is after the task ends (no results)
response = await client.get(
get_events_url(
"calendar.name", "2023-04-01T07:00:00.000Z", "2023-04-02T07:00:00.000Z"
),
)
assert response.status == HTTPStatus.OK
assert await response.json() == []
@pytest.mark.parametrize(
("todoist_config", "due", "start", "end", "expected_response"),
[
(
{"custom_projects": [{"name": "Test", "labels": ["Label1"]}]},
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-28T00:00:00.000Z",
"2023-04-01T00:00:00.000Z",
[get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})],
),
(
{"custom_projects": [{"name": "Test", "labels": ["custom"]}]},
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-28T00:00:00.000Z",
"2023-04-01T00:00:00.000Z",
[],
),
(
{"custom_projects": [{"name": "Test", "include_projects": ["Name"]}]},
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-28T00:00:00.000Z",
"2023-04-01T00:00:00.000Z",
[get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})],
),
(
{"custom_projects": [{"name": "Test", "due_date_days": 1}]},
Due(date="2023-03-30", is_recurring=False, string="Mar 30"),
"2023-03-28T00:00:00.000Z",
"2023-04-01T00:00:00.000Z",
[get_events_response({"date": "2023-03-30"}, {"date": "2023-03-31"})],
),
(
{"custom_projects": [{"name": "Test", "due_date_days": 1}]},
Due(
date=(dt_util.now() + timedelta(days=2)).strftime("%Y-%m-%d"),
is_recurring=False,
string="Mar 30",
),
dt_util.now().isoformat(),
(dt_util.now() + timedelta(days=5)).isoformat(),
[],
),
],
ids=[
"in_labels_whitelist",
"not_in_labels_whitelist",
"in_include_projects",
"in_due_date_days",
"not_in_due_date_days",
],
)
async def test_events_filtered_for_custom_projects(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
start: str,
end: str,
expected_response: dict[str, Any],
) -> None:
"""Test we filter out tasks from custom projects based on their config."""
client = await hass_client()
response = await client.get(
get_events_url("calendar.test", start, end),
)
assert response.status == HTTPStatus.OK
assert await response.json() == expected_response
@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"},
)
]