From e45d4d53dd98ac23f138eed57d39bd46be8048fd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 22 Apr 2022 17:44:59 +0200 Subject: [PATCH] Correct time stamp format in Alexa responses (#70267) --- homeassistant/components/alexa/const.py | 4 +- tests/components/alexa/__init__.py | 227 +---------------- tests/components/alexa/test_auth.py | 2 +- tests/components/alexa/test_capabilities.py | 2 +- tests/components/alexa/test_common.py | 228 ++++++++++++++++++ tests/components/alexa/test_entities.py | 2 +- tests/components/alexa/test_smart_home.py | 58 ++++- .../components/alexa/test_smart_home_http.py | 2 +- tests/components/alexa/test_state_report.py | 2 +- 9 files changed, 283 insertions(+), 244 deletions(-) create mode 100644 tests/components/alexa/test_common.py diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 0532c85dac1..6b509d9b3c6 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -28,7 +28,9 @@ ATTR_REDIRECTION_URL = "redirectionURL" SYN_RESOLUTION_MATCH = "ER_SUCCESS_MATCH" -DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.0Z" +# Alexa requires timestamps to be formatted according to ISO 8601, YYYY-MM-DDThh:mm:ssZ +# https://developer.amazon.com/es-ES/docs/alexa/device-apis/alexa-scenecontroller.html#activate-response-event +DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" API_DIRECTIVE = "directive" API_ENDPOINT = "endpoint" diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 1f0854c5102..74d039bda99 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -1,226 +1 @@ -"""Tests for the Alexa integration.""" -import re -from unittest.mock import Mock -from uuid import uuid4 - -from homeassistant.components.alexa import config, smart_home, smart_home_http -from homeassistant.components.alexa.const import CONF_ENDPOINT, CONF_FILTER, CONF_LOCALE -from homeassistant.core import Context, callback -from homeassistant.helpers import entityfilter - -from tests.common import async_mock_service - -TEST_URL = "https://api.amazonalexa.com/v3/events" -TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" -TEST_LOCALE = "en-US" - - -class MockConfig(smart_home_http.AlexaConfig): - """Mock Alexa config.""" - - entity_config = { - "binary_sensor.test_doorbell": {"display_categories": "DOORBELL"}, - "binary_sensor.test_contact_forced": {"display_categories": "CONTACT_SENSOR"}, - "binary_sensor.test_motion_forced": {"display_categories": "MOTION_SENSOR"}, - "binary_sensor.test_motion_camera_event": {"display_categories": "CAMERA"}, - "camera.test": {"display_categories": "CAMERA"}, - } - - def __init__(self, hass): - """Mock Alexa config.""" - super().__init__( - hass, - { - CONF_ENDPOINT: TEST_URL, - CONF_FILTER: entityfilter.FILTER_SCHEMA({}), - CONF_LOCALE: TEST_LOCALE, - }, - ) - self._store = Mock(spec_set=config.AlexaConfigStore) - - @property - def supports_auth(self): - """Return if config supports auth.""" - return True - - @callback - def user_identifier(self): - """Return an identifier for the user that represents this config.""" - return "mock-user-id" - - @callback - def async_invalidate_access_token(self): - """Invalidate access token.""" - - async def async_get_access_token(self): - """Get an access token.""" - return "thisisnotanacesstoken" - - async def async_accept_grant(self, code): - """Accept a grant.""" - - -def get_default_config(hass): - """Return a MockConfig instance.""" - return MockConfig(hass) - - -def get_new_request(namespace, name, endpoint=None): - """Generate a new API message.""" - raw_msg = { - "directive": { - "header": { - "namespace": namespace, - "name": name, - "messageId": str(uuid4()), - "correlationToken": str(uuid4()), - "payloadVersion": "3", - }, - "endpoint": { - "scope": {"type": "BearerToken", "token": str(uuid4())}, - "endpointId": endpoint, - }, - "payload": {}, - } - } - - if not endpoint: - raw_msg["directive"].pop("endpoint") - - return raw_msg - - -async def assert_request_calls_service( - namespace, - name, - endpoint, - service, - hass, - response_type="Response", - payload=None, - instance=None, -): - """Assert an API request calls a hass service.""" - context = Context() - request = get_new_request(namespace, name, endpoint) - if payload: - request["directive"]["payload"] = payload - if instance: - request["directive"]["header"]["instance"] = instance - - domain, service_name = service.split(".") - calls = async_mock_service(hass, domain, service_name) - - msg = await smart_home.async_handle_message( - hass, get_default_config(hass), request, context - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - call = calls[0] - assert "event" in msg - assert call.data["entity_id"] == endpoint.replace("#", ".") - assert msg["event"]["header"]["name"] == response_type - assert call.context == context - - return call, msg - - -async def assert_request_fails( - namespace, name, endpoint, service_not_called, hass, payload=None -): - """Assert an API request returns an ErrorResponse.""" - request = get_new_request(namespace, name, endpoint) - if payload: - request["directive"]["payload"] = payload - - domain, service_name = service_not_called.split(".") - call = async_mock_service(hass, domain, service_name) - - msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) - await hass.async_block_till_done() - - assert not call - assert "event" in msg - assert msg["event"]["header"]["name"] == "ErrorResponse" - - return msg - - -async def assert_power_controller_works(endpoint, on_service, off_service, hass): - """Assert PowerController API requests work.""" - await assert_request_calls_service( - "Alexa.PowerController", "TurnOn", endpoint, on_service, hass - ) - - await assert_request_calls_service( - "Alexa.PowerController", "TurnOff", endpoint, off_service, hass - ) - - -async def assert_scene_controller_works( - endpoint, activate_service, deactivate_service, hass -): - """Assert SceneController API requests work.""" - _, response = await assert_request_calls_service( - "Alexa.SceneController", - "Activate", - endpoint, - activate_service, - hass, - response_type="ActivationStarted", - ) - assert response["event"]["payload"]["cause"]["type"] == "VOICE_INTERACTION" - assert "timestamp" in response["event"]["payload"] - pattern = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.0Z" - assert re.search(pattern, response["event"]["payload"]["timestamp"]) - if deactivate_service: - await assert_request_calls_service( - "Alexa.SceneController", - "Deactivate", - endpoint, - deactivate_service, - hass, - response_type="DeactivationStarted", - ) - cause_type = response["event"]["payload"]["cause"]["type"] - assert cause_type == "VOICE_INTERACTION" - assert "timestamp" in response["event"]["payload"] - assert re.search(pattern, response["event"]["payload"]["timestamp"]) - - -async def reported_properties(hass, endpoint, return_full_response=False): - """Use ReportState to get properties and return them. - - The result is a ReportedProperties instance, which has methods to make - assertions about the properties. - """ - request = get_new_request("Alexa", "ReportState", endpoint) - msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) - await hass.async_block_till_done() - if return_full_response: - return msg - return ReportedProperties(msg["context"]["properties"]) - - -class ReportedProperties: - """Class to help assert reported properties.""" - - def __init__(self, properties): - """Initialize class.""" - self.properties = properties - - def assert_not_has_property(self, namespace, name): - """Assert a property does not exist.""" - for prop in self.properties: - if prop["namespace"] == namespace and prop["name"] == name: - assert False, "Property %s:%s exists" - - def assert_equal(self, namespace, name, value): - """Assert a property is equal to a given value.""" - for prop in self.properties: - if prop["namespace"] == namespace and prop["name"] == name: - assert prop["value"] == value - return prop - - assert False, f"property {namespace}:{name} not in {self.properties!r}" +"""Tests for alexa.""" diff --git a/tests/components/alexa/test_auth.py b/tests/components/alexa/test_auth.py index ae28052efc9..4334df5e9fb 100644 --- a/tests/components/alexa/test_auth.py +++ b/tests/components/alexa/test_auth.py @@ -2,7 +2,7 @@ from homeassistant.components.alexa.auth import Auth from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from . import TEST_TOKEN_URL +from .test_common import TEST_TOKEN_URL async def run_auth_get_access_token( diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 3f76c6bee04..4cccae1f083 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -27,7 +27,7 @@ from homeassistant.const import ( TEMP_CELSIUS, ) -from . import ( +from .test_common import ( assert_request_calls_service, assert_request_fails, get_default_config, diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py new file mode 100644 index 00000000000..da17ce253c3 --- /dev/null +++ b/tests/components/alexa/test_common.py @@ -0,0 +1,228 @@ +"""Test helpers for the Alexa integration.""" +from unittest.mock import Mock +from uuid import uuid4 + +from homeassistant.components.alexa import config, smart_home, smart_home_http +from homeassistant.components.alexa.const import CONF_ENDPOINT, CONF_FILTER, CONF_LOCALE +from homeassistant.core import Context, callback +from homeassistant.helpers import entityfilter + +from tests.common import async_mock_service + +TEST_URL = "https://api.amazonalexa.com/v3/events" +TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" +TEST_LOCALE = "en-US" + + +class MockConfig(smart_home_http.AlexaConfig): + """Mock Alexa config.""" + + entity_config = { + "binary_sensor.test_doorbell": {"display_categories": "DOORBELL"}, + "binary_sensor.test_contact_forced": {"display_categories": "CONTACT_SENSOR"}, + "binary_sensor.test_motion_forced": {"display_categories": "MOTION_SENSOR"}, + "binary_sensor.test_motion_camera_event": {"display_categories": "CAMERA"}, + "camera.test": {"display_categories": "CAMERA"}, + } + + def __init__(self, hass): + """Mock Alexa config.""" + super().__init__( + hass, + { + CONF_ENDPOINT: TEST_URL, + CONF_FILTER: entityfilter.FILTER_SCHEMA({}), + CONF_LOCALE: TEST_LOCALE, + }, + ) + self._store = Mock(spec_set=config.AlexaConfigStore) + + @property + def supports_auth(self): + """Return if config supports auth.""" + return True + + @callback + def user_identifier(self): + """Return an identifier for the user that represents this config.""" + return "mock-user-id" + + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + + async def async_get_access_token(self): + """Get an access token.""" + return "thisisnotanacesstoken" + + async def async_accept_grant(self, code): + """Accept a grant.""" + + +def get_default_config(hass): + """Return a MockConfig instance.""" + return MockConfig(hass) + + +def get_new_request(namespace, name, endpoint=None): + """Generate a new API message.""" + raw_msg = { + "directive": { + "header": { + "namespace": namespace, + "name": name, + "messageId": str(uuid4()), + "correlationToken": str(uuid4()), + "payloadVersion": "3", + }, + "endpoint": { + "scope": {"type": "BearerToken", "token": str(uuid4())}, + "endpointId": endpoint, + }, + "payload": {}, + } + } + + if not endpoint: + raw_msg["directive"].pop("endpoint") + + return raw_msg + + +async def assert_request_calls_service( + namespace, + name, + endpoint, + service, + hass, + response_type="Response", + payload=None, + instance=None, +): + """Assert an API request calls a hass service.""" + context = Context() + request = get_new_request(namespace, name, endpoint) + if payload: + request["directive"]["payload"] = payload + if instance: + request["directive"]["header"]["instance"] = instance + + domain, service_name = service.split(".") + calls = async_mock_service(hass, domain, service_name) + + msg = await smart_home.async_handle_message( + hass, get_default_config(hass), request, context + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert "event" in msg + assert call.data["entity_id"] == endpoint.replace("#", ".") + assert msg["event"]["header"]["name"] == response_type + assert call.context == context + + return call, msg + + +async def assert_request_fails( + namespace, name, endpoint, service_not_called, hass, payload=None +): + """Assert an API request returns an ErrorResponse.""" + request = get_new_request(namespace, name, endpoint) + if payload: + request["directive"]["payload"] = payload + + domain, service_name = service_not_called.split(".") + call = async_mock_service(hass, domain, service_name) + + msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) + await hass.async_block_till_done() + + assert not call + assert "event" in msg + assert msg["event"]["header"]["name"] == "ErrorResponse" + + return msg + + +async def assert_power_controller_works( + endpoint, on_service, off_service, hass, timestamp +): + """Assert PowerController API requests work.""" + _, response = await assert_request_calls_service( + "Alexa.PowerController", "TurnOn", endpoint, on_service, hass + ) + for property in response["context"]["properties"]: + assert property["timeOfSample"] == timestamp + + _, response = await assert_request_calls_service( + "Alexa.PowerController", "TurnOff", endpoint, off_service, hass + ) + for property in response["context"]["properties"]: + assert property["timeOfSample"] == timestamp + + +async def assert_scene_controller_works( + endpoint, activate_service, deactivate_service, hass, timestamp +): + """Assert SceneController API requests work.""" + _, response = await assert_request_calls_service( + "Alexa.SceneController", + "Activate", + endpoint, + activate_service, + hass, + response_type="ActivationStarted", + ) + assert response["event"]["payload"]["cause"]["type"] == "VOICE_INTERACTION" + assert response["event"]["payload"]["timestamp"] == timestamp + if deactivate_service: + _, response = await assert_request_calls_service( + "Alexa.SceneController", + "Deactivate", + endpoint, + deactivate_service, + hass, + response_type="DeactivationStarted", + ) + cause_type = response["event"]["payload"]["cause"]["type"] + assert cause_type == "VOICE_INTERACTION" + assert response["event"]["payload"]["timestamp"] == timestamp + + +async def reported_properties(hass, endpoint, return_full_response=False): + """Use ReportState to get properties and return them. + + The result is a ReportedProperties instance, which has methods to make + assertions about the properties. + """ + request = get_new_request("Alexa", "ReportState", endpoint) + msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) + await hass.async_block_till_done() + if return_full_response: + return msg + return ReportedProperties(msg["context"]["properties"]) + + +class ReportedProperties: + """Class to help assert reported properties.""" + + def __init__(self, properties): + """Initialize class.""" + self.properties = properties + + def assert_not_has_property(self, namespace, name): + """Assert a property does not exist.""" + for prop in self.properties: + if prop["namespace"] == namespace and prop["name"] == name: + assert False, "Property %s:%s exists" + + def assert_equal(self, namespace, name, value): + """Assert a property is equal to a given value.""" + for prop in self.properties: + if prop["namespace"] == namespace and prop["name"] == name: + assert prop["value"] == value + return prop + + assert False, f"property {namespace}:{name} not in {self.properties!r}" diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 7b2a455be92..5f64879b535 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -6,7 +6,7 @@ from homeassistant.const import __version__ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory -from . import get_default_config, get_new_request +from .test_common import get_default_config, get_new_request async def test_unsupported_domain(hass): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 9fb6584fae3..37888a2c415 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.alexa import messages, smart_home @@ -30,7 +31,7 @@ from homeassistant.core import Context from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component -from . import ( +from .test_common import ( MockConfig, ReportedProperties, assert_power_controller_works, @@ -172,6 +173,7 @@ def assert_endpoint_capabilities(endpoint, *interfaces): return capabilities +@freeze_time("2022-04-19 07:53:05") async def test_switch(hass, events): """Test switch discovery.""" device = ("switch.test", "on", {"friendly_name": "Test switch"}) @@ -185,7 +187,7 @@ async def test_switch(hass, events): ) await assert_power_controller_works( - "switch#test", "switch.turn_on", "switch.turn_off", hass + "switch#test", "switch.turn_on", "switch.turn_off", hass, "2022-04-19T07:53:05Z" ) properties = await reported_properties(hass, "switch#test") @@ -209,6 +211,7 @@ async def test_outlet(hass, events): ) +@freeze_time("2022-04-19 07:53:05") async def test_light(hass): """Test light discovery.""" device = ("light.test_1", "on", {"friendly_name": "Test light 1"}) @@ -222,7 +225,7 @@ async def test_light(hass): ) await assert_power_controller_works( - "light#test_1", "light.turn_on", "light.turn_off", hass + "light#test_1", "light.turn_on", "light.turn_off", hass, "2022-04-19T07:53:05Z" ) @@ -302,6 +305,7 @@ async def test_color_light(hass, supported_color_modes): # tests +@freeze_time("2022-04-19 07:53:05") async def test_script(hass): """Test script discovery.""" device = ("script.test", "off", {"friendly_name": "Test script"}) @@ -318,10 +322,11 @@ async def test_script(hass): assert scene_capability["supportsDeactivation"] await assert_scene_controller_works( - "script#test", "script.turn_on", "script.turn_off", hass + "script#test", "script.turn_on", "script.turn_off", hass, "2022-04-19T07:53:05Z" ) +@freeze_time("2022-04-19 07:53:05") async def test_input_boolean(hass): """Test input boolean discovery.""" device = ("input_boolean.test", "off", {"friendly_name": "Test input boolean"}) @@ -335,10 +340,15 @@ async def test_input_boolean(hass): ) await assert_power_controller_works( - "input_boolean#test", "input_boolean.turn_on", "input_boolean.turn_off", hass + "input_boolean#test", + "input_boolean.turn_on", + "input_boolean.turn_off", + hass, + "2022-04-19T07:53:05Z", ) +@freeze_time("2022-04-19 07:53:05") async def test_scene(hass): """Test scene discovery.""" device = ("scene.test", "off", {"friendly_name": "Test scene"}) @@ -354,9 +364,12 @@ async def test_scene(hass): scene_capability = get_capability(capabilities, "Alexa.SceneController") assert not scene_capability["supportsDeactivation"] - await assert_scene_controller_works("scene#test", "scene.turn_on", None, hass) + await assert_scene_controller_works( + "scene#test", "scene.turn_on", None, hass, "2022-04-19T07:53:05Z" + ) +@freeze_time("2022-04-19 07:53:05") async def test_fan(hass): """Test fan discovery.""" device = ("fan.test_1", "off", {"friendly_name": "Test fan 1"}) @@ -379,7 +392,7 @@ async def test_fan(hass): assert "configuration" not in power_capability await assert_power_controller_works( - "fan#test_1", "fan.turn_on", "fan.turn_off", hass + "fan#test_1", "fan.turn_on", "fan.turn_off", hass, "2022-04-19T07:53:05Z" ) await assert_request_calls_service( @@ -936,6 +949,7 @@ async def test_lock(hass): assert properties["value"] == "UNLOCKED" +@freeze_time("2022-04-19 07:53:05") async def test_media_player(hass): """Test media player discovery.""" device = ( @@ -984,7 +998,11 @@ async def test_media_player(hass): assert operation in supported_operations await assert_power_controller_works( - "media_player#test", "media_player.turn_on", "media_player.turn_off", hass + "media_player#test", + "media_player.turn_on", + "media_player.turn_off", + hass, + "2022-04-19T07:53:05Z", ) await assert_request_calls_service( @@ -1532,6 +1550,7 @@ async def test_media_player_seek_error(hass): assert msg["payload"]["type"] == "ACTION_NOT_PERMITTED_FOR_CONTENT" +@freeze_time("2022-04-19 07:53:05") async def test_alert(hass): """Test alert discovery.""" device = ("alert.test", "off", {"friendly_name": "Test alert"}) @@ -1545,10 +1564,11 @@ async def test_alert(hass): ) await assert_power_controller_works( - "alert#test", "alert.turn_on", "alert.turn_off", hass + "alert#test", "alert.turn_on", "alert.turn_off", hass, "2022-04-19T07:53:05Z" ) +@freeze_time("2022-04-19 07:53:05") async def test_automation(hass): """Test automation discovery.""" device = ("automation.test", "off", {"friendly_name": "Test automation"}) @@ -1562,10 +1582,15 @@ async def test_automation(hass): ) await assert_power_controller_works( - "automation#test", "automation.turn_on", "automation.turn_off", hass + "automation#test", + "automation.turn_on", + "automation.turn_off", + hass, + "2022-04-19T07:53:05Z", ) +@freeze_time("2022-04-19 07:53:05") async def test_group(hass): """Test group discovery.""" device = ("group.test", "off", {"friendly_name": "Test group"}) @@ -1579,7 +1604,11 @@ async def test_group(hass): ) await assert_power_controller_works( - "group#test", "homeassistant.turn_on", "homeassistant.turn_off", hass + "group#test", + "homeassistant.turn_on", + "homeassistant.turn_off", + hass, + "2022-04-19T07:53:05Z", ) @@ -3955,6 +3984,7 @@ async def test_initialize_camera_stream(hass, mock_camera, mock_stream): ) +@freeze_time("2022-04-19 07:53:05") @pytest.mark.parametrize( "domain", ["button", "input_button"], @@ -3979,7 +4009,11 @@ async def test_button(hass, domain): assert scene_capability["supportsDeactivation"] is False await assert_scene_controller_works( - f"{domain}#ring_doorbell", f"{domain}.press", False, hass + f"{domain}#ring_doorbell", + f"{domain}.press", + False, + hass, + "2022-04-19T07:53:05Z", ) diff --git a/tests/components/alexa/test_smart_home_http.py b/tests/components/alexa/test_smart_home_http.py index 650a8523f89..098458df006 100644 --- a/tests/components/alexa/test_smart_home_http.py +++ b/tests/components/alexa/test_smart_home_http.py @@ -6,7 +6,7 @@ from homeassistant.components.alexa import DOMAIN, smart_home_http from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.setup import async_setup_component -from . import get_new_request +from .test_common import get_new_request async def do_http_discovery(config, hass, hass_client): diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index bd47a80c18c..b2d36b9d179 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -8,7 +8,7 @@ import pytest from homeassistant import core from homeassistant.components.alexa import errors, state_report -from . import TEST_URL, get_default_config +from .test_common import TEST_URL, get_default_config async def test_report_state(hass, aioclient_mock):