From 489bb1e07bf77fb566e5237a907ab8e965caaee6 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Fri, 24 Mar 2023 19:28:14 +0000 Subject: [PATCH 01/13] Implement imap_content event --- homeassistant/components/imap/coordinator.py | 111 ++++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 69f291df6eb8..a25404a14280 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Mapping from datetime import timedelta +import email import logging from typing import Any @@ -11,7 +12,12 @@ import async_timeout from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONTENT_TYPE_TEXT_PLAIN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,6 +29,8 @@ BACKOFF_TIME = 10 +EVENT_IMAP = "imap_content" + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" @@ -37,6 +45,70 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: return client +class ImapMessage: + """Class to parse an RFC822 email message.""" + + def __init__(self, raw_message: bytes) -> None: + """Initialize IMAP message.""" + self.email_message = email.message_from_bytes(raw_message) + + @property + def headers(self) -> dict[str, tuple[str,]]: + """Get the email headers.""" + header_base: dict[str, tuple[str,]] = {} + for key, value in self.email_message.items(): + header: tuple[str,] = (str(value),) + if header_base.setdefault(key, header) != header: + header_base[key] += header # type: ignore[assignment] + return header_base + + @property + def sender(self) -> str: + """Get the parsed message sender from the email.""" + return str(email.utils.parseaddr(self.email_message["From"])[1]) + + @property + def subject(self) -> str: + """Decode the message subject.""" + decoded_header = email.header.decode_header(self.email_message["Subject"]) + header = email.header.make_header(decoded_header) + return str(header) + + @property + def text(self) -> str: + """Get the message text from the email. + + Will look for text/plain or use text/html if not found. + """ + message_text = None + message_html = None + message_untyped_text = None + + for part in self.email_message.walk(): + if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: + if message_text is None: + message_text = part.get_payload() + elif part.get_content_type() == "text/html": + if message_html is None: + message_html = part.get_payload() + elif ( + part.get_content_type().startswith("text") + and message_untyped_text is None + ): + message_untyped_text = part.get_payload() + + if message_text is not None: + return message_text + + if message_html is not None: + return message_html + + if message_untyped_text is not None: + return message_untyped_text + + return self.email_message.get_payload() + + class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" @@ -50,6 +122,7 @@ def __init__( ) -> None: """Initiate imap client.""" self.imap_client = imap_client + self._last_mesgid: str | None = None super().__init__( hass, _LOGGER, @@ -65,8 +138,30 @@ async def _async_reconnect_if_needed(self) -> None: if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) + async def _async_process_event(self, last_msgid: str) -> None: + """Send a event for the last message if the last message was changed.""" + response = await self.imap_client.fetch(last_msgid, "BODY.PEEK[]") + if response.result == "OK": + message = ImapMessage(response.lines[1]) + data = { + "server": self.config_entry.data[CONF_SERVER], + "username": self.config_entry.data[CONF_USERNAME], + "search": self.config_entry.data[CONF_SEARCH], + "folder": self.config_entry.data[CONF_FOLDER], + "text": message.text, + "sender": message.sender, + "subject": message.subject, + "headers": message.headers, + } + self.hass.bus.fire(EVENT_IMAP, data) + _LOGGER.debug( + "Message processed, sender: %s, subject: %s", + message.sender, + message.subject, + ) + async def _async_fetch_number_of_messages(self) -> int | None: - """Fetch number of messages.""" + """Fetch last message and messages count.""" await self._async_reconnect_if_needed() await self.imap_client.noop() result, lines = await self.imap_client.search( @@ -77,7 +172,17 @@ async def _async_fetch_number_of_messages(self) -> int | None: raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - return len(lines[0].split()) + count: int = len(msgids := lines[0].split()) + last_msgid = ( + str(msgids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) + if count + else None + ) + if count and last_msgid is not None: + self._last_mesgid = last_msgid + await self._async_process_event(last_msgid) + + return count async def _cleanup(self, log_error: bool = False) -> None: """Close resources.""" From 386fc1bccbfd2352fc7e01db4cbf881921680a08 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Sat, 25 Mar 2023 20:58:51 +0000 Subject: [PATCH 02/13] Add fixtures and tests init --- .coveragerc | 3 -- tests/components/imap/conftest.py | 83 +++++++++++++++++++++++++++++- tests/components/imap/test_init.py | 50 ++++++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/components/imap/test_init.py diff --git a/.coveragerc b/.coveragerc index da7cc42ba15f..46e11d8bda06 100644 --- a/.coveragerc +++ b/.coveragerc @@ -518,9 +518,6 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/imap/__init__.py - homeassistant/components/imap/coordinator.py - homeassistant/components/imap/sensor.py homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index bc82cf57d816..871fa73fd916 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -1,7 +1,10 @@ -"""Test the iamp config flow.""" +"""Fixtures for imap tests.""" + + from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from aioimaplib import AUTH, SELECTED, STARTED, Response import pytest @@ -12,3 +15,79 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.imap.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +def imap_capabilities() -> Generator[set[str], None]: + """Fixture to set the imap capabilities.""" + return {"IDLE"} + + +@pytest.fixture +def imap_search() -> Generator[tuple[str, list[bytes]], None]: + """Fixture to set the imap search response.""" + return ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) + + +@pytest.fixture +def imap_pending_idle() -> Generator[bool, None]: + """Fixture to set the imap pending idle feature.""" + return True + + +@pytest.fixture +async def mock_imap_protocol( + imap_search: tuple[str, list[bytes]], + imap_capabilities: set[str], + imap_pending_idle: bool, +) -> Generator[MagicMock, None]: + """Mock the aioimaplib IMAP protocol handler.""" + + class IMAP4ClientMock: + """Mock for IMAP4 client.""" + + class IMAP4ClientProtocolMock: + """Mock the IMAP4 client protocol.""" + + state: str = STARTED + + @property + def capabilities(self) -> set[str]: + """Mock the capabilities.""" + return imap_capabilities + + def __init__(self, *args, **kwargs) -> None: + self._state = STARTED + self.wait_hello_from_server = AsyncMock() + self.wait_server_push = AsyncMock() + self.noop = AsyncMock() + self.has_pending_idle = MagicMock(return_value=imap_pending_idle) + self.idle_start = AsyncMock() + self.idle_done = MagicMock() + self.stop_wait_server_push = AsyncMock() + self.close = AsyncMock() + self.logout = AsyncMock() + self.protocol = self.IMAP4ClientProtocolMock() + + def has_capability(self, capability: str) -> bool: + """Check capability.""" + return capability in self.protocol.capabilities + + async def login(self, user: str, password: str) -> Response: + """Mock the login.""" + self.protocol.state = AUTH + return ("OK", []) + + async def select(self, mailbox: str = "INBOX") -> Response: + """Mock the folder select.""" + self.protocol.state = SELECTED + return ("OK", []) + + async def search(self, *criteria: str, charset: str = "utf-8") -> Response: + """Mock the imap search.""" + return imap_search + + with patch( + "homeassistant.components.imap.coordinator.IMAP4_SSL", + side_effect=IMAP4ClientMock, + ) as protocol: + yield protocol diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py new file mode 100644 index 000000000000..1d4e6400a379 --- /dev/null +++ b/tests/components/imap/test_init.py @@ -0,0 +1,50 @@ +"""Test the imap entry initialization.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.imap import DOMAIN +from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder +from homeassistant.core import HomeAssistant + +from .test_config_flow import MOCK_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +async def test_entry_startup_and_unload( + hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock] +) -> None: + """Test imap entry startup with polling coordinator.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert await config_entry.async_unload(hass) + + +@pytest.mark.parametrize( + "effect", + [ + InvalidAuth, + InvalidFolder, + asyncio.TimeoutError, + ], +) +async def test_entry_startup_fails( + hass: HomeAssistant, + mock_imap_protocol: dict[str, AsyncMock], + effect: Exception, +) -> None: + """Test imap entry startup with polling coordinator.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.imap.connect_to_server", + side_effect=effect, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is False From 42d567eac97be9c8c7103a490cf9fee1396fa3c0 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Sun, 26 Mar 2023 15:03:13 +0000 Subject: [PATCH 03/13] Improve fixtures and add tests with message --- tests/components/imap/conftest.py | 71 ++++++++++++++++++----- tests/components/imap/const.py | 45 ++++++++++++++ tests/components/imap/test_coordinator.py | 50 ++++++++++++++++ tests/components/imap/test_init.py | 4 +- 4 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 tests/components/imap/const.py create mode 100644 tests/components/imap/test_coordinator.py diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index 871fa73fd916..7455aa0429e5 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -1,12 +1,13 @@ """Fixtures for imap tests.""" - from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from aioimaplib import AUTH, SELECTED, STARTED, Response +from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response import pytest +from .const import EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -22,10 +23,28 @@ def imap_capabilities() -> Generator[set[str], None]: return {"IDLE"} +@pytest.fixture +def imap_login_state() -> Generator[str, None]: + """Fixture to set the imap state after login.""" + return AUTH + + +@pytest.fixture +def imap_select_state() -> Generator[str, None]: + """Fixture to set the imap capabilities.""" + return SELECTED + + @pytest.fixture def imap_search() -> Generator[tuple[str, list[bytes]], None]: """Fixture to set the imap search response.""" - return ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) + return EMPTY_SEARCH_RESPONSE + + +@pytest.fixture +def imap_fetch() -> Generator[tuple[str, list[bytes | bytearray]], None]: + """Fixture to set the imap fetch response.""" + return TEST_FETCH_RESPONSE @pytest.fixture @@ -37,8 +56,11 @@ def imap_pending_idle() -> Generator[bool, None]: @pytest.fixture async def mock_imap_protocol( imap_search: tuple[str, list[bytes]], + imap_fetch: tuple[str, list[bytes | bytearray]], imap_capabilities: set[str], imap_pending_idle: bool, + imap_login_state: str, + imap_select_state: str, ) -> Generator[MagicMock, None]: """Mock the aioimaplib IMAP protocol handler.""" @@ -57,15 +79,12 @@ def capabilities(self) -> set[str]: def __init__(self, *args, **kwargs) -> None: self._state = STARTED - self.wait_hello_from_server = AsyncMock() self.wait_server_push = AsyncMock() self.noop = AsyncMock() self.has_pending_idle = MagicMock(return_value=imap_pending_idle) self.idle_start = AsyncMock() self.idle_done = MagicMock() - self.stop_wait_server_push = AsyncMock() - self.close = AsyncMock() - self.logout = AsyncMock() + self.stop_wait_server_push = AsyncMock(return_value=True) self.protocol = self.IMAP4ClientProtocolMock() def has_capability(self, capability: str) -> bool: @@ -73,18 +92,40 @@ def has_capability(self, capability: str) -> bool: return capability in self.protocol.capabilities async def login(self, user: str, password: str) -> Response: - """Mock the login.""" - self.protocol.state = AUTH - return ("OK", []) + """Mock imap login.""" + self.protocol.state = imap_login_state + if imap_login_state != AUTH: + return Response("BAD", []) + return Response("OK", [b"CAPABILITY IMAP4rev1 ...", b"Logged in"]) + + async def close(self) -> Response: + """Mock imap close the selected folder.""" + self.protocol.state = imap_login_state + return Response("OK", []) + + async def logout(self) -> Response: + """Mock imap logout.""" + self.protocol.state = LOGOUT + return Response("OK", []) async def select(self, mailbox: str = "INBOX") -> Response: - """Mock the folder select.""" - self.protocol.state = SELECTED - return ("OK", []) + """Mock imap folder select.""" + self.protocol.state = imap_select_state + if imap_login_state != SELECTED: + return Response("BAD", []) + return Response("OK", []) async def search(self, *criteria: str, charset: str = "utf-8") -> Response: - """Mock the imap search.""" - return imap_search + """Mock imap search.""" + return Response(*imap_search) + + async def fetch(self, message_set: str, message_parts: str) -> Response: + """Mock imap fetch.""" + return Response(*imap_fetch) + + async def wait_hello_from_server(self) -> None: + """Mock wait for hello.""" + self.protocol.state = NONAUTH with patch( "homeassistant.components.imap.coordinator.IMAP4_SSL", diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py new file mode 100644 index 000000000000..070358b7d535 --- /dev/null +++ b/tests/components/imap/const.py @@ -0,0 +1,45 @@ +"""Constants for tests imap integration.""" + +TEST_MESSAGE = ( + b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" + b"Received: from beta.example.com\r\n\tby beta with LMTP\r\n\t" + b"id eLp2M/GcHWQTLxQAho4UZQ\r\n\t(envelope-from )\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100\r\n" + b"Received: from localhost (localhost [127.0.0.1])\r\n\t" + b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" + b"Received: from beta.example.com ([192.168.200.137])\r\n\t" + b"by localhost (beta.example.com [127.0.0.1]) (amavisd-new, port 12345)\r\n\t" + b"with ESMTP id ycTJJEDpDgm0 for ;\r\n\t" + b"Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" + b"Received: from [IPV6:2001:db8::ed28:3645:f874:395f] " + b"(demo [IPv6:2001:db8::ed28:3645:f874:395f])\r\n\t(using TLSv1.3 with cipher " + b"TLS_AES_256_GCM_SHA384 (256/256 bits)\r\n\t key-exchange ECDHE (P-384) " + b"server-signature RSA-PSS (2048 bits))\r\n\t(No client certificate requested)\r\n\t" + b"by beta.example.com (Postfix) with ESMTPSA id B942E609BE\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n\t" + b"h=Message-ID:Date:MIME-Version:To:From:Subject:Content-Type:\r\n\t " + b"Message-ID: <48eca8bb-0551-446b-d8c5-02157f38cca7@example.com>\r\nDate: Fri, 24 Mar 2023 13:52:00 +0100\r\nMIME-Version: 1.0\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101\r\n Thunderbird/102.9.0\r\n" + b"To: notify@example.com\r\n" + b"From: John Doe \r\n" + b"Subject: Test subject\r\n" + b"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" +) + +EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) +BAD_SEARCH_RESPONSE = ("BAD", [b"", b"Unexpected error"]) + +TEST_SEARCH_RESPONSE = ("OK", [b"1", b"Search completed (0.0001 + 0.000 secs)."]) + +TEST_FETCH_RESPONSE = ( + "OK", + [ + b"1 FETCH (BODY[] {1518}", + bytearray(TEST_MESSAGE), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +BAD_FETCH_RESPONSE = ("BAD", []) diff --git a/tests/components/imap/test_coordinator.py b/tests/components/imap/test_coordinator.py new file mode 100644 index 000000000000..691eab6207c0 --- /dev/null +++ b/tests/components/imap/test_coordinator.py @@ -0,0 +1,50 @@ +"""Test the imap coordinator.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.imap import DOMAIN +from homeassistant.core import Event, HomeAssistant +from homeassistant.util.dt import utcnow + +from .const import TEST_FETCH_RESPONSE, TEST_SEARCH_RESPONSE +from .test_config_flow import MOCK_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + ("imap_search", "imap_fetch"), [(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE)] +) +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +async def test_receiving_message_successfully( + hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock] +) -> None: + """Test receiving a message successfully.""" + event_called = MagicMock() + + async def _async_event_listener(event: Event) -> None: + """Listen to events.""" + event_called(event) + + handler = hass.bus.async_listen("imap_content", _async_event_listener) + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + + # cleanup event listener + handler() + + # we should have received one event + event_called.assert_called_once() diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 1d4e6400a379..665cf9a62d41 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -18,7 +18,7 @@ async def test_entry_startup_and_unload( hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock] ) -> None: - """Test imap entry startup with polling coordinator.""" + """Test imap entry startup and unload with push and polling coordinator.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -39,7 +39,7 @@ async def test_entry_startup_fails( mock_imap_protocol: dict[str, AsyncMock], effect: Exception, ) -> None: - """Test imap entry startup with polling coordinator.""" + """Test imap entry startup fails on invalid auth or folder.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) From fb347338ecf35a9ced7e47f9de0dd47c9a56015f Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 27 Mar 2023 07:22:35 +0000 Subject: [PATCH 04/13] Test initial folder or auth error --- tests/components/imap/test_coordinator.py | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/components/imap/test_coordinator.py b/tests/components/imap/test_coordinator.py index 691eab6207c0..5d8c2f63db4e 100644 --- a/tests/components/imap/test_coordinator.py +++ b/tests/components/imap/test_coordinator.py @@ -9,6 +9,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.util.dt import utcnow +from .conftest import AUTH, NONAUTH, SELECTED from .const import TEST_FETCH_RESPONSE, TEST_SEARCH_RESPONSE from .test_config_flow import MOCK_CONFIG @@ -48,3 +49,31 @@ async def _async_event_listener(event: Event) -> None: # we should have received one event event_called.assert_called_once() + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] +) +async def test_initial_authentication_error( + hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock], success: bool +) -> None: + """Test authentication error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_select_state", "success"), [(AUTH, False), (SELECTED, True)] +) +async def test_initial_invalid_folder_error( + hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock], success: bool +) -> None: + """Test receiving a message successfully.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() From 0e5b5e2f9e0b88c1556e89c41bd4958bafe0649c Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 27 Mar 2023 08:45:01 +0000 Subject: [PATCH 05/13] rename last_msgid->last_message_id --- homeassistant/components/imap/coordinator.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index a25404a14280..0694c7845488 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -122,7 +122,7 @@ def __init__( ) -> None: """Initiate imap client.""" self.imap_client = imap_client - self._last_mesgid: str | None = None + self._last_message_id: str | None = None super().__init__( hass, _LOGGER, @@ -138,9 +138,9 @@ async def _async_reconnect_if_needed(self) -> None: if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) - async def _async_process_event(self, last_msgid: str) -> None: + async def _async_process_event(self, last_message_id: str) -> None: """Send a event for the last message if the last message was changed.""" - response = await self.imap_client.fetch(last_msgid, "BODY.PEEK[]") + response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]") if response.result == "OK": message = ImapMessage(response.lines[1]) data = { @@ -173,14 +173,14 @@ async def _async_fetch_number_of_messages(self) -> int | None: f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) count: int = len(msgids := lines[0].split()) - last_msgid = ( + last_message_id = ( str(msgids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) if count else None ) - if count and last_msgid is not None: - self._last_mesgid = last_msgid - await self._async_process_event(last_msgid) + if count and last_message_id is not None: + self._last_message_id = last_message_id + await self._async_process_event(last_message_id) return count From 3fc5c00d44e46940fb49fb8d8ce31781c99d3ba9 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 27 Mar 2023 08:54:46 +0000 Subject: [PATCH 06/13] Rename msgids->message_ids --- homeassistant/components/imap/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 0694c7845488..76eb8e46f533 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -172,9 +172,9 @@ async def _async_fetch_number_of_messages(self) -> int | None: raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - count: int = len(msgids := lines[0].split()) + count: int = len(message_ids := lines[0].split()) last_message_id = ( - str(msgids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) + str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) if count else None ) From 858e7e8d4e8caf53d86c6391803834392a2cfb67 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 27 Mar 2023 13:53:55 +0000 Subject: [PATCH 07/13] Additional tests --- tests/components/imap/conftest.py | 23 +++- tests/components/imap/const.py | 124 ++++++++++++++++++--- tests/components/imap/test_coordinator.py | 125 ++++++++++++++++++++-- tests/components/imap/test_init.py | 6 +- 4 files changed, 248 insertions(+), 30 deletions(-) diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index 7455aa0429e5..fe2da882cf9a 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -6,7 +6,7 @@ from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response import pytest -from .const import EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE +from .const import EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN @pytest.fixture @@ -44,7 +44,7 @@ def imap_search() -> Generator[tuple[str, list[bytes]], None]: @pytest.fixture def imap_fetch() -> Generator[tuple[str, list[bytes | bytearray]], None]: """Fixture to set the imap fetch response.""" - return TEST_FETCH_RESPONSE + return TEST_FETCH_RESPONSE_TEXT_PLAIN @pytest.fixture @@ -53,6 +53,18 @@ def imap_pending_idle() -> Generator[bool, None]: return True +@pytest.fixture +def imap_close() -> Generator[AsyncMock, None]: + """Fixture to mock a side_effect the imap client close method.""" + return AsyncMock() + + +@pytest.fixture +def imap_wait_server_push() -> Generator[AsyncMock, None]: + """Fixture to mock the imap client wait_server_push method.""" + return AsyncMock() + + @pytest.fixture async def mock_imap_protocol( imap_search: tuple[str, list[bytes]], @@ -61,6 +73,8 @@ async def mock_imap_protocol( imap_pending_idle: bool, imap_login_state: str, imap_select_state: str, + imap_wait_server_push: AsyncMock, + imap_close: AsyncMock, ) -> Generator[MagicMock, None]: """Mock the aioimaplib IMAP protocol handler.""" @@ -79,12 +93,12 @@ def capabilities(self) -> set[str]: def __init__(self, *args, **kwargs) -> None: self._state = STARTED - self.wait_server_push = AsyncMock() + self.wait_server_push = imap_wait_server_push self.noop = AsyncMock() self.has_pending_idle = MagicMock(return_value=imap_pending_idle) self.idle_start = AsyncMock() self.idle_done = MagicMock() - self.stop_wait_server_push = AsyncMock(return_value=True) + self.stop_wait_server_push = AsyncMock() self.protocol = self.IMAP4ClientProtocolMock() def has_capability(self, capability: str) -> bool: @@ -100,6 +114,7 @@ async def login(self, user: str, password: str) -> Response: async def close(self) -> Response: """Mock imap close the selected folder.""" + await imap_close() self.protocol.state = imap_login_state return Response("OK", []) diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 070358b7d535..7c1c929e6ebb 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -8,35 +8,129 @@ b"Received: from localhost (localhost [127.0.0.1])\r\n\t" b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" - b"Received: from beta.example.com ([192.168.200.137])\r\n\t" - b"by localhost (beta.example.com [127.0.0.1]) (amavisd-new, port 12345)\r\n\t" - b"with ESMTP id ycTJJEDpDgm0 for ;\r\n\t" - b"Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" - b"Received: from [IPV6:2001:db8::ed28:3645:f874:395f] " - b"(demo [IPv6:2001:db8::ed28:3645:f874:395f])\r\n\t(using TLSv1.3 with cipher " - b"TLS_AES_256_GCM_SHA384 (256/256 bits)\r\n\t key-exchange ECDHE (P-384) " - b"server-signature RSA-PSS (2048 bits))\r\n\t(No client certificate requested)\r\n\t" - b"by beta.example.com (Postfix) with ESMTPSA id B942E609BE\r\n\t" - b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n\t" - b"h=Message-ID:Date:MIME-Version:To:From:Subject:Content-Type:\r\n\t " - b"Message-ID: <48eca8bb-0551-446b-d8c5-02157f38cca7@example.com>\r\nDate: Fri, 24 Mar 2023 13:52:00 +0100\r\nMIME-Version: 1.0\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101\r\n Thunderbird/102.9.0\r\n" + b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" + b"MIME-Version: 1.0\r\n" b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" +) + +TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" + +TEST_CONTENT_BINARY = ( + b"Content-Type: application/binary\r\n" + b"Content-Transfer-Encoding: base64\r\n" + b"\r\n" + b"VGVzdCBib2R5\r\n" +) + +TEST_CONTENT_TEXT_PLAIN = ( b"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n" b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" ) +TEST_CONTENT_TEXT_OTHER = ( + b"Content-Type: text/other; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" +) + +TEST_CONTENT_HTML = ( + b"Content-Type: text/html; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: 7bit\r\n" + b"\r\n" + b"\r\n" + b" \r\n" + b' \r\n' + b" \r\n" + b" \r\n" + b"

Test body
\r\n" + b"

\r\n" + b" \r\n" + b"\r\n" + b"\r\n" +) + +TEST_CONTENT_MULTIPART = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + TEST_CONTENT_TEXT_PLAIN + + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + TEST_CONTENT_HTML + + b"--------------McwBciN2C0o3rWeF1tmFo2oI--\r\n" +) + EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) BAD_SEARCH_RESPONSE = ("BAD", [b"", b"Unexpected error"]) TEST_SEARCH_RESPONSE = ("OK", [b"1", b"Search completed (0.0001 + 0.000 secs)."]) -TEST_FETCH_RESPONSE = ( +TEST_FETCH_RESPONSE_TEXT_BARE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_BARE)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_BARE), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_TEXT_PLAIN = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_TEXT_OTHER = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_OTHER)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_OTHER), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_BINARY = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_BINARY)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_BINARY), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_HTML = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_HTML)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_HTML), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_MULTIPART = ( "OK", [ - b"1 FETCH (BODY[] {1518}", - bytearray(TEST_MESSAGE), + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_MULTIPART)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_MULTIPART), b")", b"Fetch completed (0.0001 + 0.000 secs).", ], diff --git a/tests/components/imap/test_coordinator.py b/tests/components/imap/test_coordinator.py index 5d8c2f63db4e..a8db2bf2fe8d 100644 --- a/tests/components/imap/test_coordinator.py +++ b/tests/components/imap/test_coordinator.py @@ -1,27 +1,47 @@ """Test the imap coordinator.""" - +import asyncio from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch +from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException import pytest from homeassistant.components.imap import DOMAIN +from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder from homeassistant.core import Event, HomeAssistant from homeassistant.util.dt import utcnow -from .conftest import AUTH, NONAUTH, SELECTED -from .const import TEST_FETCH_RESPONSE, TEST_SEARCH_RESPONSE +from .const import ( + BAD_SEARCH_RESPONSE, + TEST_FETCH_RESPONSE_BINARY, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_SEARCH_RESPONSE, +) from .test_config_flow import MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( - ("imap_search", "imap_fetch"), [(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE)] + "imap_fetch", + [ + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_BINARY, + ], + ids=["bare", "plain", "other", "html", "multipart", "binary"], ) @pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock] + hass: HomeAssistant, mock_imap_protocol: MagicMock ) -> None: """Test receiving a message successfully.""" event_called = MagicMock() @@ -56,7 +76,7 @@ async def _async_event_listener(event: Event) -> None: ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] ) async def test_initial_authentication_error( - hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock], success: bool + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool ) -> None: """Test authentication error.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) @@ -70,10 +90,99 @@ async def test_initial_authentication_error( ("imap_select_state", "success"), [(AUTH, False), (SELECTED, True)] ) async def test_initial_invalid_folder_error( - hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock], success: bool + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool ) -> None: """Test receiving a message successfully.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) == success await hass.async_block_till_done() + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize("imap_search", [BAD_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + (InvalidAuth, "Username or password incorrect, starting reauthentication"), + (InvalidFolder, "Selected mailbox folder is invalid"), + ], +) +async def test_late_authentication_or_invalid_folder_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_capabilities: set[str], + exception: InvalidAuth | InvalidFolder, + error_message: str, +) -> None: + """Test authentication error after search was failed.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + if imap_capabilities == set(): + # Avoid first refresh when polling to avoid a failing entry setup + with patch( + "homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_config_entry_first_refresh" + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + else: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + with patch( + "homeassistant.components.imap.coordinator.connect_to_server", + side_effect=exception, + ): + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert error_message in caplog.text + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize( + "imap_close", + [ + AsyncMock(side_effect=AioImapException("Something went wrong")), + AsyncMock(side_effect=asyncio.TimeoutError), + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_handle_cleanup_exception( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert "Error while cleaning up imap connection" in caplog.text + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}], ids=["push"]) +@pytest.mark.parametrize( + "imap_wait_server_push", + [ + AsyncMock(side_effect=AioImapException("Something went wrong")), + AsyncMock(side_effect=asyncio.TimeoutError), + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_last_connection_with_imap_push( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 665cf9a62d41..e6fbf6839693 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,7 +1,7 @@ """Test the imap entry initialization.""" import asyncio -from unittest.mock import AsyncMock, patch +from unittest.mock import MagicMock, patch import pytest @@ -16,7 +16,7 @@ @pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) async def test_entry_startup_and_unload( - hass: HomeAssistant, mock_imap_protocol: dict[str, AsyncMock] + hass: HomeAssistant, mock_imap_protocol: MagicMock ) -> None: """Test imap entry startup and unload with push and polling coordinator.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) @@ -36,7 +36,7 @@ async def test_entry_startup_and_unload( ) async def test_entry_startup_fails( hass: HomeAssistant, - mock_imap_protocol: dict[str, AsyncMock], + mock_imap_protocol: MagicMock, effect: Exception, ) -> None: """Test imap entry startup fails on invalid auth or folder.""" From 409aca031ee06a9a65204cf6de49a3b3921a5fab Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 27 Mar 2023 15:27:36 +0000 Subject: [PATCH 08/13] Fix merging error --- tests/components/imap/conftest.py | 1 + tests/components/imap/test_coordinator.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index fe2da882cf9a..2f4efa4fa1c6 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -18,6 +18,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup_entry +@pytest.fixture def imap_capabilities() -> Generator[set[str], None]: """Fixture to set the imap capabilities.""" return {"IDLE"} diff --git a/tests/components/imap/test_coordinator.py b/tests/components/imap/test_coordinator.py index a8db2bf2fe8d..950f2357dc6b 100644 --- a/tests/components/imap/test_coordinator.py +++ b/tests/components/imap/test_coordinator.py @@ -1,6 +1,7 @@ """Test the imap coordinator.""" import asyncio from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException @@ -69,6 +70,14 @@ async def _async_event_listener(event: Event) -> None: # we should have received one event event_called.assert_called_once() + data: dict[str, Any] = event_called.call_args[0][0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] @pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) From f1c0e01b138ec300cb6f5c548f95af9f291ac572 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 28 Mar 2023 06:33:24 +0000 Subject: [PATCH 09/13] move tests and use event helper --- tests/components/imap/test_coordinator.py | 197 ---------------------- tests/components/imap/test_init.py | 180 +++++++++++++++++++- 2 files changed, 177 insertions(+), 200 deletions(-) delete mode 100644 tests/components/imap/test_coordinator.py diff --git a/tests/components/imap/test_coordinator.py b/tests/components/imap/test_coordinator.py deleted file mode 100644 index 950f2357dc6b..000000000000 --- a/tests/components/imap/test_coordinator.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Test the imap coordinator.""" -import asyncio -from datetime import timedelta -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException -import pytest - -from homeassistant.components.imap import DOMAIN -from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder -from homeassistant.core import Event, HomeAssistant -from homeassistant.util.dt import utcnow - -from .const import ( - BAD_SEARCH_RESPONSE, - TEST_FETCH_RESPONSE_BINARY, - TEST_FETCH_RESPONSE_HTML, - TEST_FETCH_RESPONSE_MULTIPART, - TEST_FETCH_RESPONSE_TEXT_BARE, - TEST_FETCH_RESPONSE_TEXT_OTHER, - TEST_FETCH_RESPONSE_TEXT_PLAIN, - TEST_SEARCH_RESPONSE, -) -from .test_config_flow import MOCK_CONFIG - -from tests.common import MockConfigEntry, async_fire_time_changed - - -@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) -@pytest.mark.parametrize( - "imap_fetch", - [ - TEST_FETCH_RESPONSE_TEXT_BARE, - TEST_FETCH_RESPONSE_TEXT_PLAIN, - TEST_FETCH_RESPONSE_TEXT_OTHER, - TEST_FETCH_RESPONSE_HTML, - TEST_FETCH_RESPONSE_MULTIPART, - TEST_FETCH_RESPONSE_BINARY, - ], - ids=["bare", "plain", "other", "html", "multipart", "binary"], -) -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) -async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock -) -> None: - """Test receiving a message successfully.""" - event_called = MagicMock() - - async def _async_event_listener(event: Event) -> None: - """Listen to events.""" - event_called(event) - - handler = hass.bus.async_listen("imap_content", _async_event_listener) - - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Make sure we have had one update (when polling) - async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) - await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") - # we should have received one message - assert state is not None - assert state.state == "1" - - # cleanup event listener - handler() - - # we should have received one event - event_called.assert_called_once() - data: dict[str, Any] = event_called.call_args[0][0].data - assert data["server"] == "imap.server.com" - assert data["username"] == "email@email.com" - assert data["search"] == "UnSeen UnDeleted" - assert data["folder"] == "INBOX" - assert data["sender"] == "john.doe@example.com" - assert data["subject"] == "Test subject" - assert data["text"] - - -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) -@pytest.mark.parametrize( - ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] -) -async def test_initial_authentication_error( - hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool -) -> None: - """Test authentication error.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) == success - await hass.async_block_till_done() - - -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) -@pytest.mark.parametrize( - ("imap_select_state", "success"), [(AUTH, False), (SELECTED, True)] -) -async def test_initial_invalid_folder_error( - hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool -) -> None: - """Test receiving a message successfully.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) == success - await hass.async_block_till_done() - - -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) -@pytest.mark.parametrize("imap_search", [BAD_SEARCH_RESPONSE]) -@pytest.mark.parametrize( - ("exception", "error_message"), - [ - (InvalidAuth, "Username or password incorrect, starting reauthentication"), - (InvalidFolder, "Selected mailbox folder is invalid"), - ], -) -async def test_late_authentication_or_invalid_folder_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mock_imap_protocol: MagicMock, - imap_capabilities: set[str], - exception: InvalidAuth | InvalidFolder, - error_message: str, -) -> None: - """Test authentication error after search was failed.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) - config_entry.add_to_hass(hass) - - if imap_capabilities == set(): - # Avoid first refresh when polling to avoid a failing entry setup - with patch( - "homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_config_entry_first_refresh" - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - else: - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Make sure we have had at least one update (when polling) - async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) - await hass.async_block_till_done() - with patch( - "homeassistant.components.imap.coordinator.connect_to_server", - side_effect=exception, - ): - # Make sure we have had at least one update (when polling) - async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) - await hass.async_block_till_done() - assert error_message in caplog.text - - -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) -@pytest.mark.parametrize( - "imap_close", - [ - AsyncMock(side_effect=AioImapException("Something went wrong")), - AsyncMock(side_effect=asyncio.TimeoutError), - ], - ids=["AioImapException", "TimeoutError"], -) -async def test_handle_cleanup_exception( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock -) -> None: - """Test receiving a message successfully.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Make sure we have had one update (when polling) - async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) - await hass.async_block_till_done() - assert await config_entry.async_unload(hass) - await hass.async_block_till_done() - assert "Error while cleaning up imap connection" in caplog.text - - -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}], ids=["push"]) -@pytest.mark.parametrize( - "imap_wait_server_push", - [ - AsyncMock(side_effect=AioImapException("Something went wrong")), - AsyncMock(side_effect=asyncio.TimeoutError), - ], - ids=["AioImapException", "TimeoutError"], -) -async def test_last_connection_with_imap_push( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock -) -> None: - """Test receiving a message successfully.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index e6fbf6839693..80c16dcdd3f0 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,17 +1,30 @@ """Test the imap entry initialization.""" - import asyncio -from unittest.mock import MagicMock, patch +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException import pytest from homeassistant.components.imap import DOMAIN from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow +from .const import ( + BAD_SEARCH_RESPONSE, + TEST_FETCH_RESPONSE_BINARY, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_SEARCH_RESPONSE, +) from .test_config_flow import MOCK_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed @pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) @@ -48,3 +61,164 @@ async def test_entry_startup_fails( side_effect=effect, ): assert await hass.config_entries.async_setup(config_entry.entry_id) is False + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + "imap_fetch", + [ + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_BINARY, + ], + ids=["bare", "plain", "other", "html", "multipart", "binary"], +) +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +async def test_receiving_message_successfully( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] +) +async def test_initial_authentication_error( + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool +) -> None: + """Test authentication error when starting the entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_select_state", "success"), [(AUTH, False), (SELECTED, True)] +) +async def test_initial_invalid_folder_error( + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool +) -> None: + """Test invalid folder error when starting the entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize("imap_search", [BAD_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + (InvalidAuth, "Username or password incorrect, starting reauthentication"), + (InvalidFolder, "Selected mailbox folder is invalid"), + ], +) +async def test_late_authentication_or_invalid_folder_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_capabilities: set[str], + exception: InvalidAuth | InvalidFolder, + error_message: str, +) -> None: + """Test authentication and invalid folder error after search was failed.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + if imap_capabilities == set(): + # Avoid first refresh when polling to avoid a failing entry setup + with patch( + "homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_config_entry_first_refresh" + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + else: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + with patch( + "homeassistant.components.imap.coordinator.connect_to_server", + side_effect=exception, + ): + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert error_message in caplog.text + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize( + "imap_close", + [ + AsyncMock(side_effect=AioImapException("Something went wrong")), + AsyncMock(side_effect=asyncio.TimeoutError), + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_handle_cleanup_exception( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock +) -> None: + """Test handling an excepton during cleaning up.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert "Error while cleaning up imap connection" in caplog.text + + +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}], ids=["push"]) +@pytest.mark.parametrize( + "imap_wait_server_push", + [ + AsyncMock(side_effect=AioImapException("Something went wrong")), + AsyncMock(side_effect=asyncio.TimeoutError), + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_lost_connection_with_imap_push( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock +) -> None: + """Test error handling when the connection is lost.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text From 3b19cde2db593b9522661967c3e4d34fd2335cfc Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 28 Mar 2023 07:22:39 +0000 Subject: [PATCH 10/13] move tests --- tests/components/imap/test_init.py | 60 ++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 80c16dcdd3f0..0a0c1ed928e4 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -135,7 +135,7 @@ async def test_initial_invalid_folder_error( await hass.async_block_till_done() -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}], ids=["push"]) @pytest.mark.parametrize("imap_search", [BAD_SEARCH_RESPONSE]) @pytest.mark.parametrize( ("exception", "error_message"), @@ -144,7 +144,7 @@ async def test_initial_invalid_folder_error( (InvalidFolder, "Selected mailbox folder is invalid"), ], ) -async def test_late_authentication_or_invalid_folder_error( +async def test_late_authentication_or_invalid_folder_error_push( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock, @@ -152,18 +152,56 @@ async def test_late_authentication_or_invalid_folder_error( exception: InvalidAuth | InvalidFolder, error_message: str, ) -> None: - """Test authentication and invalid folder error after search was failed.""" + """Test authentication and invalid folder error after search was failed. + + Asserting the IMAP push coordinator. + """ config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) - if imap_capabilities == set(): - # Avoid first refresh when polling to avoid a failing entry setup - with patch( - "homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_config_entry_first_refresh" - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - else: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + with patch( + "homeassistant.components.imap.coordinator.connect_to_server", + side_effect=exception, + ): + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert error_message in caplog.text + + +@pytest.mark.parametrize("imap_capabilities", [set()], ids=["poll"]) +@pytest.mark.parametrize("imap_search", [BAD_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + (InvalidAuth, "Username or password incorrect, starting reauthentication"), + (InvalidFolder, "Selected mailbox folder is invalid"), + ], +) +async def test_late_authentication_or_invalid_folder_error_poll( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_capabilities: set[str], + exception: InvalidAuth | InvalidFolder, + error_message: str, +) -> None: + """Test authentication and invalid folder error after search was failed. + + Asserting the IMAP poll coordinator. + """ + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + # Avoid first refresh when polling to avoid a failing entry setup + with patch( + "homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_config_entry_first_refresh" + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # Make sure we have had at least one update (when polling) From e731f4aa6db92a55cf363080f5f1d423820bc615 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 28 Mar 2023 14:43:48 +0000 Subject: [PATCH 11/13] use autospec --- tests/components/imap/conftest.py | 108 +++++++------------- tests/components/imap/const.py | 4 +- tests/components/imap/test_init.py | 157 +++++++++++++++++------------ 3 files changed, 127 insertions(+), 142 deletions(-) diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index 2f4efa4fa1c6..74176efab111 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -19,132 +19,94 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def imap_capabilities() -> Generator[set[str], None]: +def imap_has_capability() -> bool: """Fixture to set the imap capabilities.""" - return {"IDLE"} + return True @pytest.fixture -def imap_login_state() -> Generator[str, None]: +def imap_login_state() -> str: """Fixture to set the imap state after login.""" return AUTH @pytest.fixture -def imap_select_state() -> Generator[str, None]: +def imap_select_state() -> str: """Fixture to set the imap capabilities.""" return SELECTED @pytest.fixture -def imap_search() -> Generator[tuple[str, list[bytes]], None]: +def imap_search() -> tuple[str, list[bytes]]: """Fixture to set the imap search response.""" return EMPTY_SEARCH_RESPONSE @pytest.fixture -def imap_fetch() -> Generator[tuple[str, list[bytes | bytearray]], None]: +def imap_fetch() -> tuple[str, list[bytes | bytearray]]: """Fixture to set the imap fetch response.""" return TEST_FETCH_RESPONSE_TEXT_PLAIN @pytest.fixture -def imap_pending_idle() -> Generator[bool, None]: +def imap_pending_idle() -> bool: """Fixture to set the imap pending idle feature.""" return True -@pytest.fixture -def imap_close() -> Generator[AsyncMock, None]: - """Fixture to mock a side_effect the imap client close method.""" - return AsyncMock() - - -@pytest.fixture -def imap_wait_server_push() -> Generator[AsyncMock, None]: - """Fixture to mock the imap client wait_server_push method.""" - return AsyncMock() - - @pytest.fixture async def mock_imap_protocol( imap_search: tuple[str, list[bytes]], imap_fetch: tuple[str, list[bytes | bytearray]], - imap_capabilities: set[str], + imap_has_capability: bool, imap_pending_idle: bool, imap_login_state: str, imap_select_state: str, - imap_wait_server_push: AsyncMock, - imap_close: AsyncMock, ) -> Generator[MagicMock, None]: """Mock the aioimaplib IMAP protocol handler.""" - class IMAP4ClientMock: - """Mock for IMAP4 client.""" - - class IMAP4ClientProtocolMock: - """Mock the IMAP4 client protocol.""" - - state: str = STARTED - - @property - def capabilities(self) -> set[str]: - """Mock the capabilities.""" - return imap_capabilities - - def __init__(self, *args, **kwargs) -> None: - self._state = STARTED - self.wait_server_push = imap_wait_server_push - self.noop = AsyncMock() - self.has_pending_idle = MagicMock(return_value=imap_pending_idle) - self.idle_start = AsyncMock() - self.idle_done = MagicMock() - self.stop_wait_server_push = AsyncMock() - self.protocol = self.IMAP4ClientProtocolMock() - - def has_capability(self, capability: str) -> bool: - """Check capability.""" - return capability in self.protocol.capabilities + with patch( + "homeassistant.components.imap.coordinator.IMAP4_SSL", autospec=True + ) as imap_mock: + imap_mock = imap_mock.return_value - async def login(self, user: str, password: str) -> Response: + async def login(user: str, password: str) -> Response: """Mock imap login.""" - self.protocol.state = imap_login_state + imap_mock.protocol.state = imap_login_state if imap_login_state != AUTH: return Response("BAD", []) return Response("OK", [b"CAPABILITY IMAP4rev1 ...", b"Logged in"]) - async def close(self) -> Response: + async def close() -> Response: """Mock imap close the selected folder.""" - await imap_close() - self.protocol.state = imap_login_state + imap_mock.protocol.state = imap_login_state return Response("OK", []) - async def logout(self) -> Response: + async def logout() -> Response: """Mock imap logout.""" - self.protocol.state = LOGOUT + imap_mock.protocol.state = LOGOUT return Response("OK", []) - async def select(self, mailbox: str = "INBOX") -> Response: + async def select(mailbox: str = "INBOX") -> Response: """Mock imap folder select.""" - self.protocol.state = imap_select_state + imap_mock.protocol.state = imap_select_state if imap_login_state != SELECTED: return Response("BAD", []) return Response("OK", []) - async def search(self, *criteria: str, charset: str = "utf-8") -> Response: - """Mock imap search.""" - return Response(*imap_search) - - async def fetch(self, message_set: str, message_parts: str) -> Response: - """Mock imap fetch.""" - return Response(*imap_fetch) - - async def wait_hello_from_server(self) -> None: + async def wait_hello_from_server() -> None: """Mock wait for hello.""" - self.protocol.state = NONAUTH - - with patch( - "homeassistant.components.imap.coordinator.IMAP4_SSL", - side_effect=IMAP4ClientMock, - ) as protocol: - yield protocol + imap_mock.protocol.state = NONAUTH + + imap_mock.has_pending_idle.return_value = imap_pending_idle + imap_mock.protocol = MagicMock() + imap_mock.protocol.state = STARTED + imap_mock.has_capability.return_value = imap_has_capability + imap_mock.login.side_effect = login + imap_mock.close.side_effect = close + imap_mock.logout.side_effect = logout + imap_mock.select.side_effect = select + imap_mock.search.return_value = Response(*imap_search) + imap_mock.fetch.return_value = Response(*imap_fetch) + imap_mock.wait_hello_from_server.side_effect = wait_hello_from_server + yield imap_mock diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 7c1c929e6ebb..68fab7d38cbb 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -60,7 +60,7 @@ ) EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) -BAD_SEARCH_RESPONSE = ("BAD", [b"", b"Unexpected error"]) +BAD_RESPONSE = ("BAD", [b"", b"Unexpected error"]) TEST_SEARCH_RESPONSE = ("OK", [b"1", b"Search completed (0.0001 + 0.000 secs)."]) @@ -136,4 +136,4 @@ ], ) -BAD_FETCH_RESPONSE = ("BAD", []) +RESPONSE_BAD = ("BAD", []) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 0a0c1ed928e4..45262b28a8d2 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException +from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response import pytest from homeassistant.components.imap import DOMAIN @@ -13,7 +13,7 @@ from homeassistant.util.dt import utcnow from .const import ( - BAD_SEARCH_RESPONSE, + BAD_RESPONSE, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, TEST_FETCH_RESPONSE_MULTIPART, @@ -27,7 +27,7 @@ from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_entry_startup_and_unload( hass: HomeAssistant, mock_imap_protocol: MagicMock ) -> None: @@ -76,7 +76,7 @@ async def test_entry_startup_fails( ], ids=["bare", "plain", "other", "html", "multipart", "binary"], ) -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_receiving_message_successfully( hass: HomeAssistant, mock_imap_protocol: MagicMock ) -> None: @@ -107,7 +107,7 @@ async def test_receiving_message_successfully( assert data["text"] -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize( ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] ) @@ -121,7 +121,7 @@ async def test_initial_authentication_error( await hass.async_block_till_done() -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize( ("imap_select_state", "success"), [(AUTH, False), (SELECTED, True)] ) @@ -135,89 +135,81 @@ async def test_initial_invalid_folder_error( await hass.async_block_till_done() -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}], ids=["push"]) -@pytest.mark.parametrize("imap_search", [BAD_SEARCH_RESPONSE]) -@pytest.mark.parametrize( - ("exception", "error_message"), - [ - (InvalidAuth, "Username or password incorrect, starting reauthentication"), - (InvalidFolder, "Selected mailbox folder is invalid"), - ], -) -async def test_late_authentication_or_invalid_folder_error_push( +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_late_authentication_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock, - imap_capabilities: set[str], - exception: InvalidAuth | InvalidFolder, - error_message: str, ) -> None: - """Test authentication and invalid folder error after search was failed. + """Test authentication error handling after a search was failed.""" + + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = AioImapException( + "Something went wrong" + ) - Asserting the IMAP push coordinator. - """ config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) await hass.async_block_till_done() - # Make sure we have had at least one update (when polling) + + # Mock that the search fails, this will trigger + # that the connection will be restarted + # Then fail selecting the folder + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + mock_imap_protocol.login.side_effect = Response(*BAD_RESPONSE) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) await hass.async_block_till_done() - with patch( - "homeassistant.components.imap.coordinator.connect_to_server", - side_effect=exception, - ): - # Make sure we have had at least one update (when polling) - async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) - await hass.async_block_till_done() - assert error_message in caplog.text + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert "Username or password incorrect, starting reauthentication" in caplog.text -@pytest.mark.parametrize("imap_capabilities", [set()], ids=["poll"]) -@pytest.mark.parametrize("imap_search", [BAD_SEARCH_RESPONSE]) -@pytest.mark.parametrize( - ("exception", "error_message"), - [ - (InvalidAuth, "Username or password incorrect, starting reauthentication"), - (InvalidFolder, "Selected mailbox folder is invalid"), - ], -) -async def test_late_authentication_or_invalid_folder_error_poll( +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_late_folder_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock, - imap_capabilities: set[str], - exception: InvalidAuth | InvalidFolder, - error_message: str, ) -> None: - """Test authentication and invalid folder error after search was failed. + """Test invalid folder error handling after a search was failed. - Asserting the IMAP poll coordinator. + Asserting the IMAP push coordinator. """ + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = AioImapException( + "Something went wrong" + ) + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) - # Avoid first refresh when polling to avoid a failing entry setup - with patch( - "homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_config_entry_first_refresh" - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had at least one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) await hass.async_block_till_done() - with patch( - "homeassistant.components.imap.coordinator.connect_to_server", - side_effect=exception, - ): - # Make sure we have had at least one update (when polling) - async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) - await hass.async_block_till_done() - assert error_message in caplog.text + # Mock that the search fails, this will trigger + # that the connection will be restarted + # Then fail selecting the folder + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + mock_imap_protocol.select.side_effect = Response(*BAD_RESPONSE) -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}, set()], ids=["push", "poll"]) + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert "Selected mailbox folder is invalid" in caplog.text + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize( "imap_close", [ @@ -227,7 +219,10 @@ async def test_late_authentication_or_invalid_folder_error_poll( ids=["AioImapException", "TimeoutError"], ) async def test_handle_cleanup_exception( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_close: Exception, ) -> None: """Test handling an excepton during cleaning up.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) @@ -237,26 +232,54 @@ async def test_handle_cleanup_exception( # Make sure we have had one update (when polling) async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() + + # Fail cleaning up + mock_imap_protocol.close.side_effect = imap_close + assert await config_entry.async_unload(hass) await hass.async_block_till_done() assert "Error while cleaning up imap connection" in caplog.text -@pytest.mark.parametrize("imap_capabilities", [{"IDLE"}], ids=["push"]) +@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) @pytest.mark.parametrize( - "imap_wait_server_push", + "imap_wait_server_push_exception", [ - AsyncMock(side_effect=AioImapException("Something went wrong")), - AsyncMock(side_effect=asyncio.TimeoutError), + AioImapException("Something went wrong"), + asyncio.TimeoutError, ], ids=["AioImapException", "TimeoutError"], ) async def test_lost_connection_with_imap_push( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_imap_protocol: MagicMock + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_wait_server_push_exception: AioImapException | asyncio.TimeoutError, ) -> None: """Test error handling when the connection is lost.""" + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text + + +@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) +async def test_fetch_number_of_messages( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test _async_fetch_number_of_messages fails with push coordinator.""" + # Mock an error in waiting for a pushed update + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we wait for the backoff time + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert "Invalid response for search" in caplog.text From 7619c6949131c30fa1affb1f6a00b1f1843adc08 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 28 Mar 2023 15:26:36 +0000 Subject: [PATCH 12/13] Assert the entity state --- tests/components/imap/test_init.py | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 45262b28a8d2..d9d2655beff3 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -9,6 +9,7 @@ from homeassistant.components.imap import DOMAIN from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow @@ -120,6 +121,9 @@ async def test_initial_authentication_error( assert await hass.config_entries.async_setup(config_entry.entry_id) == success await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + assert (state is not None) == success + @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize( @@ -134,6 +138,9 @@ async def test_initial_invalid_folder_error( assert await hass.config_entries.async_setup(config_entry.entry_id) == success await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + assert (state is not None) == success + @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_late_authentication_error( @@ -156,6 +163,11 @@ async def test_late_authentication_error( async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity + assert state is not None + assert state.state == "0" + # Mock that the search fails, this will trigger # that the connection will be restarted # Then fail selecting the folder @@ -169,6 +181,10 @@ async def test_late_authentication_error( await hass.async_block_till_done() assert "Username or password incorrect, starting reauthentication" in caplog.text + # we still should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_late_folder_error( @@ -195,6 +211,11 @@ async def test_late_folder_error( async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity + assert state is not None + assert state.state == "0" + # Mock that the search fails, this will trigger # that the connection will be restarted # Then fail selecting the folder @@ -208,6 +229,10 @@ async def test_late_folder_error( await hass.async_block_till_done() assert "Selected mailbox folder is invalid" in caplog.text + # we still should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @pytest.mark.parametrize( @@ -233,6 +258,11 @@ async def test_handle_cleanup_exception( async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity + assert state is not None + assert state.state == "0" + # Fail cleaning up mock_imap_protocol.close.side_effect = imap_close @@ -240,6 +270,12 @@ async def test_handle_cleanup_exception( await hass.async_block_till_done() assert "Error while cleaning up imap connection" in caplog.text + state = hass.states.get("sensor.imap_email_email_com") + + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + @pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) @pytest.mark.parametrize( @@ -265,6 +301,11 @@ async def test_lost_connection_with_imap_push( await hass.async_block_till_done() assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + @pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) async def test_fetch_number_of_messages( @@ -283,3 +324,8 @@ async def test_fetch_number_of_messages( async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert "Invalid response for search" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE From 550cb230b1839c5caf4a1605ef2374c72b6724f5 Mon Sep 17 00:00:00 2001 From: jbouwh Date: Tue, 28 Mar 2023 16:04:29 +0000 Subject: [PATCH 13/13] Fix tests --- tests/components/imap/test_init.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index d9d2655beff3..ec9058830ddc 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -163,11 +163,6 @@ async def test_late_authentication_error( async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") - # we should have an entity - assert state is not None - assert state.state == "0" - # Mock that the search fails, this will trigger # that the connection will be restarted # Then fail selecting the folder @@ -182,6 +177,7 @@ async def test_late_authentication_error( assert "Username or password incorrect, starting reauthentication" in caplog.text # we still should have an entity with an unavailable state + state = hass.states.get("sensor.imap_email_email_com") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -211,11 +207,6 @@ async def test_late_folder_error( async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) await hass.async_block_till_done() - state = hass.states.get("sensor.imap_email_email_com") - # we should have an entity - assert state is not None - assert state.state == "0" - # Mock that the search fails, this will trigger # that the connection will be restarted # Then fail selecting the folder @@ -230,6 +221,7 @@ async def test_late_folder_error( assert "Selected mailbox folder is invalid" in caplog.text # we still should have an entity with an unavailable state + state = hass.states.get("sensor.imap_email_email_com") assert state is not None assert state.state == STATE_UNAVAILABLE