Refactor Twitch tests (#114330)

Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>
This commit is contained in:
Joost Lekkerkerker 2024-05-27 10:09:12 +02:00 committed by GitHub
parent 21b9a4ef2e
commit 3d2ecd6a28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 158 additions and 323 deletions

View File

@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from err
access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]
client = await Twitch(
client = Twitch(
app_id=implementation.client_id,
authenticate_app=False,
)

View File

@ -50,7 +50,7 @@ class OAuth2FlowHandler(
self.flow_impl,
)
client = await Twitch(
client = Twitch(
app_id=implementation.client_id,
authenticate_app=False,
)

View File

@ -1,246 +1,55 @@
"""Tests for the Twitch component."""
import asyncio
from collections.abc import AsyncGenerator, AsyncIterator
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Generic, TypeVar
from twitchAPI.object.api import FollowedChannelsResult, TwitchUser
from twitchAPI.twitch import (
InvalidTokenException,
MissingScopeException,
TwitchAPIException,
TwitchAuthorizationException,
TwitchResourceNotFound,
)
from twitchAPI.type import AuthScope, AuthType
from twitchAPI.object.base import TwitchObject
from homeassistant.components.twitch import DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, load_json_array_fixture
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
def _get_twitch_user(user_id: str = "123") -> TwitchUser:
return TwitchUser(
id=user_id,
display_name="channel123",
offline_image_url="logo.png",
profile_image_url="logo.png",
view_count=42,
)
TwitchType = TypeVar("TwitchType", bound=TwitchObject)
async def async_iterator(iterable) -> AsyncIterator:
"""Return async iterator."""
for i in iterable:
yield i
class TwitchIterObject(Generic[TwitchType]):
"""Twitch object iterator."""
def __init__(self, fixture: str, target_type: type[TwitchType]) -> None:
"""Initialize object."""
self.raw_data = load_json_array_fixture(fixture, DOMAIN)
self.data = [target_type(**item) for item in self.raw_data]
self.total = len(self.raw_data)
self.target_type = target_type
@dataclass
class UserSubscriptionMock:
"""User subscription mock."""
broadcaster_id: str
is_gift: bool
@dataclass
class FollowedChannelMock:
"""Followed channel mock."""
broadcaster_login: str
followed_at: str
@dataclass
class ChannelFollowerMock:
"""Channel follower mock."""
user_id: str
@dataclass
class StreamMock:
"""Stream mock."""
game_name: str
title: str
thumbnail_url: str
class TwitchUserFollowResultMock:
"""Mock for twitch user follow result."""
def __init__(self, follows: list[FollowedChannelMock]) -> None:
"""Initialize mock."""
self.total = len(follows)
self.data = follows
def __aiter__(self):
async def __aiter__(self) -> AsyncIterator[TwitchType]:
"""Return async iterator."""
return async_iterator(self.data)
async for item in get_generator_from_data(self.raw_data, self.target_type):
yield item
class ChannelFollowersResultMock:
"""Mock for twitch channel follow result."""
def __init__(self, follows: list[ChannelFollowerMock]) -> None:
"""Initialize mock."""
self.total = len(follows)
self.data = follows
def __aiter__(self):
"""Return async iterator."""
return async_iterator(self.data)
async def get_generator(
fixture: str, target_type: type[TwitchType]
) -> AsyncGenerator[TwitchType, None]:
"""Return async generator."""
data = load_json_array_fixture(fixture, DOMAIN)
async for item in get_generator_from_data(data, target_type):
yield item
STREAMS = StreamMock(
game_name="Good game", title="Title", thumbnail_url="stream-medium.png"
)
class TwitchMock:
"""Mock for the twitch object."""
is_streaming = True
is_gifted = False
is_subscribed = False
is_following = True
different_user_id = False
def __await__(self):
"""Add async capabilities to the mock."""
t = asyncio.create_task(self._noop())
yield from t
return self
async def _noop(self):
"""Fake function to create task."""
async def get_users(
self, user_ids: list[str] | None = None, logins: list[str] | None = None
) -> AsyncGenerator[TwitchUser, None]:
"""Get list of mock users."""
users = [_get_twitch_user("234" if self.different_user_id else "123")]
for user in users:
yield user
def has_required_auth(
self, required_type: AuthType, required_scope: list[AuthScope]
) -> bool:
"""Return if auth required."""
return True
async def check_user_subscription(
self, broadcaster_id: str, user_id: str
) -> UserSubscriptionMock:
"""Check if the user is subscribed."""
if self.is_subscribed:
return UserSubscriptionMock(
broadcaster_id=broadcaster_id, is_gift=self.is_gifted
)
raise TwitchResourceNotFound
async def set_user_authentication(
self,
token: str,
scope: list[AuthScope],
refresh_token: str | None = None,
validate: bool = True,
) -> None:
"""Set user authentication."""
async def get_followed_channels(
self, user_id: str, broadcaster_id: str | None = None
) -> FollowedChannelsResult:
"""Get followed channels."""
if self.is_following:
return TwitchUserFollowResultMock(
[
FollowedChannelMock(
followed_at=datetime(year=2023, month=8, day=1),
broadcaster_login="internetofthings",
),
FollowedChannelMock(
followed_at=datetime(year=2023, month=8, day=1),
broadcaster_login="homeassistant",
),
]
)
return TwitchUserFollowResultMock([])
async def get_channel_followers(
self, broadcaster_id: str
) -> ChannelFollowersResultMock:
"""Get channel followers."""
return ChannelFollowersResultMock([ChannelFollowerMock(user_id="abc")])
async def get_streams(
self, user_id: list[str], first: int
) -> AsyncGenerator[StreamMock, None]:
"""Get streams for the user."""
streams = []
if self.is_streaming:
streams = [STREAMS]
for stream in streams:
yield stream
class TwitchUnauthorizedMock(TwitchMock):
"""Twitch mock to test if the client is unauthorized."""
def __await__(self):
"""Add async capabilities to the mock."""
raise TwitchAuthorizationException
class TwitchMissingScopeMock(TwitchMock):
"""Twitch mock to test missing scopes."""
async def set_user_authentication(
self, token: str, scope: list[AuthScope], validate: bool = True
) -> None:
"""Set user authentication."""
raise MissingScopeException
class TwitchInvalidTokenMock(TwitchMock):
"""Twitch mock to test invalid token."""
async def set_user_authentication(
self, token: str, scope: list[AuthScope], validate: bool = True
) -> None:
"""Set user authentication."""
raise InvalidTokenException
class TwitchInvalidUserMock(TwitchMock):
"""Twitch mock to test invalid user."""
async def get_users(
self, user_ids: list[str] | None = None, logins: list[str] | None = None
) -> AsyncGenerator[TwitchUser, None]:
"""Get list of mock users."""
if user_ids is not None or logins is not None:
async for user in super().get_users(user_ids, logins):
yield user
else:
for user in []:
yield user
class TwitchAPIExceptionMock(TwitchMock):
"""Twitch mock to test when twitch api throws unknown exception."""
async def check_user_subscription(
self, broadcaster_id: str, user_id: str
) -> UserSubscriptionMock:
"""Check if the user is subscribed."""
raise TwitchAPIException
async def get_generator_from_data(
items: list[dict[str, Any]], target_type: type[TwitchType]
) -> AsyncGenerator[TwitchType, None]:
"""Return async generator."""
for item in items:
yield target_type(**item)

View File

@ -1,10 +1,11 @@
"""Configure tests for the Twitch integration."""
from collections.abc import Awaitable, Callable, Generator
from collections.abc import Generator
import time
from unittest.mock import AsyncMock, patch
import pytest
from twitchAPI.object.api import FollowedChannel, Stream, TwitchUser, UserSubscription
from homeassistant.components.application_credentials import (
ClientCredential,
@ -14,11 +15,10 @@ from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN, OAUTH_SC
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.components.twitch import TwitchMock
from tests.test_util.aiohttp import AiohttpClientMocker
from . import TwitchIterObject, get_generator
type ComponentSetup = Callable[[TwitchMock | None], Awaitable[None]]
from tests.common import MockConfigEntry, load_json_object_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
@ -92,23 +92,32 @@ def mock_connection(aioclient_mock: AiohttpClientMocker) -> None:
)
@pytest.fixture(name="twitch_mock")
def twitch_mock() -> TwitchMock:
@pytest.fixture
def twitch_mock() -> Generator[AsyncMock, None, None]:
"""Return as fixture to inject other mocks."""
return TwitchMock()
@pytest.fixture(name="twitch")
def mock_twitch(twitch_mock: TwitchMock):
"""Mock Twitch."""
with (
patch(
"homeassistant.components.twitch.Twitch",
return_value=twitch_mock,
),
autospec=True,
) as mock_client,
patch(
"homeassistant.components.twitch.config_flow.Twitch",
return_value=twitch_mock,
new=mock_client,
),
):
yield twitch_mock
mock_client.return_value.get_users = lambda *args, **kwargs: get_generator(
"get_users.json", TwitchUser
)
mock_client.return_value.get_followed_channels.return_value = TwitchIterObject(
"get_followed_channels.json", FollowedChannel
)
mock_client.return_value.get_streams.return_value = get_generator(
"get_streams.json", Stream
)
mock_client.return_value.check_user_subscription.return_value = (
UserSubscription(
**load_json_object_fixture("check_user_subscription.json", DOMAIN)
)
)
mock_client.return_value.has_required_auth.return_value = True
yield mock_client

View File

@ -0,0 +1,3 @@
{
"is_gift": true
}

View File

@ -0,0 +1,3 @@
{
"is_gift": false
}

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1,10 @@
[
{
"broadcaster_login": "internetofthings",
"followed_at": "2023-08-01"
},
{
"broadcaster_login": "homeassistant",
"followed_at": "2023-08-01"
}
]

View File

@ -0,0 +1,7 @@
[
{
"game_name": "Good game",
"title": "Title",
"thumbnail_url": "stream-medium.png"
}
]

View File

@ -0,0 +1,9 @@
[
{
"id": 123,
"display_name": "channel123",
"offline_image_url": "logo.png",
"profile_image_url": "logo.png",
"view_count": 42
}
]

View File

@ -0,0 +1,9 @@
[
{
"id": 456,
"display_name": "channel123",
"offline_image_url": "logo.png",
"profile_image_url": "logo.png",
"view_count": 42
}
]

View File

@ -1,6 +1,8 @@
"""Test config flow for Twitch."""
from unittest.mock import patch
from unittest.mock import AsyncMock
from twitchAPI.object.api import TwitchUser
from homeassistant.components.twitch.const import (
CONF_CHANNELS,
@ -12,10 +14,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult, FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from . import setup_integration
from . import get_generator, setup_integration
from tests.common import MockConfigEntry
from tests.components.twitch import TwitchMock
from tests.components.twitch.conftest import CLIENT_ID, TITLE
from tests.typing import ClientSessionGenerator
@ -51,7 +52,7 @@ async def test_full_flow(
hass_client_no_auth: ClientSessionGenerator,
current_request_with_host: None,
mock_setup_entry,
twitch: TwitchMock,
twitch_mock: AsyncMock,
scopes: list[str],
) -> None:
"""Check full flow."""
@ -80,7 +81,7 @@ async def test_already_configured(
current_request_with_host: None,
config_entry: MockConfigEntry,
mock_setup_entry,
twitch: TwitchMock,
twitch_mock: AsyncMock,
scopes: list[str],
) -> None:
"""Check flow aborts when account already configured."""
@ -90,13 +91,10 @@ async def test_already_configured(
)
await _do_get_token(hass, result, hass_client_no_auth, scopes)
with patch(
"homeassistant.components.twitch.config_flow.Twitch", return_value=TwitchMock()
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reauth(
@ -105,7 +103,7 @@ async def test_reauth(
current_request_with_host: None,
config_entry: MockConfigEntry,
mock_setup_entry,
twitch: TwitchMock,
twitch_mock: AsyncMock,
scopes: list[str],
) -> None:
"""Check reauth flow."""
@ -136,7 +134,7 @@ async def test_reauth_from_import(
hass_client_no_auth: ClientSessionGenerator,
current_request_with_host: None,
mock_setup_entry,
twitch: TwitchMock,
twitch_mock: AsyncMock,
expires_at,
scopes: list[str],
) -> None:
@ -163,7 +161,7 @@ async def test_reauth_from_import(
current_request_with_host,
config_entry,
mock_setup_entry,
twitch,
twitch_mock,
scopes,
)
entries = hass.config_entries.async_entries(DOMAIN)
@ -178,12 +176,14 @@ async def test_reauth_wrong_account(
current_request_with_host: None,
config_entry: MockConfigEntry,
mock_setup_entry,
twitch: TwitchMock,
twitch_mock: AsyncMock,
scopes: list[str],
) -> None:
"""Check reauth flow."""
await setup_integration(hass, config_entry)
twitch.different_user_id = True
twitch_mock.return_value.get_users = lambda *args, **kwargs: get_generator(
"get_users_2.json", TwitchUser
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={

View File

@ -1,8 +1,8 @@
"""Tests for YouTube."""
"""Tests for Twitch."""
import http
import time
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from aiohttp.client_exceptions import ClientError
import pytest
@ -11,14 +11,14 @@ from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import TwitchMock, setup_integration
from . import setup_integration
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_setup_success(
hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock
hass: HomeAssistant, config_entry: MockConfigEntry, twitch_mock: AsyncMock
) -> None:
"""Test successful setup and unload."""
await setup_integration(hass, config_entry)
@ -38,7 +38,7 @@ async def test_expired_token_refresh_success(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
twitch: TwitchMock,
twitch_mock: AsyncMock,
) -> None:
"""Test expired token is refreshed."""
@ -84,7 +84,7 @@ async def test_expired_token_refresh_failure(
status: http.HTTPStatus,
expected_state: ConfigEntryState,
config_entry: MockConfigEntry,
twitch: TwitchMock,
twitch_mock: AsyncMock,
) -> None:
"""Test failure while refreshing token with a transient error."""
@ -93,8 +93,10 @@ async def test_expired_token_refresh_failure(
OAUTH2_TOKEN,
status=status,
)
config_entry.add_to_hass(hass)
await setup_integration(hass, config_entry)
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
# Verify a transient failure has occurred
entries = hass.config_entries.async_entries(DOMAIN)
@ -102,7 +104,7 @@ async def test_expired_token_refresh_failure(
async def test_expired_token_refresh_client_error(
hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock
hass: HomeAssistant, config_entry: MockConfigEntry, twitch_mock: AsyncMock
) -> None:
"""Test failure while refreshing token with a client error."""
@ -110,7 +112,10 @@ async def test_expired_token_refresh_client_error(
"homeassistant.components.twitch.OAuth2Session.async_ensure_token_valid",
side_effect=ClientError,
):
await setup_integration(hass, config_entry)
config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
# Verify a transient failure has occurred
entries = hass.config_entries.async_entries(DOMAIN)

View File

@ -1,30 +1,28 @@
"""The tests for an update of the Twitch component."""
from datetime import datetime
from unittest.mock import AsyncMock
import pytest
from twitchAPI.object.api import FollowedChannel, Stream, UserSubscription
from twitchAPI.type import TwitchResourceNotFound
from homeassistant.components.twitch import DOMAIN
from homeassistant.core import HomeAssistant
from ...common import MockConfigEntry
from . import (
TwitchAPIExceptionMock,
TwitchInvalidTokenMock,
TwitchInvalidUserMock,
TwitchMissingScopeMock,
TwitchMock,
TwitchUnauthorizedMock,
setup_integration,
)
from . import TwitchIterObject, get_generator_from_data, setup_integration
from tests.common import MockConfigEntry, load_json_object_fixture
ENTITY_ID = "sensor.channel123"
async def test_offline(
hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry
hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry
) -> None:
"""Test offline state."""
twitch.is_streaming = False
twitch_mock.return_value.get_streams.return_value = get_generator_from_data(
[], Stream
)
await setup_integration(hass, config_entry)
sensor_state = hass.states.get(ENTITY_ID)
@ -33,7 +31,7 @@ async def test_offline(
async def test_streaming(
hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry
hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry
) -> None:
"""Test streaming state."""
await setup_integration(hass, config_entry)
@ -46,10 +44,15 @@ async def test_streaming(
async def test_oauth_without_sub_and_follow(
hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry
hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry
) -> None:
"""Test state with oauth."""
twitch.is_following = False
twitch_mock.return_value.get_followed_channels.return_value = TwitchIterObject(
"empty_response.json", FollowedChannel
)
twitch_mock.return_value.check_user_subscription.side_effect = (
TwitchResourceNotFound
)
await setup_integration(hass, config_entry)
sensor_state = hass.states.get(ENTITY_ID)
@ -58,11 +61,15 @@ async def test_oauth_without_sub_and_follow(
async def test_oauth_with_sub(
hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry
hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry
) -> None:
"""Test state with oauth and sub."""
twitch.is_subscribed = True
twitch.is_following = False
twitch_mock.return_value.get_followed_channels.return_value = TwitchIterObject(
"empty_response.json", FollowedChannel
)
twitch_mock.return_value.check_user_subscription.return_value = UserSubscription(
**load_json_object_fixture("check_user_subscription_2.json", DOMAIN)
)
await setup_integration(hass, config_entry)
sensor_state = hass.states.get(ENTITY_ID)
@ -72,7 +79,7 @@ async def test_oauth_with_sub(
async def test_oauth_with_follow(
hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry
hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry
) -> None:
"""Test state with oauth and follow."""
await setup_integration(hass, config_entry)
@ -82,40 +89,3 @@ async def test_oauth_with_follow(
assert sensor_state.attributes["following_since"] == datetime(
year=2023, month=8, day=1
)
@pytest.mark.parametrize(
"twitch_mock",
[TwitchUnauthorizedMock(), TwitchMissingScopeMock(), TwitchInvalidTokenMock()],
)
async def test_auth_invalid(
hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry
) -> None:
"""Test auth failures."""
await setup_integration(hass, config_entry)
sensor_state = hass.states.get(ENTITY_ID)
assert sensor_state is None
@pytest.mark.parametrize("twitch_mock", [TwitchInvalidUserMock()])
async def test_auth_with_invalid_user(
hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry
) -> None:
"""Test auth with invalid user."""
await setup_integration(hass, config_entry)
sensor_state = hass.states.get(ENTITY_ID)
assert "subscribed" not in sensor_state.attributes
@pytest.mark.parametrize("twitch_mock", [TwitchAPIExceptionMock()])
async def test_auth_with_api_exception(
hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry
) -> None:
"""Test auth with invalid user."""
await setup_integration(hass, config_entry)
sensor_state = hass.states.get(ENTITY_ID)
assert sensor_state.attributes["subscribed"] is False
assert "subscription_is_gifted" not in sensor_state.attributes