core/tests/components/habitica/test_services.py

892 lines
24 KiB
Python

"""Test Habitica actions."""
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch
from uuid import UUID
from aiohttp import ClientError
from habiticalib import Direction, Skill
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.habitica.const import (
ATTR_CONFIG_ENTRY,
ATTR_DIRECTION,
ATTR_ITEM,
ATTR_KEYWORD,
ATTR_PRIORITY,
ATTR_SKILL,
ATTR_TAG,
ATTR_TARGET,
ATTR_TASK,
ATTR_TYPE,
DOMAIN,
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL,
SERVICE_GET_TASKS,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_SCORE_HABIT,
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST,
SERVICE_TRANSFORMATION,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .conftest import (
ERROR_BAD_REQUEST,
ERROR_NOT_AUTHORIZED,
ERROR_NOT_FOUND,
ERROR_TOO_MANY_REQUESTS,
)
from tests.common import MockConfigEntry
REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason"
RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds"
@pytest.fixture(autouse=True)
def services_only() -> Generator[None]:
"""Enable only services."""
with patch(
"homeassistant.components.habitica.PLATFORMS",
[],
):
yield
@pytest.fixture(autouse=True)
async def load_entry(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
services_only: Generator,
) -> None:
"""Load config entry."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
@pytest.fixture(autouse=True)
def uuid_mock() -> Generator[None]:
"""Mock the UUID."""
with patch(
"uuid.uuid4", return_value="5d1935ff-80c8-443c-b2e9-733c66b44745"
) as uuid_mock:
yield uuid_mock.return_value
@pytest.mark.parametrize(
(
"service_data",
"call_args",
),
[
(
{
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "pickpocket",
},
{
"skill": Skill.PICKPOCKET,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
),
(
{
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "backstab",
},
{
"skill": Skill.BACKSTAB,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
),
(
{
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "fireball",
},
{
"skill": Skill.BURST_OF_FLAMES,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
),
(
{
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "smash",
},
{
"skill": Skill.BRUTAL_SMASH,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
),
(
{
ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash",
},
{
"skill": Skill.BRUTAL_SMASH,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
),
(
{
ATTR_TASK: "pay_bills",
ATTR_SKILL: "smash",
},
{
"skill": Skill.BRUTAL_SMASH,
"target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"),
},
),
],
ids=[
"cast pickpocket",
"cast backstab",
"cast fireball",
"cast smash",
"select task by name",
"select task_by_alias",
],
)
async def test_cast_skill(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service_data: dict[str, Any],
call_args: dict[str, Any],
) -> None:
"""Test Habitica cast skill action."""
await hass.services.async_call(
DOMAIN,
SERVICE_CAST_SKILL,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)
habitica.cast_skill.assert_awaited_once_with(**call_args)
@pytest.mark.parametrize(
(
"service_data",
"raise_exception",
"expected_exception",
"expected_exception_msg",
),
[
(
{
ATTR_TASK: "task-not-found",
ATTR_SKILL: "smash",
},
None,
ServiceValidationError,
"Unable to complete action, could not find the task 'task-not-found'",
),
(
{
ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash",
},
ERROR_TOO_MANY_REQUESTS,
HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
{
ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash",
},
ERROR_NOT_FOUND,
ServiceValidationError,
"Unable to cast skill, your character does not have the skill or spell smash",
),
(
{
ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash",
},
ERROR_NOT_AUTHORIZED,
ServiceValidationError,
"Unable to cast skill, not enough mana. Your character has 50 MP, but the skill costs 10 MP",
),
(
{
ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash",
},
ERROR_BAD_REQUEST,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
(
{
ATTR_TASK: "Rechnungen bezahlen",
ATTR_SKILL: "smash",
},
ClientError,
HomeAssistantError,
"Unable to connect to Habitica: ",
),
],
)
async def test_cast_skill_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service_data: dict[str, Any],
raise_exception: Exception,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica cast skill action exceptions."""
habitica.cast_skill.side_effect = raise_exception
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
SERVICE_CAST_SKILL,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)
async def test_get_config_entry(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test Habitica config entry exceptions."""
with pytest.raises(
ServiceValidationError,
match="The selected character is not configured in Home Assistant",
):
await hass.services.async_call(
DOMAIN,
SERVICE_CAST_SKILL,
service_data={
ATTR_CONFIG_ENTRY: "0000000000000000",
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "smash",
},
return_response=True,
blocking=True,
)
assert await hass.config_entries.async_unload(config_entry.entry_id)
with pytest.raises(
ServiceValidationError,
match="The selected character is currently not loaded or disabled in Home Assistant",
):
await hass.services.async_call(
DOMAIN,
SERVICE_CAST_SKILL,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490",
ATTR_SKILL: "smash",
},
return_response=True,
blocking=True,
)
@pytest.mark.parametrize(
"service",
[
SERVICE_ABORT_QUEST,
SERVICE_ACCEPT_QUEST,
SERVICE_CANCEL_QUEST,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_START_QUEST,
],
)
async def test_handle_quests(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service: str,
) -> None:
"""Test Habitica actions for quest handling."""
await hass.services.async_call(
DOMAIN,
service,
service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id},
return_response=True,
blocking=True,
)
getattr(habitica, service).assert_awaited_once()
@pytest.mark.parametrize(
(
"raise_exception",
"expected_exception",
"expected_exception_msg",
),
[
(
ERROR_TOO_MANY_REQUESTS,
HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
ERROR_NOT_FOUND,
ServiceValidationError,
"Unable to complete action, quest or group not found",
),
(
ERROR_NOT_AUTHORIZED,
ServiceValidationError,
"Action not allowed, only quest leader or group leader can perform this action",
),
(
ERROR_BAD_REQUEST,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
(
ClientError,
HomeAssistantError,
"Unable to connect to Habitica: ",
),
],
)
@pytest.mark.parametrize(
"service",
[
SERVICE_ACCEPT_QUEST,
SERVICE_ABORT_QUEST,
SERVICE_CANCEL_QUEST,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
SERVICE_START_QUEST,
],
)
async def test_handle_quests_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
raise_exception: Exception,
service: str,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica handle quests action exceptions."""
getattr(habitica, service).side_effect = raise_exception
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
service,
service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id},
return_response=True,
blocking=True,
)
@pytest.mark.parametrize(
("service", "service_data", "call_args"),
[
(
SERVICE_SCORE_HABIT,
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
{
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
),
(
SERVICE_SCORE_HABIT,
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "down",
},
{
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.DOWN,
},
),
(
SERVICE_SCORE_REWARD,
{
ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
},
{
"task_id": UUID("5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"),
"direction": Direction.UP,
},
),
(
SERVICE_SCORE_HABIT,
{
ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu",
ATTR_DIRECTION: "up",
},
{
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
),
(
SERVICE_SCORE_HABIT,
{
ATTR_TASK: "create_a_task",
ATTR_DIRECTION: "up",
},
{
"task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"),
"direction": Direction.UP,
},
),
],
ids=[
"habit score up",
"habit score down",
"buy reward",
"match task by name",
"match task by alias",
],
)
async def test_score_task(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service: str,
service_data: dict[str, Any],
call_args: dict[str, Any],
) -> None:
"""Test Habitica score task action."""
await hass.services.async_call(
DOMAIN,
service,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)
habitica.update_score.assert_awaited_once_with(**call_args)
@pytest.mark.parametrize(
(
"service_data",
"raise_exception",
"expected_exception",
"expected_exception_msg",
),
[
(
{
ATTR_TASK: "task does not exist",
ATTR_DIRECTION: "up",
},
None,
ServiceValidationError,
"Unable to complete action, could not find the task 'task does not exist'",
),
(
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
ERROR_TOO_MANY_REQUESTS,
HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
ERROR_BAD_REQUEST,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
(
{
ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d",
ATTR_DIRECTION: "up",
},
ClientError,
HomeAssistantError,
"Unable to connect to Habitica: ",
),
(
{
ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b",
ATTR_DIRECTION: "up",
},
ERROR_NOT_AUTHORIZED,
HomeAssistantError,
"Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10.00 GP",
),
],
)
async def test_score_task_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service_data: dict[str, Any],
raise_exception: Exception,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica score task action exceptions."""
habitica.update_score.side_effect = raise_exception
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
SERVICE_SCORE_HABIT,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)
@pytest.mark.parametrize(
("service_data", "call_args"),
[
(
{
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
ATTR_ITEM: "spooky_sparkles",
},
{
"skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
),
(
{
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
ATTR_ITEM: "shiny_seed",
},
{
"skill": Skill.SHINY_SEED,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
),
(
{
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
ATTR_ITEM: "seafoam",
},
{
"skill": Skill.SEAFOAM,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
),
(
{
ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303",
ATTR_ITEM: "snowball",
},
{
"skill": Skill.SNOWBALL,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
),
(
{
ATTR_TARGET: "test-user",
ATTR_ITEM: "spooky_sparkles",
},
{
"skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
),
(
{
ATTR_TARGET: "test-username",
ATTR_ITEM: "spooky_sparkles",
},
{
"skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"),
},
),
(
{
ATTR_TARGET: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7",
ATTR_ITEM: "spooky_sparkles",
},
{
"skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"),
},
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
{
"skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"),
},
),
(
{
ATTR_TARGET: "test-partymember-displayname",
ATTR_ITEM: "spooky_sparkles",
},
{
"skill": Skill.SPOOKY_SPARKLES,
"target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"),
},
),
],
ids=[
"use spooky sparkles/select self by id",
"use shiny seed",
"use seafoam",
"use snowball",
"select self by displayname",
"select self by username",
"select partymember by id",
"select partymember by username",
"select partymember by displayname",
],
)
async def test_transformation(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service_data: dict[str, Any],
call_args: dict[str, Any],
) -> None:
"""Test Habitica use transformation item action."""
await hass.services.async_call(
DOMAIN,
SERVICE_TRANSFORMATION,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)
habitica.cast_skill.assert_awaited_once_with(**call_args)
@pytest.mark.parametrize(
(
"service_data",
"raise_exception_members",
"raise_exception_cast",
"expected_exception",
"expected_exception_msg",
),
[
(
{
ATTR_TARGET: "user-not-found",
ATTR_ITEM: "spooky_sparkles",
},
None,
None,
ServiceValidationError,
"Unable to find target 'user-not-found' in your party",
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
ERROR_NOT_FOUND,
None,
ServiceValidationError,
"Unable to find target, you are currently not in a party. You can only target yourself",
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
ERROR_BAD_REQUEST,
None,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
None,
ERROR_TOO_MANY_REQUESTS,
HomeAssistantError,
RATE_LIMIT_EXCEPTION_MSG,
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
None,
ERROR_NOT_AUTHORIZED,
ServiceValidationError,
"Unable to use spooky_sparkles, you don't own this item",
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
None,
ERROR_BAD_REQUEST,
HomeAssistantError,
REQUEST_EXCEPTION_MSG,
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
None,
ClientError,
HomeAssistantError,
"Unable to connect to Habitica: ",
),
(
{
ATTR_TARGET: "test-partymember-username",
ATTR_ITEM: "spooky_sparkles",
},
ClientError,
None,
HomeAssistantError,
"Unable to connect to Habitica: ",
),
],
)
async def test_transformation_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
habitica: AsyncMock,
service_data: dict[str, Any],
raise_exception_members: Exception,
raise_exception_cast: Exception,
expected_exception: Exception,
expected_exception_msg: str,
) -> None:
"""Test Habitica transformation action exceptions."""
habitica.cast_skill.side_effect = raise_exception_cast
habitica.get_group_members.side_effect = raise_exception_members
with pytest.raises(expected_exception, match=expected_exception_msg):
await hass.services.async_call(
DOMAIN,
SERVICE_TRANSFORMATION,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)
@pytest.mark.parametrize(
("service_data"),
[
{},
{ATTR_TYPE: ["daily"]},
{ATTR_TYPE: ["habit"]},
{ATTR_TYPE: ["todo"]},
{ATTR_TYPE: ["reward"]},
{ATTR_TYPE: ["daily", "habit"]},
{ATTR_TYPE: ["todo", "reward"]},
{ATTR_PRIORITY: "trivial"},
{ATTR_PRIORITY: "easy"},
{ATTR_PRIORITY: "medium"},
{ATTR_PRIORITY: "hard"},
{ATTR_TASK: ["Zahnseide benutzen", "Eine kurze Pause machen"]},
{ATTR_TASK: ["f2c85972-1a19-4426-bc6d-ce3337b9d99f"]},
{ATTR_TASK: ["alias_zahnseide_benutzen"]},
{ATTR_TAG: ["Training", "Gesundheit + Wohlbefinden"]},
{ATTR_KEYWORD: "gewohnheit"},
{ATTR_TAG: ["Home Assistant"]},
],
ids=[
"all_tasks",
"only dailies",
"only habits",
"only todos",
"only rewards",
"only dailies and habits",
"only todos and rewards",
"trivial tasks",
"easy tasks",
"medium tasks",
"hard tasks",
"by task name",
"by task ID",
"by alias",
"by tag",
"by keyword",
"empty result",
],
)
@pytest.mark.usefixtures("habitica")
async def test_get_tasks(
hass: HomeAssistant,
config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
service_data: dict[str, Any],
) -> None:
"""Test Habitica get_tasks action."""
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_TASKS,
service_data={
ATTR_CONFIG_ENTRY: config_entry.entry_id,
**service_data,
},
return_response=True,
blocking=True,
)
assert response == snapshot