From e26142949df39507ff3aa6001e7c8a7379654213 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 15 Nov 2024 17:38:30 +0100 Subject: [PATCH] Add action for using transformation items to Habitica (#129606) --- homeassistant/components/habitica/const.py | 5 + homeassistant/components/habitica/icons.json | 3 + homeassistant/components/habitica/services.py | 96 ++++ .../components/habitica/services.yaml | 22 + .../components/habitica/strings.json | 35 ++ .../habitica/fixtures/party_members.json | 442 ++++++++++++++++++ tests/components/habitica/fixtures/user.json | 2 + tests/components/habitica/test_services.py | 245 +++++++++- 8 files changed, 849 insertions(+), 1 deletion(-) create mode 100644 tests/components/habitica/fixtures/party_members.json diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index ae98cb13dcb..1fcc4b36053 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -26,6 +26,8 @@ ATTR_CONFIG_ENTRY = "config_entry" ATTR_SKILL = "skill" ATTR_TASK = "task" ATTR_DIRECTION = "direction" +ATTR_TARGET = "target" +ATTR_ITEM = "item" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" SERVICE_ACCEPT_QUEST = "accept_quest" @@ -36,6 +38,9 @@ SERVICE_LEAVE_QUEST = "leave_quest" SERVICE_SCORE_HABIT = "score_habit" SERVICE_SCORE_REWARD = "score_reward" +SERVICE_TRANSFORMATION = "transformation" + + WARRIOR = "warrior" ROGUE = "rogue" HEALER = "healer" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index d33b9c60c96..ca0ae604f14 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -187,6 +187,9 @@ }, "score_reward": { "service": "mdi:sack" + }, + "transformation": { + "service": "mdi:flask-round-bottom" } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index a50e5f1e6e3..7f2d66e4690 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -27,8 +27,10 @@ from .const import ( ATTR_CONFIG_ENTRY, ATTR_DATA, ATTR_DIRECTION, + ATTR_ITEM, ATTR_PATH, ATTR_SKILL, + ATTR_TARGET, ATTR_TASK, DOMAIN, EVENT_API_CALL_SUCCESS, @@ -42,6 +44,7 @@ from .const import ( SERVICE_SCORE_HABIT, SERVICE_SCORE_REWARD, SERVICE_START_QUEST, + SERVICE_TRANSFORMATION, ) from .types import HabiticaConfigEntry @@ -77,6 +80,14 @@ SERVICE_SCORE_TASK_SCHEMA = vol.Schema( } ) +SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_ITEM): cv.string, + vol.Required(ATTR_TARGET): cv.string, + } +) + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -294,6 +305,83 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 await coordinator.async_request_refresh() return response + async def transformation(call: ServiceCall) -> ServiceResponse: + """User a transformation item on a player character.""" + + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + ITEMID_MAP = { + "snowball": {"itemId": "snowball"}, + "spooky_sparkles": {"itemId": "spookySparkles"}, + "seafoam": {"itemId": "seafoam"}, + "shiny_seed": {"itemId": "shinySeed"}, + } + # check if target is self + if call.data[ATTR_TARGET] in ( + coordinator.data.user["id"], + coordinator.data.user["profile"]["name"], + coordinator.data.user["auth"]["local"]["username"], + ): + target_id = coordinator.data.user["id"] + else: + # check if target is a party member + try: + party = await coordinator.api.groups.party.members.get() + except ClientResponseError as e: + if e.status == HTTPStatus.TOO_MANY_REQUESTS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + if e.status == HTTPStatus.NOT_FOUND: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="party_not_found", + ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + try: + target_id = next( + member["id"] + for member in party + if call.data[ATTR_TARGET].lower() + in ( + member["id"], + member["auth"]["local"]["username"].lower(), + member["profile"]["name"].lower(), + ) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="target_not_found", + translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, + ) from e + try: + response: dict[str, Any] = await coordinator.api.user.class_.cast[ + ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"] + ].post(targetId=target_id) + except ClientResponseError as e: + if e.status == HTTPStatus.TOO_MANY_REQUESTS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + if e.status == HTTPStatus.UNAUTHORIZED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": call.data[ATTR_ITEM]}, + ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + else: + return response + hass.services.async_register( DOMAIN, SERVICE_API_CALL, @@ -323,3 +411,11 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_SCORE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_TRANSFORMATION, + transformation, + schema=SERVICE_TRANSFORMATION_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b539f6c65bf..a89c935b630 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -72,3 +72,25 @@ score_reward: fields: config_entry: *config_entry task: *task +transformation: + fields: + config_entry: + required: true + selector: + config_entry: + integration: habitica + item: + required: true + selector: + select: + options: + - "snowball" + - "spooky_sparkles" + - "seafoam" + - "shiny_seed" + mode: dropdown + translation_key: "transformation_item_select" + target: + required: true + selector: + text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index ac1faf5fcef..d32e4a048c7 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -321,6 +321,15 @@ }, "quest_not_found": { "message": "Unable to complete action, quest or group not found" + }, + "target_not_found": { + "message": "Unable to find target {target} in your party" + }, + "party_not_found": { + "message": "Unable to find target, you are currently not in a party. You can only target yourself" + }, + "item_not_found": { + "message": "Unable to use {item}, you don't own this item." } }, "issues": { @@ -461,6 +470,24 @@ "description": "The name (or task ID) of the custom reward." } } + }, + "transformation": { + "name": "Use a transformation item", + "description": "Use a transformation item from your Habitica character's inventory on a member of your party or yourself.", + "fields": { + "config_entry": { + "name": "Select character", + "description": "Choose the Habitica character to use the transformation item." + }, + "item": { + "name": "Transformation item", + "description": "Select the transformation item you want to use. Item must be in the characters inventory." + }, + "target": { + "name": "Target character", + "description": "The name of the character you want to use the transformation item on. You can also specify the players username or user ID." + } + } } }, "selector": { @@ -471,6 +498,14 @@ "backstab": "Rogue: Backstab", "smash": "Warrior: Brutal smash" } + }, + "transformation_item_select": { + "options": { + "snowball": "Snowball", + "spooky_sparkles": "Spooky sparkles", + "seafoam": "Seafoam", + "shiny_seed": "Shiny seed" + } } } } diff --git a/tests/components/habitica/fixtures/party_members.json b/tests/components/habitica/fixtures/party_members.json new file mode 100644 index 00000000000..e1bb31e6d81 --- /dev/null +++ b/tests/components/habitica/fixtures/party_members.json @@ -0,0 +1,442 @@ +{ + "success": true, + "data": [ + { + "_id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "auth": { + "local": { + "username": "test-username" + }, + "timestamps": { + "created": "2024-10-19T18:43:39.782Z", + "loggedin": "2024-10-31T16:13:35.048Z", + "updated": "2024-10-31T16:15:56.552Z" + } + }, + "achievements": { + "ultimateGearSets": { + "healer": false, + "wizard": false, + "rogue": false, + "warrior": false + }, + "streak": 0, + "challenges": [], + "perfect": 1, + "quests": {}, + "purchasedEquipment": true, + "completedTask": true, + "partyUp": true + }, + "backer": {}, + "contributor": {}, + "flags": { + "verifiedUsername": true, + "classSelected": true + }, + "items": { + "gear": { + "owned": { + "headAccessory_special_blackHeadband": true, + "headAccessory_special_blueHeadband": true, + "headAccessory_special_greenHeadband": true, + "headAccessory_special_pinkHeadband": true, + "headAccessory_special_redHeadband": true, + "headAccessory_special_whiteHeadband": true, + "headAccessory_special_yellowHeadband": true, + "eyewear_special_blackTopFrame": true, + "eyewear_special_blueTopFrame": true, + "eyewear_special_greenTopFrame": true, + "eyewear_special_pinkTopFrame": true, + "eyewear_special_redTopFrame": true, + "eyewear_special_whiteTopFrame": true, + "eyewear_special_yellowTopFrame": true, + "eyewear_special_blackHalfMoon": true, + "eyewear_special_blueHalfMoon": true, + "eyewear_special_greenHalfMoon": true, + "eyewear_special_pinkHalfMoon": true, + "eyewear_special_redHalfMoon": true, + "eyewear_special_whiteHalfMoon": true, + "eyewear_special_yellowHalfMoon": true, + "armor_special_bardRobes": true, + "weapon_special_fall2024Warrior": true, + "shield_special_fall2024Warrior": true, + "head_special_fall2024Warrior": true, + "armor_special_fall2024Warrior": true, + "back_mystery_201402": true, + "body_mystery_202003": true, + "head_special_bardHat": true, + "weapon_wizard_0": true + }, + "equipped": { + "weapon": "weapon_special_fall2024Warrior", + "armor": "armor_special_fall2024Warrior", + "head": "head_special_fall2024Warrior", + "shield": "shield_special_fall2024Warrior", + "back": "back_mystery_201402", + "headAccessory": "headAccessory_special_pinkHeadband", + "eyewear": "eyewear_special_pinkHalfMoon", + "body": "body_mystery_202003" + }, + "costume": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + } + }, + "special": { + "snowball": 99, + "spookySparkles": 99, + "shinySeed": 99, + "seafoam": 99, + "valentine": 0, + "valentineReceived": [], + "nye": 0, + "nyeReceived": [], + "greeting": 0, + "greetingReceived": [], + "thankyou": 0, + "thankyouReceived": [], + "birthday": 0, + "birthdayReceived": [], + "congrats": 0, + "congratsReceived": [], + "getwell": 0, + "getwellReceived": [], + "goodluck": 0, + "goodluckReceived": [] + }, + "pets": { + "Rat-Shade": 1, + "Gryphatrice-Jubilant": 1 + }, + "currentPet": "Gryphatrice-Jubilant", + "eggs": { + "Cactus": 1, + "Fox": 2, + "Wolf": 1 + }, + "hatchingPotions": { + "CottonCandyBlue": 1, + "RoyalPurple": 1 + }, + "food": { + "Meat": 2, + "Chocolate": 1, + "CottonCandyPink": 1, + "Candy_Zombie": 1 + }, + "mounts": { + "Velociraptor-Base": true, + "Gryphon-Gryphatrice": true + }, + "currentMount": "Gryphon-Gryphatrice", + "quests": { + "dustbunnies": 1, + "vice1": 1, + "atom1": 1, + "moonstone1": 1, + "goldenknight1": 1, + "basilist": 1 + }, + "lastDrop": { + "date": "2024-10-31T16:13:34.952Z", + "count": 0 + } + }, + "party": { + "quest": { + "progress": { + "up": 0, + "down": 0, + "collectedItems": 0, + "collect": {} + }, + "RSVPNeeded": false, + "key": "dustbunnies" + }, + "order": "level", + "orderAscending": "ascending", + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "preferences": { + "size": "slim", + "hair": { + "color": "red", + "base": 3, + "bangs": 1, + "beard": 0, + "mustache": 0, + "flower": 1 + }, + "skin": "915533", + "shirt": "blue", + "chair": "handleless_pink", + "costume": false, + "sleep": false, + "disableClasses": false, + "tasks": { + "groupByChallenge": false, + "confirmScoreNotes": false, + "mirrorGroupTasks": [], + "activeFilter": { + "habit": "all", + "daily": "all", + "todo": "remaining", + "reward": "all" + } + }, + "background": "violet" + }, + "profile": { + "name": "test-user" + }, + "stats": { + "hp": 50, + "mp": 150.8, + "exp": 127, + "gp": 19.08650199252128, + "lvl": 99, + "class": "wizard", + "points": 0, + "str": 0, + "con": 0, + "int": 0, + "per": 0, + "buffs": { + "str": 50, + "int": 50, + "per": 50, + "con": 50, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "training": { + "int": 0, + "per": 0, + "str": 0, + "con": 0 + }, + "toNextLevel": 3580, + "maxHealth": 50, + "maxMP": 228 + }, + "inbox": { + "optOut": false + }, + "loginIncentives": 6, + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303" + }, + { + "_id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "auth": { + "local": { + "username": "test-partymember-username" + }, + "timestamps": { + "created": "2024-10-10T15:57:01.106Z", + "loggedin": "2024-10-30T19:37:01.970Z", + "updated": "2024-10-30T19:38:25.968Z" + } + }, + "achievements": { + "ultimateGearSets": { + "healer": false, + "wizard": false, + "rogue": false, + "warrior": false + }, + "streak": 0, + "challenges": [], + "perfect": 1, + "quests": {}, + "completedTask": true, + "partyUp": true, + "snowball": 1, + "spookySparkles": 1, + "seafoam": 1, + "shinySeed": 1 + }, + "backer": {}, + "contributor": {}, + "flags": { + "verifiedUsername": true, + "classSelected": false + }, + "items": { + "gear": { + "equipped": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + }, + "costume": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + }, + "owned": { + "headAccessory_special_blackHeadband": true, + "headAccessory_special_blueHeadband": true, + "headAccessory_special_greenHeadband": true, + "headAccessory_special_pinkHeadband": true, + "headAccessory_special_redHeadband": true, + "headAccessory_special_whiteHeadband": true, + "headAccessory_special_yellowHeadband": true, + "eyewear_special_blackTopFrame": true, + "eyewear_special_blueTopFrame": true, + "eyewear_special_greenTopFrame": true, + "eyewear_special_pinkTopFrame": true, + "eyewear_special_redTopFrame": true, + "eyewear_special_whiteTopFrame": true, + "eyewear_special_yellowTopFrame": true, + "eyewear_special_blackHalfMoon": true, + "eyewear_special_blueHalfMoon": true, + "eyewear_special_greenHalfMoon": true, + "eyewear_special_pinkHalfMoon": true, + "eyewear_special_redHalfMoon": true, + "eyewear_special_whiteHalfMoon": true, + "eyewear_special_yellowHalfMoon": true, + "armor_special_bardRobes": true + } + }, + "special": { + "snowball": 0, + "spookySparkles": 0, + "shinySeed": 0, + "seafoam": 0, + "valentine": 0, + "valentineReceived": [], + "nye": 0, + "nyeReceived": [], + "greeting": 0, + "greetingReceived": [], + "thankyou": 0, + "thankyouReceived": [], + "birthday": 0, + "birthdayReceived": [], + "congrats": 0, + "congratsReceived": [], + "getwell": 0, + "getwellReceived": [], + "goodluck": 0, + "goodluckReceived": [] + }, + "lastDrop": { + "count": 0, + "date": "2024-10-30T19:37:01.838Z" + }, + "currentPet": "", + "currentMount": "", + "pets": {}, + "eggs": { + "BearCub": 1, + "Cactus": 1 + }, + "hatchingPotions": { + "Skeleton": 1 + }, + "food": { + "Candy_Red": 1 + }, + "mounts": {}, + "quests": { + "dustbunnies": 1 + } + }, + "party": { + "quest": { + "progress": { + "up": 0, + "down": 0, + "collectedItems": 0, + "collect": {} + }, + "RSVPNeeded": true, + "key": "dustbunnies" + }, + "order": "level", + "orderAscending": "ascending", + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "preferences": { + "size": "slim", + "hair": { + "color": "red", + "base": 3, + "bangs": 1, + "beard": 0, + "mustache": 0, + "flower": 1 + }, + "skin": "915533", + "shirt": "blue", + "chair": "none", + "costume": false, + "sleep": false, + "disableClasses": false, + "tasks": { + "groupByChallenge": false, + "confirmScoreNotes": false, + "mirrorGroupTasks": [], + "activeFilter": { + "habit": "all", + "daily": "all", + "todo": "remaining", + "reward": "all" + } + }, + "background": "violet" + }, + "profile": { + "name": "test-partymember-displayname" + }, + "stats": { + "buffs": { + "str": 1, + "int": 1, + "per": 1, + "con": 1, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": true, + "snowball": false, + "spookySparkles": false + }, + "training": { + "int": 0, + "per": 0, + "str": 0, + "con": 0 + }, + "hp": 50, + "mp": 24, + "exp": 24, + "gp": 4, + "lvl": 1, + "class": "warrior", + "points": 0, + "str": 0, + "con": 0, + "int": 0, + "per": 0, + "toNextLevel": 25, + "maxHealth": 50, + "maxMP": 32 + }, + "inbox": { + "optOut": false + }, + "loginIncentives": 1, + "id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7" + } + ], + "notifications": [], + "userV": 96, + "appVersion": "5.29.0" +} diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 569c5b81a02..e1b77cd31f2 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -2,6 +2,7 @@ "data": { "api_user": "test-api-user", "profile": { "name": "test-user" }, + "auth": { "local": { "username": "test-username" } }, "stats": { "buffs": { "str": 26, @@ -65,6 +66,7 @@ }, "needsCron": true, "lastCron": "2024-09-21T22:01:55.586Z", + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", "items": { "gear": { "equipped": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 403779bcbfb..cd363eba3b5 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -10,7 +10,9 @@ import pytest from homeassistant.components.habitica.const import ( ATTR_CONFIG_ENTRY, ATTR_DIRECTION, + ATTR_ITEM, ATTR_SKILL, + ATTR_TARGET, ATTR_TASK, DEFAULT_URL, DOMAIN, @@ -23,12 +25,13 @@ from homeassistant.components.habitica.const import ( 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 mock_called_with +from .conftest import load_json_object_fixture, mock_called_with from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -62,6 +65,15 @@ async def load_entry( 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", "item", "target_id"), [ @@ -546,3 +558,234 @@ async def test_score_task_exceptions( return_response=True, blocking=True, ) + + +@pytest.mark.parametrize( + ("service_data", "item", "target_id"), + [ + ( + { + ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ATTR_ITEM: "shiny_seed", + }, + "shinySeed", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ATTR_ITEM: "seafoam", + }, + "seafoam", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ATTR_ITEM: "snowball", + }, + "snowball", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "test-user", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "test-username", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "a380546a-94be-4b8e-8a0b-23e0d5c03303", + ), + ( + { + ATTR_TARGET: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ), + ( + { + ATTR_TARGET: "test-partymember-displayname", + ATTR_ITEM: "spooky_sparkles", + }, + "spookySparkles", + "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + ), + ], + ids=[], +) +async def test_transformation( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + item: str, + target_id: str, +) -> None: + """Test Habitica user transformation item action.""" + mock_habitica.get( + f"{DEFAULT_URL}/api/v3/groups/party/members", + json=load_json_object_fixture("party_members.json", DOMAIN), + ) + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", + json={"success": True, "data": {}}, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_TRANSFORMATION, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", + ) + + +@pytest.mark.parametrize( + ( + "service_data", + "http_status_members", + "http_status_cast", + "expected_exception", + "expected_exception_msg", + ), + [ + ( + { + ATTR_TARGET: "user-not-found", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.OK, + HTTPStatus.OK, + ServiceValidationError, + "Unable to find target 'user-not-found' in your party", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.TOO_MANY_REQUESTS, + HTTPStatus.OK, + ServiceValidationError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.NOT_FOUND, + HTTPStatus.OK, + 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", + }, + HTTPStatus.BAD_REQUEST, + HTTPStatus.OK, + HomeAssistantError, + "Unable to connect to Habitica, try again later", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.OK, + HTTPStatus.TOO_MANY_REQUESTS, + ServiceValidationError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.OK, + HTTPStatus.UNAUTHORIZED, + ServiceValidationError, + "Unable to use spooky_sparkles, you don't own this item", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + HTTPStatus.OK, + HTTPStatus.BAD_REQUEST, + HomeAssistantError, + "Unable to connect to Habitica, try again later", + ), + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_transformation_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + http_status_members: HTTPStatus, + http_status_cast: HTTPStatus, + expected_exception: Exception, + expected_exception_msg: str, +) -> None: + """Test Habitica transformation action exceptions.""" + mock_habitica.get( + f"{DEFAULT_URL}/api/v3/groups/party/members", + json=load_json_object_fixture("party_members.json", DOMAIN), + status=http_status_members, + ) + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/user/class/cast/spookySparkles?targetId=ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + json={"success": True, "data": {}}, + status=http_status_cast, + ) + + 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, + )