Add config flow to tautulli integration (#57450)

pull/70841/head
Robert Hillis 2022-04-26 19:37:13 -04:00 committed by GitHub
parent 8a2b20faf0
commit 09a7116efc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1441 additions and 87 deletions

View File

@ -1202,7 +1202,7 @@ omit =
homeassistant/components/tankerkoenig/const.py
homeassistant/components/tankerkoenig/sensor.py
homeassistant/components/tapsaff/binary_sensor.py
homeassistant/components/tautulli/const.py
homeassistant/components/tautulli/__init__.py
homeassistant/components/tautulli/coordinator.py
homeassistant/components/tautulli/sensor.py
homeassistant/components/ted5000/sensor.py

View File

@ -1013,7 +1013,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/tapsaff/ @bazwilliams
/homeassistant/components/tasmota/ @emontnemery
/tests/components/tasmota/ @emontnemery
/homeassistant/components/tautulli/ @ludeeus
/homeassistant/components/tautulli/ @ludeeus @tkdrob
/tests/components/tautulli/ @ludeeus @tkdrob
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core

View File

@ -1 +1,63 @@
"""The tautulli component."""
"""The Tautulli integration."""
from __future__ import annotations
from pytautulli import PyTautulli, PyTautulliHostConfiguration
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_NAME, DOMAIN
from .coordinator import TautulliDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Tautulli from a config entry."""
host_configuration = PyTautulliHostConfiguration(
api_token=entry.data[CONF_API_KEY],
url=entry.data[CONF_URL],
verify_ssl=entry.data[CONF_VERIFY_SSL],
)
api_client = PyTautulli(
host_configuration=host_configuration,
session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]),
)
coordinator = TautulliDataUpdateCoordinator(hass, host_configuration, api_client)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data.pop(DOMAIN)
return unload_ok
class TautulliEntity(CoordinatorEntity[TautulliDataUpdateCoordinator]):
"""Defines a base Tautulli entity."""
def __init__(
self,
coordinator: TautulliDataUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the Tautulli entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
configuration_url=coordinator.host_configuration.base_url,
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
manufacturer=DEFAULT_NAME,
)

View File

@ -0,0 +1,142 @@
"""Config flow for Tautulli."""
from __future__ import annotations
from typing import Any
from pytautulli import (
PyTautulli,
PyTautulliException,
PyTautulliHostConfiguration,
exceptions,
)
import voluptuous as vol
from homeassistant.components.sensor import _LOGGER
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PATH,
CONF_PORT,
CONF_SSL,
CONF_URL,
CONF_VERIFY_SSL,
)
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DEFAULT_NAME,
DEFAULT_PATH,
DEFAULT_PORT,
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
)
class TautulliConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Tautulli."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initiated by the user."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
errors = {}
if user_input is not None:
if (error := await self.validate_input(user_input)) is None:
return self.async_create_entry(
title=DEFAULT_NAME,
data=user_input,
)
errors["base"] = error
user_input = user_input or {}
data_schema = {
vol.Required(CONF_API_KEY, default=user_input.get(CONF_API_KEY, "")): str,
vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str,
vol.Optional(
CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)
): bool,
}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(data_schema),
errors=errors or {},
)
async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult:
"""Handle a reauthorization flow request."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
errors = {}
if user_input is not None and (
entry := self.hass.config_entries.async_get_entry(self.context["entry_id"])
):
_input = {**entry.data, CONF_API_KEY: user_input[CONF_API_KEY]}
if (error := await self.validate_input(_input)) is None:
self.hass.config_entries.async_update_entry(entry, data=_input)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
errors["base"] = error
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
async def async_step_import(self, config: dict[str, Any]) -> FlowResult:
"""Import a config entry from configuration.yaml."""
_LOGGER.warning(
"Configuration of the Tautulli platform in YAML is deprecated and will be "
"removed in Home Assistant 2022.6; Your existing configuration for host %s"
"has been imported into the UI automatically and can be safely removed "
"from your configuration.yaml file",
config[CONF_HOST],
)
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
host_configuration = PyTautulliHostConfiguration(
config[CONF_API_KEY],
ipaddress=config[CONF_HOST],
port=config.get(CONF_PORT, DEFAULT_PORT),
ssl=config.get(CONF_SSL, DEFAULT_SSL),
verify_ssl=config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
base_api_path=config.get(CONF_PATH, DEFAULT_PATH),
)
return await self.async_step_user(
{
CONF_API_KEY: host_configuration.api_token,
CONF_URL: host_configuration.base_url,
CONF_VERIFY_SSL: host_configuration.verify_ssl,
}
)
async def validate_input(self, user_input: dict[str, Any]) -> str | None:
"""Try connecting to Tautulli."""
try:
api_client = PyTautulli(
api_token=user_input[CONF_API_KEY],
url=user_input[CONF_URL],
session=async_get_clientsession(
self.hass, user_input.get(CONF_VERIFY_SSL, True)
),
verify_ssl=user_input.get(CONF_VERIFY_SSL, True),
)
await api_client.async_get_server_info()
except exceptions.PyTautulliConnectionException:
return "cannot_connect"
except exceptions.PyTautulliAuthenticationException:
return "invalid_auth"
except PyTautulliException:
return "unknown"
return None

View File

@ -1,5 +1,11 @@
"""Constants for the Tautulli integration."""
from logging import Logger, getLogger
CONF_MONITORED_USERS = "monitored_users"
DEFAULT_NAME = "Tautulli"
DEFAULT_PATH = ""
DEFAULT_PORT = "8181"
DEFAULT_SSL = False
DEFAULT_VERIFY_SSL = True
DOMAIN = "tautulli"
LOGGER: Logger = getLogger(__package__)

View File

@ -9,10 +9,16 @@ from pytautulli import (
PyTautulliApiActivity,
PyTautulliApiHomeStats,
PyTautulliApiUser,
PyTautulliException,
)
from pytautulli.exceptions import (
PyTautulliAuthenticationException,
PyTautulliConnectionException,
)
from pytautulli.models.host_configuration import PyTautulliHostConfiguration
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
@ -21,9 +27,12 @@ from .const import DOMAIN, LOGGER
class TautulliDataUpdateCoordinator(DataUpdateCoordinator):
"""Data update coordinator for the Tautulli integration."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
host_configuration: PyTautulliHostConfiguration,
api_client: PyTautulli,
) -> None:
"""Initialize the coordinator."""
@ -33,6 +42,7 @@ class TautulliDataUpdateCoordinator(DataUpdateCoordinator):
name=DOMAIN,
update_interval=timedelta(seconds=10),
)
self.host_configuration = host_configuration
self.api_client = api_client
self.activity: PyTautulliApiActivity | None = None
self.home_stats: list[PyTautulliApiHomeStats] | None = None
@ -48,5 +58,7 @@ class TautulliDataUpdateCoordinator(DataUpdateCoordinator):
self.api_client.async_get_users(),
]
)
except PyTautulliException as exception:
raise UpdateFailed(exception) from exception
except PyTautulliConnectionException as ex:
raise UpdateFailed(ex) from ex
except PyTautulliAuthenticationException as ex:
raise ConfigEntryAuthFailed(ex) from ex

View File

@ -3,7 +3,8 @@
"name": "Tautulli",
"documentation": "https://www.home-assistant.io/integrations/tautulli",
"requirements": ["pytautulli==21.11.0"],
"codeowners": ["@ludeeus"],
"config_flow": true,
"codeowners": ["@ludeeus", "@tkdrob"],
"iot_class": "local_polling",
"loggers": ["pytautulli"]
}

View File

@ -3,10 +3,14 @@ from __future__ import annotations
from typing import Any
from pytautulli import PyTautulli
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
from homeassistant.components.sensor import (
PLATFORM_SCHEMA,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
@ -18,22 +22,23 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TautulliEntity
from .const import (
CONF_MONITORED_USERS,
DEFAULT_NAME,
DEFAULT_PATH,
DEFAULT_PORT,
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
)
from .coordinator import TautulliDataUpdateCoordinator
CONF_MONITORED_USERS = "monitored_users"
DEFAULT_NAME = "Tautulli"
DEFAULT_PORT = "8181"
DEFAULT_PATH = ""
DEFAULT_SSL = False
DEFAULT_VERIFY_SSL = True
# Deprecated in Home Assistant 2022.4
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_API_KEY): cv.string,
@ -48,6 +53,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
}
)
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
icon="mdi:plex",
key="watching_count",
name="Tautulli",
native_unit_of_measurement="Watching",
),
)
async def async_setup_platform(
hass: HomeAssistant,
@ -56,65 +70,30 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Create the Tautulli sensor."""
name = config[CONF_NAME]
host = config[CONF_HOST]
port = config[CONF_PORT]
path = config[CONF_PATH]
api_key = config[CONF_API_KEY]
monitored_conditions = config.get(CONF_MONITORED_CONDITIONS, [])
users = config.get(CONF_MONITORED_USERS, [])
use_ssl = config[CONF_SSL]
verify_ssl = config[CONF_VERIFY_SSL]
session = async_get_clientsession(hass=hass, verify_ssl=verify_ssl)
api_client = PyTautulli(
api_token=api_key,
hostname=host,
session=session,
verify_ssl=verify_ssl,
port=port,
ssl=use_ssl,
base_api_path=path,
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
)
coordinator = TautulliDataUpdateCoordinator(hass=hass, api_client=api_client)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Tautulli sensor."""
coordinator: TautulliDataUpdateCoordinator = hass.data[DOMAIN]
async_add_entities(
new_entities=[
TautulliSensor(
coordinator=coordinator,
name=name,
monitored_conditions=monitored_conditions,
usernames=users,
)
],
update_before_add=True,
TautulliSensor(
coordinator,
description,
)
for description in SENSOR_TYPES
)
class TautulliSensor(CoordinatorEntity[TautulliDataUpdateCoordinator], SensorEntity):
class TautulliSensor(TautulliEntity, SensorEntity):
"""Representation of a Tautulli sensor."""
def __init__(
self,
coordinator: TautulliDataUpdateCoordinator,
name: str,
monitored_conditions: list[str],
usernames: list[str],
) -> None:
"""Initialize the Tautulli sensor."""
super().__init__(coordinator)
self.monitored_conditions = monitored_conditions
self.usernames = usernames
self._name = name
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
@ -122,16 +101,6 @@ class TautulliSensor(CoordinatorEntity[TautulliDataUpdateCoordinator], SensorEnt
return 0
return self.coordinator.activity.stream_count or 0
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return "mdi:plex"
@property
def native_unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
return "Watching"
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return attributes for the sensor."""
@ -161,20 +130,14 @@ class TautulliSensor(CoordinatorEntity[TautulliDataUpdateCoordinator], SensorEnt
_attributes["Top User"] = stat.rows[0].user if stat.rows else None
for user in self.coordinator.users:
if (
self.usernames
and user.username not in self.usernames
or user.username == "Local"
):
if user.username == "Local":
continue
_attributes.setdefault(user.username, {})["Activity"] = None
for session in self.coordinator.activity.sessions:
if not _attributes.get(session.username):
if not _attributes.get(session.username) or "null" in session.state:
continue
_attributes[session.username]["Activity"] = session.state
for key in self.monitored_conditions:
_attributes[session.username][key] = getattr(session, key)
return _attributes

View File

@ -0,0 +1,30 @@
{
"config": {
"step": {
"user": {
"description": "To find your API key, open the Tautulli webpage and navigate to Settings and then to Web interface. The API key will be at the bottom of that page.\n\nExample of the URL: ```http://192.168.0.10:8181``` with 8181 being the default port.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl]"
}
},
"reauth_confirm": {
"title": "Re-authenticate Tautulli",
"description": "To find your API key, open the Tautulli webpage and navigate to Settings and then to Web interface. The API key will be at the bottom of that page.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View File

@ -0,0 +1,31 @@
{
"config": {
"step": {
"user": {
"description": "To find your API key, open the Tautulli webpage and navigate to Settings and then to Web interface. The API key will be at the bottom of that page.\n\nExample of the URL: ```http://192.168.0.10:8181``` with 8181 being the default port.",
"data": {
"api_key": "Api Key",
"url": "URL",
"verify_ssl": "Verify SSL certificate"
}
},
"reauth_confirm": {
"title": "Re-authenticate Tautulli",
"description": "To find your API key, open the Tautulli webpage and navigate to Settings and then to Web interface. The API key will be at the bottom of that page.",
"data": {
"api_key": "Api Key"
}
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"single_instance_allowed": "Already configured. Only a single configuration possible.",
"reauth_successful": "Re-authentication was successful"
}
}
}

View File

@ -342,6 +342,7 @@ FLOWS = {
"tailscale",
"tankerkoenig",
"tasmota",
"tautulli",
"tellduslive",
"tesla_wall_connector",
"tibber",

View File

@ -1232,6 +1232,9 @@ pysyncthru==0.7.10
# homeassistant.components.tankerkoenig
pytankerkoenig==0.0.6
# homeassistant.components.tautulli
pytautulli==21.11.0
# homeassistant.components.ecobee
python-ecobee-api==0.2.14

View File

@ -0,0 +1,124 @@
"""Tests for the Tautulli integration."""
from unittest.mock import AsyncMock, patch
from homeassistant.components.tautulli.const import CONF_MONITORED_USERS, DOMAIN
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_MONITORED_CONDITIONS,
CONF_PATH,
CONF_PORT,
CONF_SSL,
CONF_URL,
CONF_VERIFY_SSL,
CONTENT_TYPE_JSON,
)
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
API_KEY = "abcd"
URL = "http://1.2.3.4:8181/test"
NAME = "Tautulli"
SSL = False
VERIFY_SSL = True
CONF_DATA = {
CONF_API_KEY: API_KEY,
CONF_URL: URL,
CONF_VERIFY_SSL: VERIFY_SSL,
}
CONF_IMPORT_DATA = {
CONF_API_KEY: API_KEY,
CONF_HOST: "1.2.3.4",
CONF_MONITORED_CONDITIONS: ["Stream count"],
CONF_MONITORED_USERS: ["test"],
CONF_PORT: "8181",
CONF_PATH: "/test",
CONF_SSL: SSL,
CONF_VERIFY_SSL: VERIFY_SSL,
}
DEFAULT_USERS = [{11111111: {"enabled": False}, 22222222: {"enabled": False}}]
SELECTED_USERNAMES = ["user1"]
def patch_config_flow_tautulli(mocked_tautulli) -> AsyncMock:
"""Mock Tautulli config flow."""
return patch(
"homeassistant.components.tautulli.config_flow.PyTautulli.async_get_server_info",
return_value=mocked_tautulli,
)
def mock_connection(
aioclient_mock: AiohttpClientMocker,
url: str = URL,
invalid_auth: bool = False,
) -> None:
"""Mock Tautulli connection."""
url = f"http://{url}/api/v2?apikey={API_KEY}"
if invalid_auth:
aioclient_mock.get(
f"{url}&cmd=get_activity",
text=load_fixture("tautulli/get_activity.json"),
headers={"Content-Type": CONTENT_TYPE_JSON},
)
return
aioclient_mock.get(
f"{url}&cmd=get_activity",
text=load_fixture("tautulli/get_activity.json"),
headers={"Content-Type": CONTENT_TYPE_JSON},
)
aioclient_mock.get(
f"{url}&cmd=get_home_stats",
text=load_fixture("tautulli/get_home_stats.json"),
headers={"Content-Type": CONTENT_TYPE_JSON},
)
aioclient_mock.get(
f"{url}&cmd=get_users",
text=load_fixture("tautulli/get_users.json"),
headers={"Content-Type": CONTENT_TYPE_JSON},
)
async def setup_integration(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
url: str = URL,
api_key: str = API_KEY,
unique_id: str = None,
skip_entry_setup: bool = False,
invalid_auth: bool = False,
) -> MockConfigEntry:
"""Set up the Tautulli integration in Home Assistant."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=unique_id,
data={
CONF_URL: url,
CONF_VERIFY_SSL: VERIFY_SSL,
CONF_API_KEY: api_key,
},
options={
CONF_MONITORED_USERS: DEFAULT_USERS,
},
)
entry.add_to_hass(hass)
mock_connection(
aioclient_mock,
url=url,
invalid_auth=invalid_auth,
)
if not skip_entry_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -0,0 +1,256 @@
{
"response": {
"result": "success",
"message": "None",
"data": {
"stream_count": "1",
"sessions": [
{
"session_key": "39",
"media_type": "episode",
"view_offset": "688805",
"progress_percent": "53",
"quality_profile": "Original",
"synced_version_profile": "",
"optimized_version_profile": "",
"user": "user1",
"channel_stream": 0,
"section_id": "2",
"library_name": "TV Shows",
"rating_key": "28514",
"parent_rating_key": "28493",
"grandparent_rating_key": "27615",
"title": "What If…? Ultron Won",
"parent_title": "Season 1",
"grandparent_title": "What If…?",
"original_title": "",
"sort_title": "",
"media_index": "21",
"parent_media_index": "7",
"studio": "Marvel",
"content_rating": "TV-14",
"summary": "Natasha Romanoff and Clint Barton seek to destroy killer-robot Ultron following a cataclysmic event.\r\n",
"tagline": "",
"rating": "7.0",
"rating_image": "",
"audience_rating": "",
"audience_rating_image": "",
"user_rating": "",
"duration": "1288538",
"year": "2021",
"thumb": "/library/metadata/28514/thumb/1581502592",
"parent_thumb": "/library/metadata/28493/thumb/1581502619",
"grandparent_thumb": "/library/metadata/27615/thumb/1608869467",
"art": "/library/metadata/27615/art/1608869467",
"banner": "/library/metadata/27615/banner/1608869467",
"originally_available_at": "2004-01-29",
"added_at": "1516913437",
"updated_at": "1581502592",
"last_viewed_at": "",
"guid": "com.plexapp.agents.thetvdb://367147/1/8?lang=en",
"parent_guid": "com.plexapp.agents.thetvdb://367147/1?lang=en",
"grandparent_guid": "com.plexapp.agents.thetvdb://367147?lang=en",
"directors": [],
"writers": [],
"actors": ["John Doe"],
"genres": [
"Action",
"Animation",
"Anime",
"Children",
"Comedy",
"Fantasy"
],
"labels": [],
"collections": [],
"guids": [],
"full_title": "What If…? Ultron Won",
"children_count": 0,
"live": 0,
"id": "",
"container": "mkv",
"bitrate": "326",
"height": "480",
"width": "640",
"aspect_ratio": "1.33",
"video_codec": "h264",
"video_resolution": "480",
"video_full_resolution": "480p",
"video_framerate": "24p",
"video_profile": "high",
"audio_codec": "aac",
"audio_channels": "2",
"audio_channel_layout": "stereo",
"audio_profile": "lc",
"optimized_version": 0,
"channel_call_sign": "",
"channel_identifier": "",
"channel_thumb": "",
"file": "/Videos/What If...?/Season 1/What If...? - S01E08 - Ultron Won.mkv",
"file_size": "52560333",
"indexes": 1,
"selected": 1,
"type": "",
"video_codec_level": "41",
"video_bitrate": "326",
"video_bit_depth": "8",
"video_chroma_subsampling": "4:2:0",
"video_color_primaries": "smpte170m",
"video_color_range": "tv",
"video_color_space": "smpte170m",
"video_color_trc": "bt709",
"video_frame_rate": "23.976",
"video_ref_frames": "16",
"video_height": "480",
"video_width": "640",
"video_language": "English",
"video_language_code": "eng",
"video_scan_type": "progressive",
"audio_bitrate": "",
"audio_bitrate_mode": "",
"audio_sample_rate": "48000",
"audio_language": "English",
"audio_language_code": "eng",
"subtitle_codec": "",
"subtitle_container": "",
"subtitle_format": "",
"subtitle_forced": 0,
"subtitle_location": "",
"subtitle_language": "",
"subtitle_language_code": "",
"row_id": 2,
"user_id": 11111111,
"username": "user1",
"friendly_name": "user1",
"user_thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
"email": "user1@test.com",
"is_active": 1,
"is_admin": 1,
"is_home_user": 1,
"is_allow_sync": 1,
"is_restricted": 0,
"do_notify": 1,
"keep_history": 1,
"deleted_user": 0,
"allow_guest": 0,
"shared_libraries": [
"2",
"1",
"3",
"4",
"5",
"6",
"7",
"2",
"1",
"2",
"3",
"1",
"2",
"3"
],
"last_seen": "None",
"ip_address": "10.1.0.200",
"ip_address_public": "1.2.3.4",
"device": "AFTMM",
"platform": "Android",
"platform_name": "android",
"platform_version": "7.1.2",
"product": "Plex for Android (TV)",
"product_version": "8.23.2.28087",
"profile": "Android",
"player": "AFTMM",
"machine_id": "1234567890abcdef-com-plexapp-android",
"state": "playing",
"local": 1,
"relayed": 0,
"secure": 1,
"session_id": "1234567890abcdef-com-plexapp-android",
"bandwidth": "587",
"location": "lan",
"transcode_key": "",
"transcode_throttled": 0,
"transcode_progress": 0,
"transcode_speed": "",
"transcode_audio_channels": "",
"transcode_audio_codec": "",
"transcode_video_codec": "",
"transcode_width": "",
"transcode_height": "",
"transcode_container": "",
"transcode_protocol": "",
"transcode_hw_requested": 0,
"transcode_hw_decode": "",
"transcode_hw_decode_title": "",
"transcode_hw_encode": "",
"transcode_hw_encode_title": "",
"transcode_hw_full_pipeline": 0,
"audio_decision": "direct play",
"video_decision": "direct play",
"subtitle_decision": "",
"throttled": "0",
"transcode_hw_decoding": 0,
"transcode_hw_encoding": 0,
"stream_container": "mkv",
"stream_bitrate": "326",
"stream_aspect_ratio": "1.33",
"stream_audio_codec": "aac",
"stream_audio_channels": "2",
"stream_audio_channel_layout": "stereo",
"stream_video_codec": "h264",
"stream_video_framerate": "24p",
"stream_video_resolution": "480",
"stream_video_height": "480",
"stream_video_width": "640",
"stream_duration": "1288538",
"stream_container_decision": "direct play",
"optimized_version_title": "",
"synced_version": 0,
"live_uuid": "",
"bif_thumb": "/library/parts/227108/indexes/sd/688805",
"subtitles": 0,
"transcode_decision": "direct play",
"container_decision": "direct play",
"stream_video_full_resolution": "480p",
"video_dynamic_range": "SDR",
"stream_video_dynamic_range": "SDR",
"stream_video_bitrate": "326",
"stream_video_bit_depth": "8",
"stream_video_chroma_subsampling": "4:2:0",
"stream_video_color_primaries": "smpte170m",
"stream_video_color_range": "tv",
"stream_video_color_space": "smpte170m",
"stream_video_color_trc": "bt709",
"stream_video_codec_level": "41",
"stream_video_ref_frames": "16",
"stream_video_language": "English",
"stream_video_language_code": "eng",
"stream_video_scan_type": "progressive",
"stream_video_decision": "direct play",
"stream_audio_bitrate": "",
"stream_audio_bitrate_mode": "",
"stream_audio_sample_rate": "48000",
"stream_audio_channel_layout_": "stereo",
"stream_audio_language": "English",
"stream_audio_language_code": "eng",
"stream_audio_decision": "direct play",
"stream_subtitle_codec": "",
"stream_subtitle_container": "",
"stream_subtitle_format": "",
"stream_subtitle_forced": 0,
"stream_subtitle_location": "",
"stream_subtitle_language": "",
"stream_subtitle_language_code": "",
"stream_subtitle_decision": "",
"stream_subtitle_transient": 0
}
],
"stream_count_direct_play": 1,
"stream_count_direct_stream": 0,
"stream_count_transcode": 0,
"total_bandwidth": 587,
"lan_bandwidth": 587,
"wan_bandwidth": 0
}
}
}

View File

@ -0,0 +1,434 @@
{
"response": {
"result": "success",
"message": "None",
"data": [
{
"stat_id": "top_movies",
"stat_type": "total_plays",
"stat_title": "Most Watched Movies",
"rows": [
{
"title": "Rush Hour",
"year": 1998,
"total_plays": 1,
"total_duration": 6007,
"users_watched": "",
"rating_key": 2975,
"grandparent_rating_key": "",
"last_play": 1633333051,
"grandparent_thumb": "",
"thumb": "/library/metadata/2975/thumb/1608877847",
"art": "/library/metadata/2975/art/1608877847",
"section_id": 1,
"media_type": "movie",
"content_rating": "PG-13",
"labels": [],
"user": "",
"friendly_name": "",
"platform": "",
"live": 0,
"guid": "plex://movie/5d77682b7e9a3c0020c6b438",
"row_id": 4838
},
{
"title": "Tomb Raider",
"year": 2018,
"total_plays": 1,
"total_duration": 7102,
"users_watched": "",
"rating_key": 4725,
"grandparent_rating_key": "",
"last_play": 1633299184,
"grandparent_thumb": "",
"thumb": "/library/metadata/4725/thumb/1608896342",
"art": "/library/metadata/4725/art/1608896342",
"section_id": 1,
"media_type": "movie",
"content_rating": "PG-13",
"labels": [],
"user": "",
"friendly_name": "",
"platform": "",
"live": 0,
"guid": "plex://movie/5d776b9e96b655001fe16fd9",
"row_id": 4837
}
]
},
{
"stat_id": "popular_movies",
"stat_title": "Most Popular Movies",
"rows": [
{
"title": "Rush Hour",
"year": 1998,
"users_watched": 1,
"rating_key": 2975,
"grandparent_rating_key": "",
"last_play": 1633333051,
"total_plays": 1,
"grandparent_thumb": "",
"thumb": "/library/metadata/2975/thumb/1608877847",
"art": "/library/metadata/2975/art/1608877847",
"section_id": 1,
"media_type": "movie",
"content_rating": "PG-13",
"labels": [],
"user": "",
"friendly_name": "",
"platform": "",
"live": 0,
"guid": "plex://movie/5d77682b7e9a3c0020c6b438",
"row_id": 4838
},
{
"title": "Rush Hour 2",
"year": 2001,
"users_watched": 1,
"rating_key": 2976,
"grandparent_rating_key": "",
"last_play": 1633339363,
"total_plays": 1,
"grandparent_thumb": "",
"thumb": "/library/metadata/2976/thumb/1608877847",
"art": "/library/metadata/2976/art/1608877847",
"section_id": 1,
"media_type": "movie",
"content_rating": "PG-13",
"labels": [],
"user": "",
"friendly_name": "",
"platform": "",
"live": 0,
"guid": "plex://movie/5d77682eeb5d26001f1df4fd",
"row_id": 4839
}
]
},
{
"stat_id": "top_tv",
"stat_type": "total_plays",
"stat_title": "Most Watched TV Shows",
"rows": [
{
"title": "What If…?",
"year": "",
"total_plays": 17,
"total_duration": 32104,
"users_watched": "",
"rating_key": 229036,
"grandparent_rating_key": 229036,
"last_play": 1633555543,
"grandparent_thumb": "/library/metadata/229036/thumb/1632908571",
"thumb": "/library/metadata/229036/thumb/1632908571",
"art": "/library/metadata/229036/art/1632908571",
"section_id": 2,
"media_type": "episode",
"content_rating": "TV-14",
"labels": [],
"user": "",
"friendly_name": "",
"platform": "",
"live": 0,
"guid": "plex://episode/60eb19efd30af2002dc67d14",
"row_id": 4852
},
{
"title": "Star Trek: Lower Decks",
"year": "",
"total_plays": 3,
"total_duration": 5189,
"users_watched": "",
"rating_key": 156643,
"grandparent_rating_key": 156643,
"last_play": 1633593441,
"grandparent_thumb": "/library/metadata/156643/thumb/1632998327",
"thumb": "/library/metadata/156643/thumb/1632998327",
"art": "/library/metadata/156643/art/1632998327",
"section_id": 2,
"media_type": "episode",
"content_rating": "TV-14",
"labels": [],
"user": "",
"friendly_name": "",
"platform": "",
"live": 0,
"guid": "plex://episode/6148a04e5f74bd5dfc6aa3cf",
"row_id": 4856
}
]
},
{
"stat_id": "popular_tv",
"stat_title": "Most Popular TV Shows",
"rows": [
{
"title": "What If…?",
"year": "",
"users_watched": 1,
"rating_key": 229036,
"grandparent_rating_key": 229036,
"last_play": 1633555543,
"total_plays": 17,
"grandparent_thumb": "/library/metadata/229036/thumb/1632908571",
"thumb": "/library/metadata/229036/thumb/1632908571",
"art": "/library/metadata/229036/art/1632908571",
"section_id": 2,
"media_type": "episode",
"content_rating": "TV-14",
"labels": [],
"user": "",
"friendly_name": "",
"platform": "",
"live": 0,
"guid": "plex://episode/60eb19efd30af2002dc67d14",
"row_id": 4852
},
{
"title": "Star Trek: Lower Decks",
"year": "",
"users_watched": 1,
"rating_key": 156643,
"grandparent_rating_key": 156643,
"last_play": 1633593441,
"total_plays": 3,
"grandparent_thumb": "/library/metadata/156643/thumb/1632998327",
"thumb": "/library/metadata/156643/thumb/1632998327",
"art": "/library/metadata/156643/art/1632998327",
"section_id": 2,
"media_type": "episode",
"content_rating": "TV-14",
"labels": [],
"user": "",
"friendly_name": "",
"platform": "",
"live": 0,
"guid": "plex://episode/6148a04e5f74bd5dfc6aa3cf",
"row_id": 4856
}
]
},
{
"stat_id": "top_music",
"stat_type": "total_plays",
"stat_title": "Most Played Artists",
"rows": []
},
{
"stat_id": "popular_music",
"stat_title": "Most Popular Artists",
"rows": []
},
{
"stat_id": "last_watched",
"stat_title": "Recently Watched",
"rows": [
{
"row_id": 4876,
"user": "user1",
"friendly_name": "user1",
"user_id": 12269439,
"user_thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
"title": "What If…? Ultron Won",
"grandparent_title": "What If…?",
"grandchild_title": "What If…? Ultron Won",
"year": 2021,
"media_index": 8,
"parent_media_index": 1,
"rating_key": 28505,
"grandparent_rating_key": 27615,
"thumb": "/library/metadata/367147/thumb/1/8/1608869467",
"grandparent_thumb": "/library/metadata/367147/thumb/1608869467",
"art": "/library/metadata/367147/art/1608869467",
"section_id": 2,
"media_type": "episode",
"content_rating": "TV-14",
"labels": [],
"last_watch": 1633851517,
"live": 0,
"guid": "com.plexapp.agents.thetvdb://367147?lang=en",
"player": "AFTMM"
},
{
"row_id": 4876,
"user": "user1",
"friendly_name": "user1",
"user_id": 12269439,
"user_thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
"title": "What If…? The Watcher Broke His Oath",
"grandparent_title": "What If…?",
"grandchild_title": "What If…? The Watcher Broke His Oath",
"year": 2021,
"media_index": 9,
"parent_media_index": 1,
"rating_key": 28505,
"grandparent_rating_key": 27615,
"thumb": "/library/metadata/367147/thumb/1608869467",
"grandparent_thumb": "/library/metadata/367147/thumb/1608869467",
"art": "/library/metadata/367147/art/1608869467",
"section_id": 2,
"media_type": "episode",
"content_rating": "TV-14",
"labels": [],
"last_watch": 1633851517,
"live": 0,
"guid": "com.plexapp.agents.thetvdb://367147?lang=en",
"player": "AFTMM"
}
]
},
{
"stat_id": "top_libraries",
"stat_type": "total_plays",
"stat_title": "Most Active Libraries",
"rows": [
{
"total_plays": 488,
"total_duration": 914600,
"section_type": "movie",
"section_name": "Movies",
"section_id": 2,
"last_play": 1633879410,
"thumb": "/:/resources/show.png",
"grandparent_thumb": "",
"art": "/:/resources/movie-fanart.jpg",
"user": "",
"friendly_name": "",
"users_watched": "",
"rating_key": "",
"grandparent_rating_key": "",
"title": "",
"platform": "",
"row_id": ""
},
{
"total_plays": 51,
"total_duration": 271800,
"section_type": "movie",
"section_name": "Movies",
"section_id": 1,
"last_play": 1633811027,
"thumb": "/:/resources/movie.png",
"grandparent_thumb": "",
"art": "/:/resources/movie-fanart.jpg",
"user": "",
"friendly_name": "",
"users_watched": "",
"rating_key": "",
"grandparent_rating_key": "",
"title": "",
"platform": "",
"row_id": ""
}
]
},
{
"stat_id": "top_users",
"stat_type": "total_plays",
"stat_title": "Most Active Users",
"rows": [
{
"user": "user1",
"user_id": 11111111,
"friendly_name": "user1",
"total_plays": 132,
"total_duration": 308729,
"last_play": 1633879410,
"user_thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
"grandparent_thumb": "",
"art": "",
"users_watched": "",
"rating_key": "",
"grandparent_rating_key": "",
"title": "",
"platform": "",
"row_id": ""
},
{
"user": "user2",
"user_id": 22222222,
"friendly_name": "test@email.com",
"total_plays": 7,
"total_duration": 10521,
"last_play": 1632799281,
"user_thumb": "https://plex.tv/users/fedcba0987654321/avatar?c=2222222222",
"grandparent_thumb": "",
"art": "",
"users_watched": "",
"rating_key": "",
"grandparent_rating_key": "",
"title": "",
"platform": "",
"row_id": ""
}
]
},
{
"stat_id": "top_platforms",
"stat_type": "total_plays",
"stat_title": "Most Active Platforms",
"rows": [
{
"total_plays": 122,
"total_duration": 285083,
"last_play": 1633879410,
"platform": "Android",
"platform_name": "android",
"title": "",
"thumb": "",
"grandparent_thumb": "",
"art": "",
"users_watched": "",
"rating_key": "",
"grandparent_rating_key": "",
"user": "",
"friendly_name": "",
"row_id": ""
},
{
"total_plays": 10,
"total_duration": 23646,
"last_play": 1632480545,
"platform": "Windows",
"platform_name": "windows",
"title": "",
"thumb": "",
"grandparent_thumb": "",
"art": "",
"users_watched": "",
"rating_key": "",
"grandparent_rating_key": "",
"user": "",
"friendly_name": "",
"row_id": ""
}
]
},
{
"stat_id": "most_concurrent",
"stat_title": "Most Concurrent Streams",
"rows": [
{
"title": "Concurrent Streams",
"count": 2,
"started": "1632702800",
"stopped": "1632705315"
},
{
"title": "Concurrent Transcodes",
"count": 1,
"started": "1633294952",
"stopped": "1633298957"
},
{
"title": "Concurrent Direct Plays",
"count": 2,
"started": "1632702800",
"stopped": "1632705315"
}
]
}
]
}
}

View File

@ -0,0 +1,54 @@
{
"response": {
"result": "success",
"message": null,
"data": [
{
"row_id": 1,
"user_id": 11111111,
"username": "user1",
"friendly_name": "user1",
"thumb": "https://plex.tv/users/1234567890abcdef/avatar?c=1111111111",
"email": "test@email.com",
"is_active": 1,
"is_admin": 1,
"is_home_user": 1,
"is_allow_sync": 1,
"is_restricted": 0,
"do_notify": 1,
"keep_history": 1,
"allow_guest": 0,
"server_token": "1234-67890abcdef-234",
"shared_libraries": "2;1;3;4;5;6;7;2;1;2;3;1;2;3",
"filter_all": "",
"filter_movies": "",
"filter_tv": "",
"filter_music": "",
"filter_photos": ""
},
{
"row_id": 2,
"user_id": 22222222,
"username": "user2",
"friendly_name": "user2",
"thumb": "https://plex.tv/users/fedcba0987654321/avatar?c=2222222222",
"email": "",
"is_active": 1,
"is_admin": 0,
"is_home_user": 1,
"is_allow_sync": 1,
"is_restricted": 1,
"do_notify": 1,
"keep_history": 1,
"allow_guest": 0,
"server_token": "1234567890abcdef1234",
"shared_libraries": "1;3;2",
"filter_all": "",
"filter_movies": "",
"filter_tv": "",
"filter_music": "",
"filter_photos": ""
}
]
}
}

View File

@ -0,0 +1,7 @@
{
"response": {
"result": "error",
"message": "Invalid apikey",
"data": {}
}
}

View File

@ -0,0 +1,227 @@
"""Test Tautulli config flow."""
from unittest.mock import AsyncMock, patch
from pytautulli import exceptions
from pytest import LogCaptureFixture
from homeassistant import data_entry_flow
from homeassistant.components.tautulli.const import DEFAULT_NAME, DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_SOURCE
from homeassistant.core import HomeAssistant
from . import (
CONF_DATA,
CONF_IMPORT_DATA,
NAME,
patch_config_flow_tautulli,
setup_integration,
)
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_flow_user_single_instance_allowed(hass: HomeAssistant) -> None:
"""Test user step single instance allowed."""
entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA)
entry.add_to_hass(hass)
with patch_config_flow_tautulli(AsyncMock()):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=CONF_IMPORT_DATA,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "single_instance_allowed"
async def test_flow_user(hass: HomeAssistant) -> None:
"""Test user initiated flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch_config_flow_tautulli(AsyncMock()):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_DATA,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == NAME
assert result2["data"] == CONF_DATA
async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None:
"""Test user initialized flow with unreachable server."""
with patch_config_flow_tautulli(AsyncMock()) as tautullimock:
tautullimock.side_effect = exceptions.PyTautulliConnectionException
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "cannot_connect"
with patch_config_flow_tautulli(AsyncMock()):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_DATA,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == NAME
assert result2["data"] == CONF_DATA
async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None:
"""Test user initialized flow with invalid authentication."""
with patch_config_flow_tautulli(AsyncMock()) as tautullimock:
tautullimock.side_effect = exceptions.PyTautulliAuthenticationException
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "invalid_auth"
with patch_config_flow_tautulli(AsyncMock()):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_DATA,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == NAME
assert result2["data"] == CONF_DATA
async def test_flow_user_unknown_error(hass: HomeAssistant) -> None:
"""Test user initialized flow with unreachable server."""
with patch_config_flow_tautulli(AsyncMock()) as tautullimock:
tautullimock.side_effect = exceptions.PyTautulliException
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "unknown"
with patch_config_flow_tautulli(AsyncMock()):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_DATA,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == NAME
assert result2["data"] == CONF_DATA
async def test_flow_import(hass: HomeAssistant, caplog: LogCaptureFixture) -> None:
"""Test import step."""
with patch_config_flow_tautulli(AsyncMock()):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=CONF_IMPORT_DATA,
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
assert result["data"] == CONF_DATA
assert "Tautulli platform in YAML" in caplog.text
async def test_flow_import_single_instance_allowed(hass: HomeAssistant) -> None:
"""Test import step single instance allowed."""
entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA)
entry.add_to_hass(hass)
with patch_config_flow_tautulli(AsyncMock()):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=CONF_IMPORT_DATA,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "single_instance_allowed"
async def test_flow_reauth(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test reauth flow."""
with patch("homeassistant.components.tautulli.PLATFORMS", []):
entry = await setup_integration(hass, aioclient_mock)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
CONF_SOURCE: SOURCE_REAUTH,
"entry_id": entry.entry_id,
"unique_id": entry.unique_id,
},
data=CONF_DATA,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {}
new_conf = {CONF_API_KEY: "efgh"}
CONF_DATA[CONF_API_KEY] = "efgh"
with patch_config_flow_tautulli(AsyncMock()), patch(
"homeassistant.components.tautulli.async_setup_entry"
) as mock_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=new_conf,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result2["reason"] == "reauth_successful"
assert entry.data == CONF_DATA
assert len(mock_entry.mock_calls) == 1
async def test_flow_reauth_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test reauth flow with invalid authentication."""
with patch("homeassistant.components.tautulli.PLATFORMS", []):
entry = await setup_integration(hass, aioclient_mock)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": entry.entry_id,
"unique_id": entry.unique_id,
},
)
with patch_config_flow_tautulli(AsyncMock()) as tautullimock:
tautullimock.side_effect = exceptions.PyTautulliAuthenticationException
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "efgh"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"]["base"] == "invalid_auth"
with patch_config_flow_tautulli(AsyncMock()):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "efgh"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "reauth_successful"