From 0db07a033bfc4b394ee372095fabafd80e8ab0fa Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 29 Dec 2024 15:00:31 +0100 Subject: [PATCH] Migrate Habitica integration to habiticalib (#131032) * Migrate data to habiticalib * Add habiticalib to init and coordinator * Migrate Habitica config flow to habiticalib * migrate init to habiticalib * migrate buttons to habiticalib * migrate switch to habiticalib * update habiticalib * cast_skill action * migrate update_score * migrate transformation items action * migrate quest actions * fix fixture errors * Migrate coordinator data and content * bump habiticalib * Remove habitipy and use wrapper in habiticalub * changes * some fixes * minor refactoring * class_needed annotation * Update diagnostics * do integration setup in coordinator setup * small changes * raise HomeAssistantError for TooManyRequestsError * fix docstring * update tests * changes to tests/snapshots * fix update_todo_item --- homeassistant/components/habitica/__init__.py | 62 +- .../components/habitica/binary_sensor.py | 15 +- homeassistant/components/habitica/button.py | 197 ++- homeassistant/components/habitica/calendar.py | 85 +- .../components/habitica/config_flow.py | 86 +- homeassistant/components/habitica/const.py | 8 +- .../components/habitica/coordinator.py | 98 +- .../components/habitica/diagnostics.py | 4 +- .../components/habitica/manifest.json | 4 +- homeassistant/components/habitica/sensor.py | 83 +- homeassistant/components/habitica/services.py | 277 +-- .../components/habitica/strings.json | 6 +- homeassistant/components/habitica/switch.py | 8 +- homeassistant/components/habitica/todo.py | 114 +- homeassistant/components/habitica/util.py | 120 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/habitica/conftest.py | 204 ++- .../habitica/fixtures/anonymized.json | 661 +++++++ .../components/habitica/fixtures/content.json | 187 +- tests/components/habitica/fixtures/login.json | 10 + .../habitica/fixtures/party_quest.json | 31 + tests/components/habitica/fixtures/task.json | 51 + tests/components/habitica/fixtures/user.json | 3 +- .../habitica/fixtures/user_no_party.json | 86 + .../snapshots/test_binary_sensor.ambr | 2 +- .../habitica/snapshots/test_button.ambr | 56 +- .../habitica/snapshots/test_calendar.ambr | 8 +- .../habitica/snapshots/test_diagnostics.ambr | 1573 ++++++++++------- .../habitica/snapshots/test_sensor.ambr | 451 ++++- .../habitica/snapshots/test_switch.ambr | 2 +- .../habitica/snapshots/test_todo.ambr | 4 +- .../components/habitica/test_binary_sensor.py | 24 +- tests/components/habitica/test_button.py | 215 +-- tests/components/habitica/test_calendar.py | 8 +- tests/components/habitica/test_config_flow.py | 234 ++- tests/components/habitica/test_diagnostics.py | 2 +- tests/components/habitica/test_init.py | 68 +- tests/components/habitica/test_sensor.py | 4 +- tests/components/habitica/test_services.py | 390 ++-- tests/components/habitica/test_switch.py | 41 +- tests/components/habitica/test_todo.py | 278 ++- 42 files changed, 3664 insertions(+), 2100 deletions(-) create mode 100644 tests/components/habitica/fixtures/anonymized.json create mode 100644 tests/components/habitica/fixtures/login.json create mode 100644 tests/components/habitica/fixtures/party_quest.json create mode 100644 tests/components/habitica/fixtures/task.json create mode 100644 tests/components/habitica/fixtures/user_no_party.json diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 5843e14d63e..91411e18b16 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,27 +1,15 @@ """The habitica integration.""" -from http import HTTPStatus - -from aiohttp import ClientResponseError -from habitipy.aio import HabitipyAsync +from habiticalib import Habitica from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - APPLICATION_NAME, - CONF_API_KEY, - CONF_NAME, - CONF_URL, - CONF_VERIFY_SSL, - Platform, - __version__, -) +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import CONF_API_USER, DEVELOPER_ID, DOMAIN +from .const import CONF_API_USER, DOMAIN, X_CLIENT from .coordinator import HabiticaDataUpdateCoordinator from .services import async_setup_services from .types import HabiticaConfigEntry @@ -51,47 +39,17 @@ async def async_setup_entry( ) -> bool: """Set up habitica from a config entry.""" - class HAHabitipyAsync(HabitipyAsync): - """Closure API class to hold session.""" - - def __call__(self, **kwargs): - return super().__call__(websession, **kwargs) - - def _make_headers(self) -> dict[str, str]: - headers = super()._make_headers() - headers.update( - {"x-client": f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"} - ) - return headers - - websession = async_get_clientsession( + session = async_get_clientsession( hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) ) - api = await hass.async_add_executor_job( - HAHabitipyAsync, - { - "url": config_entry.data[CONF_URL], - "login": config_entry.data[CONF_API_USER], - "password": config_entry.data[CONF_API_KEY], - }, + api = Habitica( + session, + api_user=config_entry.data[CONF_API_USER], + api_key=config_entry.data[CONF_API_KEY], + url=config_entry.data[CONF_URL], + x_client=X_CLIENT, ) - try: - user = await api.user.get(userFields="profile") - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e - raise ConfigEntryNotReady(e) from e - - if not config_entry.data.get(CONF_NAME): - name = user["profile"]["name"] - hass.config_entries.async_update_entry( - config_entry, - data={**config_entry.data, CONF_NAME: name}, - ) coordinator = HabiticaDataUpdateCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index bc79370ea63..bf42348e2b8 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -5,7 +5,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from typing import Any + +from habiticalib import UserData from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -23,8 +24,8 @@ from .types import HabiticaConfigEntry class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription): """Habitica Binary Sensor Description.""" - value_fn: Callable[[dict[str, Any]], bool | None] - entity_picture: Callable[[dict[str, Any]], str | None] + value_fn: Callable[[UserData], bool | None] + entity_picture: Callable[[UserData], str | None] class HabiticaBinarySensor(StrEnum): @@ -33,10 +34,10 @@ class HabiticaBinarySensor(StrEnum): PENDING_QUEST = "pending_quest" -def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None: +def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None: """Entity picture for pending quest invitation.""" - if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]: - return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png" + if user.party.quest.key and user.party.quest.RSVPNeeded: + return f"inventory_quest_scroll_{user.party.quest.key}.png" return None @@ -44,7 +45,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] = HabiticaBinarySensorEntityDescription( key=HabiticaBinarySensor.PENDING_QUEST, translation_key=HabiticaBinarySensor.PENDING_QUEST, - value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"], + value_fn=lambda user: user.party.quest.RSVPNeeded, entity_picture=get_scroll_image_for_pending_quest_invitation, ), ) diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index 2b9a4199133..d7e9175e577 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -5,10 +5,17 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from http import HTTPStatus from typing import Any -from aiohttp import ClientResponseError +from aiohttp import ClientError +from habiticalib import ( + HabiticaClass, + HabiticaException, + NotAuthorizedError, + Skill, + TaskType, + TooManyRequestsError, +) from homeassistant.components.button import ( DOMAIN as BUTTON_DOMAIN, @@ -20,7 +27,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ASSETS_URL, DOMAIN, HEALER, MAGE, ROGUE, WARRIOR +from .const import ASSETS_URL, DOMAIN from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator from .entity import HabiticaBase from .types import HabiticaConfigEntry @@ -34,7 +41,7 @@ class HabiticaButtonEntityDescription(ButtonEntityDescription): press_fn: Callable[[HabiticaDataUpdateCoordinator], Any] available_fn: Callable[[HabiticaData], bool] - class_needed: str | None = None + class_needed: HabiticaClass | None = None entity_picture: str | None = None @@ -63,35 +70,33 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabitipyButtonEntity.RUN_CRON, translation_key=HabitipyButtonEntity.RUN_CRON, - press_fn=lambda coordinator: coordinator.api.cron.post(), - available_fn=lambda data: data.user["needsCron"], + press_fn=lambda coordinator: coordinator.habitica.run_cron(), + available_fn=lambda data: data.user.needsCron is True, ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.BUY_HEALTH_POTION, translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION, - press_fn=( - lambda coordinator: coordinator.api["user"]["buy-health-potion"].post() - ), + press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(), available_fn=( - lambda data: data.user["stats"]["gp"] >= 25 - and data.user["stats"]["hp"] < 50 + lambda data: (data.user.stats.gp or 0) >= 25 + and (data.user.stats.hp or 0) < 50 ), entity_picture="shop_potion.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS, translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS, - press_fn=lambda coordinator: coordinator.api["user"]["allocate-now"].post(), + press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(), available_fn=( - lambda data: data.user["preferences"].get("automaticAllocation") is True - and data.user["stats"]["points"] > 0 + lambda data: data.user.preferences.automaticAllocation is True + and (data.user.stats.points or 0) > 0 ), ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.REVIVE, translation_key=HabitipyButtonEntity.REVIVE, - press_fn=lambda coordinator: coordinator.api["user"]["revive"].post(), - available_fn=lambda data: data.user["stats"]["hp"] == 0, + press_fn=lambda coordinator: coordinator.habitica.revive(), + available_fn=lambda data: data.user.stats.hp == 0, ), ) @@ -100,166 +105,170 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabitipyButtonEntity.MPHEAL, translation_key=HabitipyButtonEntity.MPHEAL, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["mpheal"].post(), - available_fn=( - lambda data: data.user["stats"]["lvl"] >= 12 - and data.user["stats"]["mp"] >= 30 + press_fn=( + lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE) ), - class_needed=MAGE, + available_fn=( + lambda data: (data.user.stats.lvl or 0) >= 12 + and (data.user.stats.mp or 0) >= 30 + ), + class_needed=HabiticaClass.MAGE, entity_picture="shop_mpheal.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.EARTH, translation_key=HabitipyButtonEntity.EARTH, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["earth"].post(), + press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 13 - and data.user["stats"]["mp"] >= 35 + lambda data: (data.user.stats.lvl or 0) >= 13 + and (data.user.stats.mp or 0) >= 35 ), - class_needed=MAGE, + class_needed=HabiticaClass.MAGE, entity_picture="shop_earth.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.FROST, translation_key=HabitipyButtonEntity.FROST, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["frost"].post(), + press_fn=( + lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST) + ), # chilling frost can only be cast once per day (streaks buff is false) available_fn=( - lambda data: data.user["stats"]["lvl"] >= 14 - and data.user["stats"]["mp"] >= 40 - and not data.user["stats"]["buffs"]["streaks"] + lambda data: (data.user.stats.lvl or 0) >= 14 + and (data.user.stats.mp or 0) >= 40 + and not data.user.stats.buffs.streaks ), - class_needed=MAGE, + class_needed=HabiticaClass.MAGE, entity_picture="shop_frost.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.DEFENSIVE_STANCE, translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast[ - "defensiveStance" - ].post() + lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 12 - and data.user["stats"]["mp"] >= 25 + lambda data: (data.user.stats.lvl or 0) >= 12 + and (data.user.stats.mp or 0) >= 25 ), - class_needed=WARRIOR, + class_needed=HabiticaClass.WARRIOR, entity_picture="shop_defensiveStance.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.VALOROUS_PRESENCE, translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast[ - "valorousPresence" - ].post() + lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 13 - and data.user["stats"]["mp"] >= 20 + lambda data: (data.user.stats.lvl or 0) >= 13 + and (data.user.stats.mp or 0) >= 20 ), - class_needed=WARRIOR, + class_needed=HabiticaClass.WARRIOR, entity_picture="shop_valorousPresence.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.INTIMIDATE, translation_key=HabitipyButtonEntity.INTIMIDATE, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["intimidate"].post() + lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 14 - and data.user["stats"]["mp"] >= 15 + lambda data: (data.user.stats.lvl or 0) >= 14 + and (data.user.stats.mp or 0) >= 15 ), - class_needed=WARRIOR, + class_needed=HabiticaClass.WARRIOR, entity_picture="shop_intimidate.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.TOOLS_OF_TRADE, translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["toolsOfTrade"].post() + lambda coordinator: coordinator.habitica.cast_skill( + Skill.TOOLS_OF_THE_TRADE + ) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 13 - and data.user["stats"]["mp"] >= 25 + lambda data: (data.user.stats.lvl or 0) >= 13 + and (data.user.stats.mp or 0) >= 25 ), - class_needed=ROGUE, + class_needed=HabiticaClass.ROGUE, entity_picture="shop_toolsOfTrade.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.STEALTH, translation_key=HabitipyButtonEntity.STEALTH, - press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["stealth"].post() - ), + press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH), # Stealth buffs stack and it can only be cast if the amount of - # unfinished dailies is smaller than the amount of buffs + # buffs is smaller than the amount of unfinished dailies available_fn=( - lambda data: data.user["stats"]["lvl"] >= 14 - and data.user["stats"]["mp"] >= 45 - and data.user["stats"]["buffs"]["stealth"] + lambda data: (data.user.stats.lvl or 0) >= 14 + and (data.user.stats.mp or 0) >= 45 + and (data.user.stats.buffs.stealth or 0) < len( [ r for r in data.tasks - if r.get("type") == "daily" - and r.get("isDue") is True - and r.get("completed") is False + if r.Type is TaskType.DAILY + and r.isDue is True + and r.completed is False ] ) ), - class_needed=ROGUE, + class_needed=HabiticaClass.ROGUE, entity_picture="shop_stealth.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.HEAL, translation_key=HabitipyButtonEntity.HEAL, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["heal"].post(), - available_fn=( - lambda data: data.user["stats"]["lvl"] >= 11 - and data.user["stats"]["mp"] >= 15 - and data.user["stats"]["hp"] < 50 + press_fn=( + lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT) ), - class_needed=HEALER, + available_fn=( + lambda data: (data.user.stats.lvl or 0) >= 11 + and (data.user.stats.mp or 0) >= 15 + and (data.user.stats.hp or 0) < 50 + ), + class_needed=HabiticaClass.HEALER, entity_picture="shop_heal.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.BRIGHTNESS, translation_key=HabitipyButtonEntity.BRIGHTNESS, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["brightness"].post() + lambda coordinator: coordinator.habitica.cast_skill( + Skill.SEARING_BRIGHTNESS + ) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 12 - and data.user["stats"]["mp"] >= 15 + lambda data: (data.user.stats.lvl or 0) >= 12 + and (data.user.stats.mp or 0) >= 15 ), - class_needed=HEALER, + class_needed=HabiticaClass.HEALER, entity_picture="shop_brightness.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.PROTECT_AURA, translation_key=HabitipyButtonEntity.PROTECT_AURA, press_fn=( - lambda coordinator: coordinator.api.user.class_.cast["protectAura"].post() + lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA) ), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 13 - and data.user["stats"]["mp"] >= 30 + lambda data: (data.user.stats.lvl or 0) >= 13 + and (data.user.stats.mp or 0) >= 30 ), - class_needed=HEALER, + class_needed=HabiticaClass.HEALER, entity_picture="shop_protectAura.png", ), HabiticaButtonEntityDescription( key=HabitipyButtonEntity.HEAL_ALL, translation_key=HabitipyButtonEntity.HEAL_ALL, - press_fn=lambda coordinator: coordinator.api.user.class_.cast["healAll"].post(), + press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING), available_fn=( - lambda data: data.user["stats"]["lvl"] >= 14 - and data.user["stats"]["mp"] >= 25 + lambda data: (data.user.stats.lvl or 0) >= 14 + and (data.user.stats.mp or 0) >= 25 ), - class_needed=HEALER, + class_needed=HabiticaClass.HEALER, entity_picture="shop_healAll.png", ), ) @@ -285,10 +294,10 @@ async def async_setup_entry( for description in CLASS_SKILLS: if ( - coordinator.data.user["stats"]["lvl"] >= 10 - and coordinator.data.user["flags"]["classSelected"] - and not coordinator.data.user["preferences"]["disableClasses"] - and description.class_needed == coordinator.data.user["stats"]["class"] + (coordinator.data.user.stats.lvl or 0) >= 10 + and coordinator.data.user.flags.classSelected + and not coordinator.data.user.preferences.disableClasses + and description.class_needed is coordinator.data.user.stats.Class ): if description.key not in skills_added: buttons.append(HabiticaButton(coordinator, description)) @@ -322,17 +331,17 @@ class HabiticaButton(HabiticaBase, ButtonEntity): """Handle the button press.""" try: await self.entity_description.press_fn(self.coordinator) - 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="service_call_unallowed", - ) from e + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_call_unallowed", + ) from e + except (HabiticaException, ClientError) as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index ff483b71fd8..46191acf270 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -5,8 +5,11 @@ from __future__ import annotations from abc import abstractmethod from datetime import date, datetime, timedelta from enum import StrEnum +from typing import TYPE_CHECKING +from uuid import UUID from dateutil.rrule import rrule +from habiticalib import TaskType from homeassistant.components.calendar import ( CalendarEntity, @@ -20,7 +23,6 @@ from homeassistant.util import dt as dt_util from . import HabiticaConfigEntry from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase -from .types import HabiticaTaskType from .util import build_rrule, get_recurrence_rule @@ -83,9 +85,7 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity): @property def start_of_today(self) -> datetime: """Habitica daystart.""" - return dt_util.start_of_local_day( - datetime.fromisoformat(self.coordinator.data.user["lastCron"]) - ) + return dt_util.start_of_local_day(self.coordinator.data.user.lastCron) def get_recurrence_dates( self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None @@ -115,13 +115,13 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity): events = [] for task in self.coordinator.data.tasks: if not ( - task["type"] == HabiticaTaskType.TODO - and not task["completed"] - and task.get("date") # only if has due date + task.Type is TaskType.TODO + and not task.completed + and task.date is not None # only if has due date ): continue - start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"])) + start = dt_util.start_of_local_day(task.date) end = start + timedelta(days=1) # return current and upcoming events or events within the requested range @@ -132,21 +132,23 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity): if end_date and start > end_date: # Event starts after date range continue - + if TYPE_CHECKING: + assert task.text + assert task.id events.append( CalendarEvent( start=start.date(), end=end.date(), - summary=task["text"], - description=task["notes"], - uid=task["id"], + summary=task.text, + description=task.notes, + uid=str(task.id), ) ) return sorted( events, key=lambda event: ( event.start, - self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid), + self.coordinator.data.user.tasksOrder.todos.index(UUID(event.uid)), ), ) @@ -189,7 +191,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): events = [] for task in self.coordinator.data.tasks: # only dailies that that are not 'grey dailies' - if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]): + if not (task.Type is TaskType.DAILY and task.everyX): continue recurrences = build_rrule(task) @@ -199,19 +201,21 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): for recurrence in recurrence_dates: is_future_event = recurrence > self.start_of_today is_current_event = ( - recurrence <= self.start_of_today and not task["completed"] + recurrence <= self.start_of_today and not task.completed ) if not is_future_event and not is_current_event: continue - + if TYPE_CHECKING: + assert task.text + assert task.id events.append( CalendarEvent( start=recurrence.date(), end=self.end_date(recurrence, end_date), - summary=task["text"], - description=task["notes"], - uid=task["id"], + summary=task.text, + description=task.notes, + uid=str(task.id), rrule=get_recurrence_rule(recurrences), ) ) @@ -219,7 +223,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity): events, key=lambda event: ( event.start, - self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid), + self.coordinator.data.user.tasksOrder.dailys.index(UUID(event.uid)), ), ) @@ -254,14 +258,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity): events = [] for task in self.coordinator.data.tasks: - if task["type"] != HabiticaTaskType.TODO or task["completed"]: + if task.Type is not TaskType.TODO or task.completed: continue - for reminder in task.get("reminders", []): + for reminder in task.reminders: # reminders are returned by the API in local time but with wrong # timezone (UTC) and arbitrary added seconds/microseconds. When # creating reminders in Habitica only hours and minutes can be defined. - start = datetime.fromisoformat(reminder["time"]).replace( + start = reminder.time.replace( tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0 ) end = start + timedelta(hours=1) @@ -273,14 +277,16 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity): if end_date and start > end_date: # Event starts after date range continue - + if TYPE_CHECKING: + assert task.text + assert task.id events.append( CalendarEvent( start=start, end=end, - summary=task["text"], - description=task["notes"], - uid=f"{task["id"]}_{reminder["id"]}", + summary=task.text, + description=task.notes, + uid=f"{task.id}_{reminder.id}", ) ) @@ -298,7 +304,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): translation_key=HabiticaCalendar.DAILY_REMINDERS, ) - def start(self, reminder_time: str, reminder_date: date) -> datetime: + def start(self, reminder_time: datetime, reminder_date: date) -> datetime: """Generate reminder times for dailies. Reminders for dailies have a datetime but the date part is arbitrary, @@ -307,12 +313,10 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): """ return datetime.combine( reminder_date, - datetime.fromisoformat(reminder_time) - .replace( + reminder_time.replace( second=0, microsecond=0, - ) - .time(), + ).time(), tzinfo=dt_util.DEFAULT_TIME_ZONE, ) @@ -327,7 +331,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): start_date = max(start_date, self.start_of_today) for task in self.coordinator.data.tasks: - if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]): + if not (task.Type is TaskType.DAILY and task.everyX): continue recurrences = build_rrule(task) @@ -339,27 +343,30 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity): for recurrence in recurrence_dates: is_future_event = recurrence > self.start_of_today is_current_event = ( - recurrence <= self.start_of_today and not task["completed"] + recurrence <= self.start_of_today and not task.completed ) if not is_future_event and not is_current_event: continue - for reminder in task.get("reminders", []): - start = self.start(reminder["time"], recurrence) + for reminder in task.reminders: + start = self.start(reminder.time, recurrence) end = start + timedelta(hours=1) if end < start_date: # Event ends before date range continue + if TYPE_CHECKING: + assert task.id + assert task.text events.append( CalendarEvent( start=start, end=end, - summary=task["text"], - description=task["notes"], - uid=f"{task["id"]}_{reminder["id"]}", + summary=task.text, + description=task.notes, + uid=f"{task.id}_{reminder.id}", ) ) diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index d168a5f57b4..4acbad89eeb 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -2,17 +2,17 @@ from __future__ import annotations -from http import HTTPStatus import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from aiohttp import ClientResponseError -from habitipy.aio import HabitipyAsync +from aiohttp import ClientError +from habiticalib import Habitica, HabiticaException, NotAuthorizedError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, @@ -33,6 +33,7 @@ from .const import ( HABITICANS_URL, SIGN_UP_URL, SITE_DATA_URL, + X_CLIENT, ) STEP_ADVANCED_DATA_SCHEMA = vol.Schema( @@ -93,39 +94,33 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): """ errors: dict[str, str] = {} if user_input is not None: + session = async_get_clientsession(self.hass) + api = Habitica(session=session, x_client=X_CLIENT) try: - session = async_get_clientsession(self.hass) - api = await self.hass.async_add_executor_job( - HabitipyAsync, - { - "login": "", - "password": "", - "url": DEFAULT_URL, - }, - ) - login_response = await api.user.auth.local.login.post( - session=session, + login = await api.login( username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], ) + user = await api.get_user(user_fields="profile") - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except (HabiticaException, ClientError): + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(login_response["id"]) + await self.async_set_unique_id(str(login.data.id)) self._abort_if_unique_id_configured() + if TYPE_CHECKING: + assert user.data.profile.name return self.async_create_entry( - title=login_response["username"], + title=user.data.profile.name, data={ - CONF_API_USER: login_response["id"], - CONF_API_KEY: login_response["apiToken"], - CONF_USERNAME: login_response["username"], + CONF_API_USER: str(login.data.id), + CONF_API_KEY: login.data.apiToken, + CONF_NAME: user.data.profile.name, # needed for api_call action CONF_URL: DEFAULT_URL, CONF_VERIFY_SSL: True, }, @@ -150,36 +145,37 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): """ errors: dict[str, str] = {} if user_input is not None: + session = async_get_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ) try: - session = async_get_clientsession( - self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True) - ) - api = await self.hass.async_add_executor_job( - HabitipyAsync, - { - "login": user_input[CONF_API_USER], - "password": user_input[CONF_API_KEY], - "url": user_input.get(CONF_URL, DEFAULT_URL), - }, - ) - api_response = await api.user.get( + api = Habitica( session=session, - userFields="auth", + x_client=X_CLIENT, + api_user=user_input[CONF_API_USER], + api_key=user_input[CONF_API_KEY], + url=user_input.get(CONF_URL, DEFAULT_URL), ) - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" + user = await api.get_user(user_fields="profile") + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except (HabiticaException, ClientError): + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id(user_input[CONF_API_USER]) self._abort_if_unique_id_configured() - user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"] + if TYPE_CHECKING: + assert user.data.profile.name return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input + title=user.data.profile.name, + data={ + **user_input, + CONF_URL: user_input.get(CONF_URL, DEFAULT_URL), + CONF_NAME: user.data.profile.name, # needed for api_call action + }, ) return self.async_show_form( diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 42d64ca7d3f..76cf4b7beb1 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -1,6 +1,6 @@ """Constants for the habitica integration.""" -from homeassistant.const import CONF_PATH +from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__ CONF_API_USER = "api_user" @@ -44,9 +44,5 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" -WARRIOR = "warrior" -ROGUE = "rogue" -HEALER = "healer" -MAGE = "wizard" - DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" +X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index f9ffb1b53bd..2e37cb5d907 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -5,16 +5,25 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from http import HTTPStatus import logging from typing import Any -from aiohttp import ClientResponseError -from habitipy.aio import HabitipyAsync +from aiohttp import ClientError +from habiticalib import ( + ContentData, + Habitica, + HabiticaException, + NotAuthorizedError, + TaskData, + TaskFilter, + TooManyRequestsError, + UserData, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,10 +34,10 @@ _LOGGER = logging.getLogger(__name__) @dataclass class HabiticaData: - """Coordinator data class.""" + """Habitica data.""" - user: dict[str, Any] - tasks: list[dict] + user: UserData + tasks: list[TaskData] class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): @@ -36,7 +45,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None: + def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None: """Initialize the Habitica data coordinator.""" super().__init__( hass, @@ -50,25 +59,54 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): immediate=False, ), ) - self.api = habitipy - self.content: dict[str, Any] = {} + self.habitica = habitica + self.content: ContentData + + async def _async_setup(self) -> None: + """Set up Habitica integration.""" + + try: + user = await self.habitica.get_user() + self.content = ( + await self.habitica.get_content(user.data.preferences.language) + ).data + except NotAuthorizedError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"account": self.config_entry.title}, + ) from e + except TooManyRequestsError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + except (HabiticaException, ClientError) as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + + if not self.config_entry.data.get(CONF_NAME): + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, CONF_NAME: user.data.profile.name}, + ) async def _async_update_data(self) -> HabiticaData: try: - user_response = await self.api.user.get() - tasks_response = await self.api.tasks.user.get() - tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) - if not self.content: - self.content = await self.api.content.get( - language=user_response["preferences"]["language"] - ) - except ClientResponseError as error: - if error.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.debug("Rate limit exceeded, will try again later") - return self.data - raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error - - return HabiticaData(user=user_response, tasks=tasks_response) + user = (await self.habitica.get_user()).data + tasks = (await self.habitica.get_tasks()).data + completed_todos = ( + await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS) + ).data + except TooManyRequestsError: + _LOGGER.debug("Rate limit exceeded, will try again later") + return self.data + except (HabiticaException, ClientError) as e: + raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e + else: + return HabiticaData(user=user, tasks=tasks + completed_todos) async def execute( self, func: Callable[[HabiticaDataUpdateCoordinator], Any] @@ -77,12 +115,12 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): try: await func(self) - except ClientResponseError as e: - if e.status == HTTPStatus.TOO_MANY_REQUESTS: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - ) from e + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + except (HabiticaException, ClientError) as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py index bca79946503..abfa0f35c4b 100644 --- a/homeassistant/components/habitica/diagnostics.py +++ b/homeassistant/components/habitica/diagnostics.py @@ -16,12 +16,12 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - habitica_data = await config_entry.runtime_data.api.user.anonymized.get() + habitica_data = await config_entry.runtime_data.habitica.get_user_anonymized() return { "config_entry_data": { CONF_URL: config_entry.data[CONF_URL], CONF_API_USER: config_entry.data[CONF_API_USER], }, - "habitica_data": habitica_data, + "habitica_data": habitica_data.to_dict()["data"], } diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index a01697c3945..94ee90ce209 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", - "loggers": ["habitipy", "plumbum"], - "requirements": ["habitipy==0.3.3"] + "loggers": ["habiticalib"], + "requirements": ["habiticalib==0.3.1"] } diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index bead15d109b..8d08bc09f4b 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -3,11 +3,20 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import asdict, dataclass from enum import StrEnum import logging from typing import TYPE_CHECKING, Any +from habiticalib import ( + ContentData, + HabiticaClass, + TaskData, + TaskType, + UserData, + deserialize_task, +) + from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, @@ -36,10 +45,10 @@ _LOGGER = logging.getLogger(__name__) class HabitipySensorEntityDescription(SensorEntityDescription): """Habitipy Sensor Description.""" - value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType] - attributes_fn: ( - Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None - ) = None + value_fn: Callable[[UserData, ContentData], StateType] + attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = ( + None + ) entity_picture: str | None = None @@ -47,7 +56,7 @@ class HabitipySensorEntityDescription(SensorEntityDescription): class HabitipyTaskSensorEntityDescription(SensorEntityDescription): """Habitipy Task Sensor Description.""" - value_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]] + value_fn: Callable[[list[TaskData]], list[TaskData]] class HabitipySensorEntity(StrEnum): @@ -79,75 +88,70 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = ( HabitipySensorEntityDescription( key=HabitipySensorEntity.DISPLAY_NAME, translation_key=HabitipySensorEntity.DISPLAY_NAME, - value_fn=lambda user, _: user.get("profile", {}).get("name"), + value_fn=lambda user, _: user.profile.name, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.HEALTH, translation_key=HabitipySensorEntity.HEALTH, suggested_display_precision=0, - value_fn=lambda user, _: user.get("stats", {}).get("hp"), + value_fn=lambda user, _: user.stats.hp, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.HEALTH_MAX, translation_key=HabitipySensorEntity.HEALTH_MAX, entity_registry_enabled_default=False, - value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"), + value_fn=lambda user, _: 50, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.MANA, translation_key=HabitipySensorEntity.MANA, suggested_display_precision=0, - value_fn=lambda user, _: user.get("stats", {}).get("mp"), + value_fn=lambda user, _: user.stats.mp, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.MANA_MAX, translation_key=HabitipySensorEntity.MANA_MAX, - value_fn=lambda user, _: user.get("stats", {}).get("maxMP"), + value_fn=lambda user, _: user.stats.maxMP, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.EXPERIENCE, translation_key=HabitipySensorEntity.EXPERIENCE, - value_fn=lambda user, _: user.get("stats", {}).get("exp"), + value_fn=lambda user, _: user.stats.exp, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.EXPERIENCE_MAX, translation_key=HabitipySensorEntity.EXPERIENCE_MAX, - value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"), + value_fn=lambda user, _: user.stats.toNextLevel, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.LEVEL, translation_key=HabitipySensorEntity.LEVEL, - value_fn=lambda user, _: user.get("stats", {}).get("lvl"), + value_fn=lambda user, _: user.stats.lvl, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.GOLD, translation_key=HabitipySensorEntity.GOLD, suggested_display_precision=2, - value_fn=lambda user, _: user.get("stats", {}).get("gp"), + value_fn=lambda user, _: user.stats.gp, ), HabitipySensorEntityDescription( key=HabitipySensorEntity.CLASS, translation_key=HabitipySensorEntity.CLASS, - value_fn=lambda user, _: user.get("stats", {}).get("class"), + value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None, device_class=SensorDeviceClass.ENUM, - options=["warrior", "healer", "wizard", "rogue"], + options=[item.value for item in HabiticaClass], ), HabitipySensorEntityDescription( key=HabitipySensorEntity.GEMS, translation_key=HabitipySensorEntity.GEMS, - value_fn=lambda user, _: user.get("balance", 0) * 4, + value_fn=lambda user, _: round(user.balance * 4) if user.balance else None, suggested_display_precision=0, entity_picture="shop_gem.png", ), HabitipySensorEntityDescription( key=HabitipySensorEntity.TRINKETS, translation_key=HabitipySensorEntity.TRINKETS, - value_fn=( - lambda user, _: user.get("purchased", {}) - .get("plan", {}) - .get("consecutive", {}) - .get("trinkets", 0) - ), + value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets or 0, suggested_display_precision=0, native_unit_of_measurement="⧖", entity_picture="notif_subscriber_reward.png", @@ -155,16 +159,16 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = ( HabitipySensorEntityDescription( key=HabitipySensorEntity.STRENGTH, translation_key=HabitipySensorEntity.STRENGTH, - value_fn=lambda user, content: get_attributes_total(user, content, "str"), - attributes_fn=lambda user, content: get_attribute_points(user, content, "str"), + value_fn=lambda user, content: get_attributes_total(user, content, "Str"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"), suggested_display_precision=0, native_unit_of_measurement="STR", ), HabitipySensorEntityDescription( key=HabitipySensorEntity.INTELLIGENCE, translation_key=HabitipySensorEntity.INTELLIGENCE, - value_fn=lambda user, content: get_attributes_total(user, content, "int"), - attributes_fn=lambda user, content: get_attribute_points(user, content, "int"), + value_fn=lambda user, content: get_attributes_total(user, content, "Int"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"), suggested_display_precision=0, native_unit_of_measurement="INT", ), @@ -203,7 +207,7 @@ TASKS_MAP = { "yester_daily": "yesterDaily", "completed": "completed", "collapse_checklist": "collapseChecklist", - "type": "type", + "type": "Type", "notes": "notes", "tags": "tags", "value": "value", @@ -221,26 +225,28 @@ TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = ( HabitipyTaskSensorEntityDescription( key=HabitipySensorEntity.HABITS, translation_key=HabitipySensorEntity.HABITS, - value_fn=lambda tasks: [r for r in tasks if r.get("type") == "habit"], + value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT], ), HabitipyTaskSensorEntityDescription( key=HabitipySensorEntity.DAILIES, translation_key=HabitipySensorEntity.DAILIES, - value_fn=lambda tasks: [r for r in tasks if r.get("type") == "daily"], + value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.DAILY], entity_registry_enabled_default=False, ), HabitipyTaskSensorEntityDescription( key=HabitipySensorEntity.TODOS, translation_key=HabitipySensorEntity.TODOS, - value_fn=lambda tasks: [ - r for r in tasks if r.get("type") == "todo" and not r.get("completed") - ], + value_fn=( + lambda tasks: [ + r for r in tasks if r.Type is TaskType.TODO and not r.completed + ] + ), entity_registry_enabled_default=False, ), HabitipyTaskSensorEntityDescription( key=HabitipySensorEntity.REWARDS, translation_key=HabitipySensorEntity.REWARDS, - value_fn=lambda tasks: [r for r in tasks if r.get("type") == "reward"], + value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD], ), ) @@ -309,15 +315,14 @@ class HabitipyTaskSensor(HabiticaBase, SensorEntity): attrs = {} # Map tasks to TASKS_MAP - for received_task in self.entity_description.value_fn( - self.coordinator.data.tasks - ): + for task_data in self.entity_description.value_fn(self.coordinator.data.tasks): + received_task = deserialize_task(asdict(task_data)) task_id = received_task[TASKS_MAP_ID] task = {} for map_key, map_value in TASKS_MAP.items(): if value := received_task.get(map_value): task[map_key] = value - attrs[task_id] = task + attrs[str(task_id)] = task return attrs async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 7f2d66e4690..b2ba7218c38 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -2,11 +2,19 @@ from __future__ import annotations -from http import HTTPStatus +from dataclasses import asdict import logging -from typing import Any +from typing import TYPE_CHECKING -from aiohttp import ClientResponseError +from aiohttp import ClientError +from habiticalib import ( + Direction, + HabiticaException, + NotAuthorizedError, + NotFoundError, + Skill, + TooManyRequestsError, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -88,6 +96,25 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) +SKILL_MAP = { + "pickpocket": Skill.PICKPOCKET, + "backstab": Skill.BACKSTAB, + "smash": Skill.BRUTAL_SMASH, + "fireball": Skill.BURST_OF_FLAMES, +} +COST_MAP = { + "pickpocket": "10 MP", + "backstab": "15 MP", + "smash": "10 MP", + "fireball": "10 MP", +} +ITEMID_MAP = { + "snowball": Skill.SNOWBALL, + "spooky_sparkles": Skill.SPOOKY_SPARKLES, + "seafoam": Skill.SEAFOAM, + "shiny_seed": Skill.SHINY_SEED, +} + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -123,12 +150,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] - entries = hass.config_entries.async_entries(DOMAIN) + entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN) api = None for entry in entries: if entry.data[CONF_NAME] == name: - api = entry.runtime_data.api + api = await entry.runtime_data.habitica.habitipy() break if api is None: _LOGGER.error("API_CALL: User '%s' not configured", name) @@ -151,18 +178,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Skill action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data - skill = { - "pickpocket": {"spellId": "pickPocket", "cost": "10 MP"}, - "backstab": {"spellId": "backStab", "cost": "15 MP"}, - "smash": {"spellId": "smash", "cost": "10 MP"}, - "fireball": {"spellId": "fireball", "cost": "10 MP"}, - } + + skill = SKILL_MAP[call.data[ATTR_SKILL]] + cost = COST_MAP[call.data[ATTR_SKILL]] + try: task_id = next( - task["id"] + task.id for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (task["id"], task.get("alias")) - or call.data[ATTR_TASK] == task["text"] + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) ) except StopIteration as e: raise ServiceValidationError( @@ -172,75 +196,76 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 ) from e try: - response: dict[str, Any] = await coordinator.api.user.class_.cast[ - skill[call.data[ATTR_SKILL]]["spellId"] - ].post(targetId=task_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="not_enough_mana", - translation_placeholders={ - "cost": skill[call.data[ATTR_SKILL]]["cost"], - "mana": f"{int(coordinator.data.user.get("stats", {}).get("mp", 0))} MP", - }, - ) from e - if e.status == HTTPStatus.NOT_FOUND: - # could also be task not found, but the task is looked up - # before the request, so most likely wrong skill selected - # or the skill hasn't been unlocked yet. - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="skill_not_found", - translation_placeholders={"skill": call.data[ATTR_SKILL]}, - ) from e + response = await coordinator.habitica.cast_skill(skill, task_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_enough_mana", + translation_placeholders={ + "cost": cost, + "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP", + }, + ) from e + except NotFoundError as e: + # could also be task not found, but the task is looked up + # before the request, so most likely wrong skill selected + # or the skill hasn't been unlocked yet. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="skill_not_found", + translation_placeholders={"skill": call.data[ATTR_SKILL]}, + ) from e + except (HabiticaException, ClientError) as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", ) from e else: await coordinator.async_request_refresh() - return response + return asdict(response.data) async def manage_quests(call: ServiceCall) -> ServiceResponse: """Accept, reject, start, leave or cancel quests.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data - COMMAND_MAP = { - SERVICE_ABORT_QUEST: "abort", - SERVICE_ACCEPT_QUEST: "accept", - SERVICE_CANCEL_QUEST: "cancel", - SERVICE_LEAVE_QUEST: "leave", - SERVICE_REJECT_QUEST: "reject", - SERVICE_START_QUEST: "force-start", + FUNC_MAP = { + SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, + SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, + SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, + SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest, + SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest, + SERVICE_START_QUEST: coordinator.habitica.start_quest, } + + func = FUNC_MAP[call.service] + try: - return await coordinator.api.groups.party.quests[ - COMMAND_MAP[call.service] - ].post() - 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="quest_action_unallowed" - ) from e - if e.status == HTTPStatus.NOT_FOUND: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_not_found" - ) from e + response = await func() + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_action_unallowed" + ) from e + except NotFoundError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_not_found" + ) from e + except (HabiticaException, ClientError) as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception" ) from e + else: + return asdict(response.data) for service in ( SERVICE_ABORT_QUEST, @@ -262,12 +287,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Score a task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data + + direction = ( + Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP + ) try: task_id, task_value = next( - (task["id"], task.get("value")) + (task.id, task.value) for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (task["id"], task.get("alias")) - or call.data[ATTR_TASK] == task["text"] + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) ) except StopIteration as e: raise ServiceValidationError( @@ -276,81 +304,76 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, ) from e + if TYPE_CHECKING: + assert task_id try: - response: dict[str, Any] = ( - await coordinator.api.tasks[task_id] - .score[call.data.get(ATTR_DIRECTION, "up")] - .post() - ) - 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 and task_value is not None: + response = await coordinator.habitica.update_score(task_id, direction) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + except NotAuthorizedError as e: + if task_value is not None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="not_enough_gold", translation_placeholders={ - "gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP", - "cost": f"{task_value} GP", + "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP", + "cost": f"{task_value:.2f} GP", }, ) from e raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", ) from e + except (HabiticaException, ClientError) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e else: await coordinator.async_request_refresh() - return response + return asdict(response.data) 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"}, - } + + item = ITEMID_MAP[call.data[ATTR_ITEM]] # 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"], + str(coordinator.data.user.id), + coordinator.data.user.profile.name, + coordinator.data.user.auth.local.username, ): - target_id = coordinator.data.user["id"] + 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 + party = await coordinator.habitica.get_group_members(public_fields=True) + except NotFoundError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="party_not_found", + ) from e + except (ClientError, HabiticaException) as 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() + member.id + for member in party.data + if member.id + and call.data[ATTR_TARGET].lower() in ( - member["id"], - member["auth"]["local"]["username"].lower(), - member["profile"]["name"].lower(), + str(member.id), + str(member.auth.local.username).lower(), + str(member.profile.name).lower(), ) ) except StopIteration as e: @@ -360,27 +383,25 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 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 + response = await coordinator.habitica.cast_skill(item, target_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": call.data[ATTR_ITEM]}, + ) from e + except (HabiticaException, ClientError) as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="service_call_exception", ) from e else: - return response + return asdict(response.data) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index f1b956fe17e..3154c0c4f56 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -49,7 +49,8 @@ "data_description": { "url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`", "api_user": "User ID of your Habitica account", - "api_key": "API Token of the Habitica account" + "api_key": "API Token of the Habitica account", + "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate" }, "description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to" } @@ -365,6 +366,9 @@ }, "item_not_found": { "message": "Unable to use {item}, you don't own this item." + }, + "invalid_auth": { + "message": "Authentication failed for {account}." } }, "issues": { diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py index de0cc533050..ddc0db27108 100644 --- a/homeassistant/components/habitica/switch.py +++ b/homeassistant/components/habitica/switch.py @@ -28,7 +28,7 @@ class HabiticaSwitchEntityDescription(SwitchEntityDescription): turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any] turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any] - is_on_fn: Callable[[HabiticaData], bool] + is_on_fn: Callable[[HabiticaData], bool | None] class HabiticaSwitchEntity(StrEnum): @@ -42,9 +42,9 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = ( key=HabiticaSwitchEntity.SLEEP, translation_key=HabiticaSwitchEntity.SLEEP, device_class=SwitchDeviceClass.SWITCH, - turn_on_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(), - turn_off_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(), - is_on_fn=lambda data: data.user["preferences"]["sleep"], + turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(), + turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(), + is_on_fn=lambda data: data.user.preferences.sleep, ), ) diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index 0ca5f723c45..a14327f5378 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -2,11 +2,12 @@ from __future__ import annotations -import datetime from enum import StrEnum from typing import TYPE_CHECKING +from uuid import UUID -from aiohttp import ClientResponseError +from aiohttp import ClientError +from habiticalib import Direction, HabiticaException, Task, TaskType from homeassistant.components import persistent_notification from homeassistant.components.todo import ( @@ -24,7 +25,7 @@ from homeassistant.util import dt as dt_util from .const import ASSETS_URL, DOMAIN from .coordinator import HabiticaDataUpdateCoordinator from .entity import HabiticaBase -from .types import HabiticaConfigEntry, HabiticaTaskType +from .types import HabiticaConfigEntry from .util import next_due_date PARALLEL_UPDATES = 1 @@ -70,8 +71,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): """Delete Habitica tasks.""" if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS: try: - await self.coordinator.api.tasks.clearCompletedTodos.post() - except ClientResponseError as e: + await self.coordinator.habitica.delete_completed_todos() + except (HabiticaException, ClientError) as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="delete_completed_todos_failed", @@ -79,8 +80,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): else: for task_id in uids: try: - await self.coordinator.api.tasks[task_id].delete() - except ClientResponseError as e: + await self.coordinator.habitica.delete_task(UUID(task_id)) + except (HabiticaException, ClientError) as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"delete_{self.entity_description.key}_failed", @@ -106,9 +107,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): pos = 0 try: - await self.coordinator.api.tasks[uid].move.to[str(pos)].post() - - except ClientResponseError as e: + await self.coordinator.habitica.reorder_task(UUID(uid), pos) + except (HabiticaException, ClientError) as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"move_{self.entity_description.key}_item_failed", @@ -118,12 +118,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): # move tasks in the coordinator until we have fresh data tasks = self.coordinator.data.tasks new_pos = ( - tasks.index(next(task for task in tasks if task["id"] == previous_uid)) + tasks.index( + next(task for task in tasks if task.id == UUID(previous_uid)) + ) + 1 if previous_uid else 0 ) - old_pos = tasks.index(next(task for task in tasks if task["id"] == uid)) + old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid))) tasks.insert(new_pos, tasks.pop(old_pos)) await self.coordinator.async_request_refresh() @@ -138,14 +140,17 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): if TYPE_CHECKING: assert item.uid assert current_item + assert item.summary + + task = Task( + text=item.summary, + notes=item.description or "", + ) if ( self.entity_description.key is HabiticaTodoList.TODOS - and item.due is not None ): # Only todos support a due date. - date = item.due.isoformat() - else: - date = None + task["date"] = item.due if ( item.summary != current_item.summary @@ -153,13 +158,9 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): or item.due != current_item.due ): try: - await self.coordinator.api.tasks[item.uid].put( - text=item.summary, - notes=item.description or "", - date=date, - ) + await self.coordinator.habitica.update_task(UUID(item.uid), task) refresh_required = True - except ClientResponseError as e: + except (HabiticaException, ClientError) as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"update_{self.entity_description.key}_item_failed", @@ -172,32 +173,33 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): current_item.status is TodoItemStatus.NEEDS_ACTION and item.status == TodoItemStatus.COMPLETED ): - score_result = ( - await self.coordinator.api.tasks[item.uid].score["up"].post() + score_result = await self.coordinator.habitica.update_score( + UUID(item.uid), Direction.UP ) refresh_required = True elif ( current_item.status is TodoItemStatus.COMPLETED and item.status == TodoItemStatus.NEEDS_ACTION ): - score_result = ( - await self.coordinator.api.tasks[item.uid].score["down"].post() + score_result = await self.coordinator.habitica.update_score( + UUID(item.uid), Direction.DOWN ) refresh_required = True else: score_result = None - except ClientResponseError as e: + except (HabiticaException, ClientError) as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"score_{self.entity_description.key}_item_failed", translation_placeholders={"name": item.summary or ""}, ) from e - if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)): + if score_result and score_result.data.tmp.drop.key: + drop = score_result.data.tmp.drop msg = ( - f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n" - f"{drop["dialog"]}" + f"![{drop.key}]({ASSETS_URL}Pet_{drop.Type}_{drop.key}.png)\n" + f"{drop.dialog}" ) persistent_notification.async_create( self.hass, message=msg, title="Habitica" @@ -229,38 +231,36 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): return [ *( TodoItem( - uid=task["id"], - summary=task["text"], - description=task["notes"], - due=( - dt_util.as_local( - datetime.datetime.fromisoformat(task["date"]) - ).date() - if task.get("date") - else None - ), + uid=str(task.id), + summary=task.text, + description=task.notes, + due=dt_util.as_local(task.date).date() if task.date else None, status=( TodoItemStatus.NEEDS_ACTION - if not task["completed"] + if not task.completed else TodoItemStatus.COMPLETED ), ) for task in self.coordinator.data.tasks - if task["type"] == HabiticaTaskType.TODO + if task.Type is TaskType.TODO ), ] async def async_create_todo_item(self, item: TodoItem) -> None: """Create a Habitica todo.""" - + if TYPE_CHECKING: + assert item.summary + assert item.description try: - await self.coordinator.api.tasks.user.post( - text=item.summary, - type=HabiticaTaskType.TODO, - notes=item.description, - date=item.due.isoformat() if item.due else None, + await self.coordinator.habitica.create_task( + Task( + text=item.summary, + type=TaskType.TODO, + notes=item.description, + date=item.due, + ) ) - except ClientResponseError as e: + except (HabiticaException, ClientError) as e: raise ServiceValidationError( translation_domain=DOMAIN, translation_key=f"create_{self.entity_description.key}_item_failed", @@ -295,23 +295,23 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): that have been completed but forgotten to mark as completed before resetting the dailies. Changes of the date input field in Home Assistant will be ignored. """ - - last_cron = self.coordinator.data.user["lastCron"] + if TYPE_CHECKING: + assert self.coordinator.data.user.lastCron return [ *( TodoItem( - uid=task["id"], - summary=task["text"], - description=task["notes"], - due=next_due_date(task, last_cron), + uid=str(task.id), + summary=task.text, + description=task.notes, + due=next_due_date(task, self.coordinator.data.user.lastCron), status=( TodoItemStatus.COMPLETED - if task["completed"] + if task.completed else TodoItemStatus.NEEDS_ACTION ), ) for task in self.coordinator.data.tasks - if task["type"] == HabiticaTaskType.DAILY + if task.Type is TaskType.DAILY ) ] diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index b2b4430c490..4c1e54639d0 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -2,9 +2,10 @@ from __future__ import annotations +from dataclasses import fields import datetime from math import floor -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from dateutil.rrule import ( DAILY, @@ -20,6 +21,7 @@ from dateutil.rrule import ( YEARLY, rrule, ) +from habiticalib import ContentData, Frequency, TaskData, UserData from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -27,50 +29,32 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None: +def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | None: """Calculate due date for dailies and yesterdailies.""" - if task["everyX"] == 0 or not task.get("nextDue"): # grey dailies never become due + if task.everyX == 0 or not task.nextDue: # grey dailies never become due return None - today = to_date(last_cron) - startdate = to_date(task["startDate"]) if TYPE_CHECKING: - assert today - assert startdate + assert task.startDate - if task["isDue"] and not task["completed"]: - return to_date(last_cron) + if task.isDue is True and not task.completed: + return dt_util.as_local(today).date() - if startdate > today: - if task["frequency"] == "daily" or ( - task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"] + if task.startDate > today: + if task.frequency is Frequency.DAILY or ( + task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth ): - return startdate + return dt_util.as_local(task.startDate).date() if ( - task["frequency"] in ("weekly", "monthly") - and (nextdue := to_date(task["nextDue"][0])) - and startdate > nextdue + task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY) + and (nextdue := task.nextDue[0]) + and task.startDate > nextdue ): - return to_date(task["nextDue"][1]) + return dt_util.as_local(task.nextDue[1]).date() - return to_date(task["nextDue"][0]) - - -def to_date(date: str) -> datetime.date | None: - """Convert an iso date to a datetime.date object.""" - try: - return dt_util.as_local(datetime.datetime.fromisoformat(date)).date() - except ValueError: - # sometimes nextDue dates are JavaScript datetime strings instead of iso: - # "Mon May 06 2024 00:00:00 GMT+0200" - try: - return dt_util.as_local( - datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z") - ).date() - except ValueError: - return None + return dt_util.as_local(task.nextDue[0]).date() def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: @@ -84,30 +68,27 @@ FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} -def build_rrule(task: dict[str, Any]) -> rrule: +def build_rrule(task: TaskData) -> rrule: """Build rrule string.""" - rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY) - weekdays = [ - WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active - ] + if TYPE_CHECKING: + assert task.frequency + assert task.everyX + rrule_frequency = FREQUENCY_MAP.get(task.frequency, DAILY) + weekdays = [day for key, day in WEEKDAY_MAP.items() if getattr(task.repeat, key)] bymonthday = ( - task["daysOfMonth"] - if rrule_frequency == MONTHLY and task["daysOfMonth"] - else None + task.daysOfMonth if rrule_frequency == MONTHLY and task.daysOfMonth else None ) bysetpos = None - if rrule_frequency == MONTHLY and task["weeksOfMonth"]: - bysetpos = task["weeksOfMonth"] + if rrule_frequency == MONTHLY and task.weeksOfMonth: + bysetpos = task.weeksOfMonth weekdays = weekdays if weekdays else [MO] return rrule( freq=rrule_frequency, - interval=task["everyX"], - dtstart=dt_util.start_of_local_day( - datetime.datetime.fromisoformat(task["startDate"]) - ), + interval=task.everyX, + dtstart=dt_util.start_of_local_day(task.startDate), byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None, bymonthday=bymonthday, bysetpos=bysetpos, @@ -143,48 +124,37 @@ def get_recurrence_rule(recurrence: rrule) -> str: def get_attribute_points( - user: dict[str, Any], content: dict[str, Any], attribute: str + user: UserData, content: ContentData, attribute: str ) -> dict[str, float]: - """Get modifiers contributing to strength attribute.""" - - gear_set = { - "weapon", - "armor", - "head", - "shield", - "back", - "headAccessory", - "eyewear", - "body", - } + """Get modifiers contributing to STR/INT/CON/PER attributes.""" equipment = sum( - stats[attribute] - for gear in gear_set - if (equipped := user["items"]["gear"]["equipped"].get(gear)) - and (stats := content["gear"]["flat"].get(equipped)) + getattr(stats, attribute) + for gear in fields(user.items.gear.equipped) + if (equipped := getattr(user.items.gear.equipped, gear.name)) + and (stats := content.gear.flat[equipped]) ) class_bonus = sum( - stats[attribute] / 2 - for gear in gear_set - if (equipped := user["items"]["gear"]["equipped"].get(gear)) - and (stats := content["gear"]["flat"].get(equipped)) - and stats["klass"] == user["stats"]["class"] + getattr(stats, attribute) / 2 + for gear in fields(user.items.gear.equipped) + if (equipped := getattr(user.items.gear.equipped, gear.name)) + and (stats := content.gear.flat[equipped]) + and stats.klass == user.stats.Class ) + if TYPE_CHECKING: + assert user.stats.lvl return { - "level": min(floor(user["stats"]["lvl"] / 2), 50), + "level": min(floor(user.stats.lvl / 2), 50), "equipment": equipment, "class": class_bonus, - "allocated": user["stats"][attribute], - "buffs": user["stats"]["buffs"][attribute], + "allocated": getattr(user.stats, attribute), + "buffs": getattr(user.stats.buffs, attribute), } -def get_attributes_total( - user: dict[str, Any], content: dict[str, Any], attribute: str -) -> int: +def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int: """Get total attribute points.""" return floor( sum(value for value in get_attribute_points(user, content, attribute).values()) diff --git a/requirements_all.txt b/requirements_all.txt index 1c53183669e..63d1c70151d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1088,7 +1088,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habitipy==0.3.3 +habiticalib==0.3.1 # homeassistant.components.bluetooth habluetooth==3.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55c292bd68a..2626e131421 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -929,7 +929,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habitipy==0.3.3 +habiticalib==0.3.1 # homeassistant.components.bluetooth habluetooth==3.6.0 diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index f76987c5ce6..935a203f993 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -1,77 +1,41 @@ """Tests for the habitica component.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +from habiticalib import ( + BadRequestError, + HabiticaContentResponse, + HabiticaErrorResponse, + HabiticaGroupMembersResponse, + HabiticaLoginResponse, + HabiticaQuestResponse, + HabiticaResponse, + HabiticaScoreResponse, + HabiticaSleepResponse, + HabiticaTaskOrderResponse, + HabiticaTaskResponse, + HabiticaTasksResponse, + HabiticaUserAnonymizedrResponse, + HabiticaUserResponse, + NotAuthorizedError, + NotFoundError, + TaskFilter, + TooManyRequestsError, +) import pytest -from yarl import URL from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_json_object_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry, load_fixture - -@pytest.fixture(autouse=True) -def disable_plumbum(): - """Disable plumbum in tests as it can cause the test suite to fail. - - plumbum can leave behind PlumbumTimeoutThreads - """ - with patch("plumbum.local"), patch("plumbum.colors"): - yield - - -def mock_called_with( - mock_client: AiohttpClientMocker, - method: str, - url: str, -) -> tuple | None: - """Assert request mock was called with json data.""" - - return next( - ( - call - for call in mock_client.mock_calls - if call[0].upper() == method.upper() and call[1] == URL(url) - ), - None, - ) - - -@pytest.fixture -def mock_habitica(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: - """Mock aiohttp requests.""" - - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", json=load_json_object_fixture("user.json", DOMAIN) - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json=load_json_object_fixture("completed_todos.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user/anonymized", - json={ - "data": { - "user": load_json_object_fixture("user.json", DOMAIN)["data"], - "tasks": load_json_object_fixture("tasks.json", DOMAIN)["data"], - } - }, - ) - - return aioclient_mock +ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="message") +ERROR_NOT_AUTHORIZED = NotAuthorizedError(error=ERROR_RESPONSE, headers={}) +ERROR_NOT_FOUND = NotFoundError(error=ERROR_RESPONSE, headers={}) +ERROR_BAD_REQUEST = BadRequestError(error=ERROR_RESPONSE, headers={}) +ERROR_TOO_MANY_REQUESTS = TooManyRequestsError(error=ERROR_RESPONSE, headers={}) @pytest.fixture(name="config_entry") @@ -82,10 +46,10 @@ def mock_config_entry() -> MockConfigEntry: title="test-user", data={ CONF_URL: DEFAULT_URL, - CONF_API_USER: "test-api-user", - CONF_API_KEY: "test-api-key", + CONF_API_USER: "a380546a-94be-4b8e-8a0b-23e0d5c03303", + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", }, - unique_id="00000000-0000-0000-0000-000000000000", + unique_id="a380546a-94be-4b8e-8a0b-23e0d5c03303", ) @@ -93,3 +57,109 @@ def mock_config_entry() -> MockConfigEntry: async def set_tz(hass: HomeAssistant) -> None: """Fixture to set timezone.""" await hass.config.async_set_time_zone("Europe/Berlin") + + +def mock_get_tasks(task_type: TaskFilter | None = None) -> HabiticaTasksResponse: + """Load tasks fixtures.""" + + if task_type is TaskFilter.COMPLETED_TODOS: + return HabiticaTasksResponse.from_json( + load_fixture("completed_todos.json", DOMAIN) + ) + return HabiticaTasksResponse.from_json(load_fixture("tasks.json", DOMAIN)) + + +@pytest.fixture(name="habitica") +async def mock_habiticalib() -> Generator[AsyncMock]: + """Mock habiticalib.""" + + with ( + patch( + "homeassistant.components.habitica.Habitica", autospec=True + ) as mock_client, + patch( + "homeassistant.components.habitica.config_flow.Habitica", new=mock_client + ), + ): + client = mock_client.return_value + + client.login.return_value = HabiticaLoginResponse.from_json( + load_fixture("login.json", DOMAIN) + ) + + client.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("user.json", DOMAIN) + ) + + client.cast_skill.return_value = HabiticaUserResponse.from_json( + load_fixture("user.json", DOMAIN) + ) + client.toggle_sleep.return_value = HabiticaSleepResponse( + success=True, data=True + ) + client.update_score.return_value = HabiticaUserResponse.from_json( + load_fixture("score_with_drop.json", DOMAIN) + ) + client.get_group_members.return_value = HabiticaGroupMembersResponse.from_json( + load_fixture("party_members.json", DOMAIN) + ) + for func in ( + "leave_quest", + "reject_quest", + "cancel_quest", + "abort_quest", + "start_quest", + "accept_quest", + ): + getattr(client, func).return_value = HabiticaQuestResponse.from_json( + load_fixture("party_quest.json", DOMAIN) + ) + client.get_content.return_value = HabiticaContentResponse.from_json( + load_fixture("content.json", DOMAIN) + ) + client.get_tasks.side_effect = mock_get_tasks + client.update_score.return_value = HabiticaScoreResponse.from_json( + load_fixture("score_with_drop.json", DOMAIN) + ) + client.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + client.create_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + client.delete_task.return_value = HabiticaResponse.from_dict( + {"data": {}, "success": True} + ) + client.delete_completed_todos.return_value = HabiticaResponse.from_dict( + {"data": {}, "success": True} + ) + client.reorder_task.return_value = HabiticaTaskOrderResponse.from_dict( + {"data": [], "success": True} + ) + client.get_user_anonymized.return_value = ( + HabiticaUserAnonymizedrResponse.from_json( + load_fixture("anonymized.json", DOMAIN) + ) + ) + client.habitipy.return_value = { + "tasks": { + "user": { + "post": AsyncMock( + return_value={ + "text": "Use API from Home Assistant", + "type": "todo", + } + ) + } + } + } + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.habitica.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/habitica/fixtures/anonymized.json b/tests/components/habitica/fixtures/anonymized.json new file mode 100644 index 00000000000..e0251f4c1bc --- /dev/null +++ b/tests/components/habitica/fixtures/anonymized.json @@ -0,0 +1,661 @@ +{ + "success": true, + "data": { + "user": { + "auth": { + "timestamps": { + "created": "2024-10-10T15:57:01.106Z", + "loggedin": "2024-11-27T19:34:28.887Z", + "updated": "2024-11-27T20:05:19.047Z" + } + }, + "achievements": { + "ultimateGearSets": { + "healer": false, + "wizard": false, + "rogue": false, + "warrior": false + }, + "streak": 0, + "perfect": 2, + "quests": {}, + "completedTask": true, + "partyUp": true, + "snowball": 1, + "spookySparkles": 1, + "seafoam": 1, + "shinySeed": 1, + "createdTask": true + }, + "backer": {}, + "purchased": { + "ads": false, + "txnCount": 0, + "skin": {}, + "hair": {}, + "shirt": {}, + "background": { + "violet": true, + "blue": true, + "green": true, + "purple": true, + "red": true, + "yellow": true + } + }, + "flags": { + "tour": { + "intro": -1, + "classes": -1, + "stats": -1, + "tavern": -1, + "party": -1, + "guilds": -1, + "challenges": -1, + "market": -1, + "pets": -1, + "mounts": -1, + "hall": -1, + "equipment": -1, + "groupPlans": -1 + }, + "tutorial": { + "common": { + "habits": true, + "dailies": true, + "todos": true, + "rewards": true, + "party": true, + "pets": true, + "gems": true, + "skills": true, + "classes": true, + "tavern": true, + "equipment": true, + "items": true, + "mounts": true, + "inbox": true, + "stats": true + }, + "ios": { + "addTask": false, + "editTask": false, + "deleteTask": false, + "filterTask": false, + "groupPets": false, + "inviteParty": false, + "reorderTask": false + } + }, + "verifiedUsername": true, + "customizationsNotification": true, + "showTour": true, + "dropsEnabled": false, + "itemsEnabled": true, + "lastNewStuffRead": "", + "rewrite": true, + "classSelected": false, + "rebirthEnabled": false, + "levelDrops": {}, + "recaptureEmailsPhase": 0, + "weeklyRecapEmailsPhase": 0, + "lastWeeklyRecap": "2024-10-10T15:57:01.106Z", + "communityGuidelinesAccepted": true, + "cronCount": 6, + "welcomed": true, + "armoireEnabled": true, + "armoireOpened": false, + "armoireEmpty": false, + "cardReceived": false, + "warnedLowHealth": true, + "thirdPartyTools": "2024-11-27T19:32:18.826Z", + "newStuff": false + }, + "history": { + "exp": [ + { + "date": "2024-10-30T19:37:01.970Z", + "value": 24 + }, + { + "date": "2024-10-31T23:33:14.972Z", + "value": 48 + }, + { + "date": "2024-11-05T18:25:04.681Z", + "value": 66 + }, + { + "date": "2024-11-21T15:09:07.501Z", + "value": 66 + }, + { + "date": "2024-11-22T00:41:21.137Z", + "value": 66 + }, + { + "date": "2024-11-27T19:34:28.887Z", + "value": 66 + } + ], + "todos": [ + { + "date": "2024-10-30T19:37:01.970Z", + "value": -5 + }, + { + "date": "2024-10-31T23:33:14.972Z", + "value": -10.129783523135325 + }, + { + "date": "2024-11-05T18:25:04.681Z", + "value": -16.396221153338182 + }, + { + "date": "2024-11-21T15:09:07.501Z", + "value": -22.8326979965846 + }, + { + "date": "2024-11-22T00:41:21.137Z", + "value": -29.448636229365235 + }, + { + "date": "2024-11-27T19:34:28.887Z", + "value": -36.25425987861077 + } + ] + }, + "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, + "head_special_bardHat": true + } + }, + "special": { + "snowball": 0, + "spookySparkles": 0, + "shinySeed": 0, + "seafoam": 0, + "valentine": 0, + "nye": 0, + "greeting": 0, + "greetingReceived": [], + "thankyou": 0, + "thankyouReceived": [], + "birthday": 0, + "birthdayReceived": [], + "congrats": 0, + "congratsReceived": [], + "getwell": 0, + "getwellReceived": [], + "goodluck": 0, + "goodluckReceived": [] + }, + "lastDrop": { + "count": 0, + "date": "2024-11-05T18:25:04.619Z" + }, + "currentPet": "", + "currentMount": "", + "pets": {}, + "eggs": { + "BearCub": 1, + "Cactus": 1, + "Wolf": 1, + "Dragon": 1 + }, + "hatchingPotions": { + "Skeleton": 1, + "Zombie": 1, + "RoyalPurple": 1 + }, + "food": { + "Candy_Red": 1, + "Chocolate": 1, + "Meat": 1, + "CottonCandyPink": 1 + }, + "mounts": {}, + "quests": { + "dustbunnies": 1 + } + }, + "party": { + "quest": { + "progress": { + "up": 0, + "down": 0, + "collectedItems": 0, + "collect": {} + }, + "RSVPNeeded": true, + "key": "dustbunnies", + "completed": null + }, + "order": "level", + "orderAscending": "ascending", + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "preferences": { + "hair": { + "color": "red", + "base": 3, + "bangs": 1, + "beard": 0, + "mustache": 0, + "flower": 1 + }, + "emailNotifications": { + "unsubscribeFromAll": false, + "newPM": true, + "kickedGroup": true, + "wonChallenge": true, + "giftedGems": true, + "giftedSubscription": true, + "invitedParty": true, + "invitedGuild": true, + "questStarted": true, + "invitedQuest": true, + "importantAnnouncements": true, + "weeklyRecaps": true, + "onboarding": true, + "majorUpdates": true, + "subscriptionReminders": true, + "contentRelease": true + }, + "pushNotifications": { + "unsubscribeFromAll": false, + "newPM": true, + "wonChallenge": true, + "giftedGems": true, + "giftedSubscription": true, + "invitedParty": true, + "invitedGuild": true, + "questStarted": true, + "invitedQuest": true, + "majorUpdates": true, + "mentionParty": true, + "mentionJoinedGuild": true, + "mentionUnjoinedGuild": true, + "partyActivity": true, + "contentRelease": true + }, + "suppressModals": { + "levelUp": false, + "hatchPet": false, + "raisePet": false, + "streak": false + }, + "tasks": { + "activeFilter": { + "habit": "all", + "daily": "all", + "todo": "remaining", + "reward": "all" + }, + "groupByChallenge": false, + "confirmScoreNotes": false, + "mirrorGroupTasks": [] + }, + "language": "de", + "dayStart": 0, + "size": "slim", + "hideHeader": false, + "skin": "915533", + "shirt": "blue", + "timezoneOffset": -60, + "sound": "rosstavoTheme", + "chair": "none", + "allocationMode": "flat", + "autoEquip": true, + "costume": false, + "dateFormat": "MM/dd/yyyy", + "sleep": false, + "stickyHeader": true, + "disableClasses": false, + "newTaskEdit": false, + "dailyDueDefaultView": false, + "advancedCollapsed": false, + "toolbarCollapsed": false, + "reverseChatOrder": false, + "developerMode": false, + "displayInviteToPartyWhenPartyIs1": true, + "webhooks": {}, + "improvementCategories": [], + "background": "violet", + "timezoneOffsetAtLastCron": -60 + }, + "stats": { + "buffs": { + "str": 0, + "int": 0, + "per": 0, + "con": 0, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "training": { + "int": 0, + "per": 0, + "str": 0, + "con": 0 + }, + "hp": 25.40000000000002, + "mp": 32, + "exp": 41, + "gp": 11.100978952781748, + "lvl": 2, + "class": "warrior", + "points": 2, + "str": 0, + "con": 0, + "int": 0, + "per": 0, + "toNextLevel": 50, + "maxHealth": 50, + "maxMP": 32 + }, + "inbox": { + "newMessages": 0, + "optOut": false, + "blocks": [], + "messages": {} + }, + "tasksOrder": { + "habits": ["30923acd-3b4c-486d-9ef3-c8f57cf56049"], + "dailys": ["6e53f1f5-a315-4edd-984d-8d762e4a08ef"], + "todos": ["e6e06dc6-c887-4b86-b175-b99cc2e20fdf"], + "rewards": ["2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9"] + }, + "_id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "_v": 131, + "balance": 0, + "_subSignature": "NOT_RUNNING", + "challenges": [], + "guilds": [], + "loginIncentives": 6, + "invitesSent": 0, + "pinnedItemsOrder": [], + "lastCron": "2024-11-27T19:34:28.887Z", + "tags": [ + { + "id": "c1a35186-9895-4ac0-9cd7-49e7bb875695", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "53d1deb8-ed2b-4f94-bbfc-955e9e92aa98", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "29bf6a99-536f-446b-838f-a81d41e1ed4d", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "1b1297e7-4fd8-460a-b148-e92d7bcfa9a5", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "05e6cf40-48ea-415a-9b8b-e2ecad258ef6", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "fe53f179-59d8-4c28-9bf7-b9068ab552a4", + "name": "tag", + "challenge": "challenge" + }, + { + "id": "c44e9e8c-4bff-42df-98d5-1a1a7b69eada", + "name": "tag", + "challenge": "challenge" + } + ], + "extra": {}, + "pushDevices": [], + "pinnedItems": [ + { + "path": "gear.flat.weapon_warrior_0", + "type": "marketGear" + }, + { + "path": "gear.flat.armor_warrior_1", + "type": "marketGear" + }, + { + "path": "gear.flat.shield_warrior_1", + "type": "marketGear" + }, + { + "path": "gear.flat.head_warrior_1", + "type": "marketGear" + }, + { + "path": "potion", + "type": "potion" + }, + { + "path": "armoire", + "type": "armoire" + } + ], + "unpinnedItems": [], + "id": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7" + }, + "tasks": [ + { + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "30923acd-3b4c-486d-9ef3-c8f57cf56049", + "up": true, + "down": false, + "counterUp": 0, + "counterDown": 0, + "frequency": "daily", + "history": [], + "type": "habit", + "text": "task text", + "notes": "task notes", + "tags": [], + "value": 0, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "reminders": [], + "createdAt": "2024-10-10T15:57:14.287Z", + "updatedAt": "2024-10-10T15:57:14.287Z", + "userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "id": "30923acd-3b4c-486d-9ef3-c8f57cf56049" + }, + { + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "e6e06dc6-c887-4b86-b175-b99cc2e20fdf", + "completed": false, + "collapseChecklist": false, + "type": "todo", + "text": "task text", + "notes": "task notes", + "tags": [], + "value": -6.418582324043852, + "priority": 1, + "attribute": "str", + "byHabitica": true, + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.290Z", + "updatedAt": "2024-11-27T19:34:29.001Z", + "userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "id": "e6e06dc6-c887-4b86-b175-b99cc2e20fdf" + }, + { + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9", + "type": "reward", + "text": "task text", + "notes": "task notes", + "tags": [], + "value": 10, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "reminders": [], + "createdAt": "2024-10-10T15:57:14.290Z", + "updatedAt": "2024-10-10T15:57:14.290Z", + "userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "id": "2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9" + }, + { + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "frequency": "weekly", + "everyX": 1, + "streak": 0, + "nextDue": [ + "2024-11-27T23:00:00.000Z", + "2024-11-28T23:00:00.000Z", + "2024-11-29T23:00:00.000Z", + "2024-12-01T23:00:00.000Z", + "2024-12-02T23:00:00.000Z", + "2024-12-03T23:00:00.000Z" + ], + "yesterDaily": true, + "history": [ + { + "date": 1730317021817, + "value": 1, + "isDue": true, + "completed": true + }, + { + "date": 1730417594890, + "value": 1.9747, + "isDue": true, + "completed": true + }, + { + "date": 1730831104730, + "value": 1.024043774264157, + "isDue": true, + "completed": false + }, + { + "date": 1732201747573, + "value": 0.049944135963563174, + "isDue": true, + "completed": false + }, + { + "date": 1732236081228, + "value": -0.9487768368544092, + "isDue": true, + "completed": false + }, + { + "date": 1732736068973, + "value": -1.973387732005249, + "isDue": true, + "completed": false + } + ], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "task text", + "notes": "task notes", + "tags": [], + "value": -1.973387732005249, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-10-09T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.304Z", + "updatedAt": "2024-11-27T19:34:29.001Z", + "userId": "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + "isDue": true, + "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + } + ] + }, + "notifications": [ + { + "id": "35708dfc-74c6-4352-97d3-8aeddbb31d20", + "type": "NEW_CHAT_MESSAGE", + "data": { + "group": { + "id": "94cd398c-2240-4320-956e-6d345cf2c0de", + "name": "tests Party" + } + }, + "seen": false + } + ], + "userV": 131, + "appVersion": "5.29.2" +} diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index e8e14dead73..b4458aa647a 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -2,6 +2,88 @@ "success": true, "data": { "gear": { + "tree": { + "weapon": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "armor": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "head": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "shield": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "back": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "body": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "headAccessory": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + }, + "eyewear": { + "base": {}, + "warrior": {}, + "wizard": {}, + "rogue": {}, + "special": {}, + "armoire": {}, + "mystery": {}, + "healer": {} + } + }, "flat": { "weapon_warrior_5": { "text": "Ruby Sword", @@ -281,7 +363,110 @@ "per": 0 } } - } + }, + "achievements": {}, + "questSeriesAchievements": {}, + "animalColorAchievements": [], + "animalSetAchievements": {}, + "stableAchievements": {}, + "petSetCompleteAchievs": [], + "quests": {}, + "questsByLevel": {}, + "userCanOwnQuestCategories": [], + "itemList": { + "weapon": { + "localeKey": "weapon", + "isEquipment": true + }, + "armor": { + "localeKey": "armor", + "isEquipment": true + }, + "head": { + "localeKey": "headgear", + "isEquipment": true + }, + "shield": { + "localeKey": "offhand", + "isEquipment": true + }, + "back": { + "localeKey": "back", + "isEquipment": true + }, + "body": { + "localeKey": "body", + "isEquipment": true + }, + "headAccessory": { + "localeKey": "headAccessory", + "isEquipment": true + }, + "eyewear": { + "localeKey": "eyewear", + "isEquipment": true + }, + "hatchingPotions": { + "localeKey": "hatchingPotion", + "isEquipment": false + }, + "premiumHatchingPotions": { + "localeKey": "hatchingPotion", + "isEquipment": false + }, + "eggs": { + "localeKey": "eggSingular", + "isEquipment": false + }, + "quests": { + "localeKey": "quest", + "isEquipment": false + }, + "food": { + "localeKey": "foodTextThe", + "isEquipment": false + }, + "Saddle": { + "localeKey": "foodSaddleText", + "isEquipment": false + }, + "bundles": { + "localeKey": "discountBundle", + "isEquipment": false + } + }, + "spells": { + "wizard": {}, + "warrior": {}, + "rogue": {}, + "healer": {}, + "special": {} + }, + "officialPinnedItems": [], + "audioThemes": [], + "classes": [], + "gearTypes": [], + "cardTypes": {}, + "special": {}, + "dropEggs": {}, + "questEggs": {}, + "eggs": {}, + "dropHatchingPotions": {}, + "premiumHatchingPotions": {}, + "wackyHatchingPotions": {}, + "hatchingPotions": {}, + "pets": {}, + "premiumPets": {}, + "questPets": {}, + "specialPets": {}, + "wackyPets": {}, + "petInfo": {}, + "mounts": {}, + "premiumMounts": {}, + "questMounts": {}, + "specialMounts": {}, + "mountInfo": {}, + "food": {} }, "appVersion": "5.29.2" } diff --git a/tests/components/habitica/fixtures/login.json b/tests/components/habitica/fixtures/login.json new file mode 100644 index 00000000000..f0c598b3cee --- /dev/null +++ b/tests/components/habitica/fixtures/login.json @@ -0,0 +1,10 @@ +{ + "success": true, + "data": { + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "apiToken": "cd0e5985-17de-4b4f-849e-5d506c5e4382", + "newUser": false, + "username": "test-username" + }, + "appVersion": "5.30.0" +} diff --git a/tests/components/habitica/fixtures/party_quest.json b/tests/components/habitica/fixtures/party_quest.json new file mode 100644 index 00000000000..c140a4525bc --- /dev/null +++ b/tests/components/habitica/fixtures/party_quest.json @@ -0,0 +1,31 @@ +{ + "success": true, + "data": { + "progress": { + "collect": {}, + "hp": 100 + }, + "key": "dustbunnies", + "active": true, + "leader": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "members": { + "a380546a-94be-4b8e-8a0b-23e0d5c03303": true + }, + "extra": {} + }, + "notifications": [ + { + "id": "3f59313f-6d7c-4fff-a8c4-3b153d828c6f", + "type": "NEW_CHAT_MESSAGE", + "data": { + "group": { + "id": "94cd398c-2240-4320-956e-6d345cf2c0de", + "name": "tests Party" + } + }, + "seen": false + } + ], + "userV": 287, + "appVersion": "5.29.2" +} diff --git a/tests/components/habitica/fixtures/task.json b/tests/components/habitica/fixtures/task.json new file mode 100644 index 00000000000..2816c368198 --- /dev/null +++ b/tests/components/habitica/fixtures/task.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": { + "_id": "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "date": "2024-09-27T22:17:00.000Z", + "completed": false, + "collapseChecklist": false, + "checklist": [], + "type": "todo", + "text": "Buch zu Ende lesen", + "notes": "Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.", + "tags": [], + "value": 0, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "reminders": [], + "byHabitica": false, + "createdAt": "2024-09-21T22:17:57.816Z", + "updatedAt": "2024-09-21T22:17:57.816Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "88de7cd9-af2b-49ce-9afd-bf941d87336b" + }, + "notifications": [ + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_orca_mount", + "title": "Orcas for Summer Splash!", + "text": "To celebrate Summer Splash, we've given you an Orca Mount!", + "destination": "stable" + }, + "seen": true, + "id": "b7a85df1-06ed-4ab1-b56d-43418fc6a5e5" + }, + { + "type": "UNALLOCATED_STATS_POINTS", + "data": { + "points": 2 + }, + "seen": true, + "id": "bc3f8a69-231f-4eb1-ba48-a00b6c0e0f37" + } + ], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index ed41a306a03..e176ca48ea4 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -83,6 +83,7 @@ "body": "body_special_aetherAmulet" } } - } + }, + "balance": 10 } } diff --git a/tests/components/habitica/fixtures/user_no_party.json b/tests/components/habitica/fixtures/user_no_party.json new file mode 100644 index 00000000000..1c58dde6f50 --- /dev/null +++ b/tests/components/habitica/fixtures/user_no_party.json @@ -0,0 +1,86 @@ +{ + "success": true, + "data": { + "api_user": "test-api-user", + "profile": { "name": "test-user" }, + "auth": { "local": { "username": "test-username" } }, + "stats": { + "buffs": { + "str": 26, + "int": 26, + "per": 26, + "con": 26, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "hp": 0, + "mp": 50.89999999999998, + "exp": 737, + "gp": 137.62587214609795, + "lvl": 38, + "class": "wizard", + "maxHealth": 50, + "maxMP": 166, + "toNextLevel": 880, + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 + }, + "preferences": { + "sleep": false, + "automaticAllocation": true, + "disableClasses": false, + "language": "en" + }, + "flags": { + "classSelected": true + }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, + "party": { + "quest": { + "RSVPNeeded": true, + "key": "dustbunnies" + } + }, + "needsCron": true, + "lastCron": "2024-09-21T22:01:55.586Z", + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "back_special_heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" + } + } + } + } +} diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index c18f8f551c9..0a4076a6135 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_pending_quest', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_pending_quest', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/snapshots/test_button.ambr b/tests/components/habitica/snapshots/test_button.ambr index c8f92650874..fe2715d5ca7 100644 --- a/tests/components/habitica/snapshots/test_button.ambr +++ b/tests/components/habitica/snapshots/test_button.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', 'unit_of_measurement': None, }) # --- @@ -74,7 +74,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_heal_all', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_heal_all', 'unit_of_measurement': None, }) # --- @@ -121,7 +121,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', 'unit_of_measurement': None, }) # --- @@ -168,7 +168,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_heal', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_heal', 'unit_of_measurement': None, }) # --- @@ -215,7 +215,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_protect_aura', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_protect_aura', 'unit_of_measurement': None, }) # --- @@ -262,7 +262,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_revive', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', 'unit_of_measurement': None, }) # --- @@ -308,7 +308,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_brightness', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_brightness', 'unit_of_measurement': None, }) # --- @@ -355,7 +355,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', 'unit_of_measurement': None, }) # --- @@ -401,7 +401,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', 'unit_of_measurement': None, }) # --- @@ -447,7 +447,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', 'unit_of_measurement': None, }) # --- @@ -494,7 +494,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_revive', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', 'unit_of_measurement': None, }) # --- @@ -540,7 +540,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', 'unit_of_measurement': None, }) # --- @@ -586,7 +586,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_stealth', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_stealth', 'unit_of_measurement': None, }) # --- @@ -633,7 +633,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_tools_of_trade', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_tools_of_trade', 'unit_of_measurement': None, }) # --- @@ -680,7 +680,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', 'unit_of_measurement': None, }) # --- @@ -726,7 +726,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', 'unit_of_measurement': None, }) # --- @@ -773,7 +773,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_defensive_stance', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_defensive_stance', 'unit_of_measurement': None, }) # --- @@ -820,7 +820,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_intimidate', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_intimidate', 'unit_of_measurement': None, }) # --- @@ -867,7 +867,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_revive', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', 'unit_of_measurement': None, }) # --- @@ -913,7 +913,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', 'unit_of_measurement': None, }) # --- @@ -959,7 +959,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_valorous_presence', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_valorous_presence', 'unit_of_measurement': None, }) # --- @@ -1006,7 +1006,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_allocate_all_stat_points', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_allocate_all_stat_points', 'unit_of_measurement': None, }) # --- @@ -1052,7 +1052,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_buy_health_potion', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_buy_health_potion', 'unit_of_measurement': None, }) # --- @@ -1099,7 +1099,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_frost', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_frost', 'unit_of_measurement': None, }) # --- @@ -1146,7 +1146,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_earth', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_earth', 'unit_of_measurement': None, }) # --- @@ -1193,7 +1193,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_mpheal', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mpheal', 'unit_of_measurement': None, }) # --- @@ -1240,7 +1240,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_revive', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_revive', 'unit_of_measurement': None, }) # --- @@ -1286,7 +1286,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_run_cron', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_run_cron', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 5e010a33c84..8be45ccc0fd 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -928,7 +928,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_dailys', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys', 'unit_of_measurement': None, }) # --- @@ -981,7 +981,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_daily_reminders', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_daily_reminders', 'unit_of_measurement': None, }) # --- @@ -1033,7 +1033,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_todo_reminders', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todo_reminders', 'unit_of_measurement': None, }) # --- @@ -1085,7 +1085,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_todos', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index 0d5f07d9a6c..b4304e33ec8 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -2,774 +2,1027 @@ # name: test_diagnostics dict({ 'config_entry_data': dict({ - 'api_user': 'test-api-user', + 'api_user': 'a380546a-94be-4b8e-8a0b-23e0d5c03303', 'url': 'https://habitica.com', }), 'habitica_data': dict({ 'tasks': list([ dict({ - '_id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', + 'Type': 'habit', + 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': '2024-07-07T17:51:53.268Z', - 'down': True, - 'frequency': 'daily', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'history': list([ + 'createdAt': '2024-10-10T15:57:14.287000+00:00', + 'date': None, + 'daysOfMonth': list([ ]), - 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', - 'notes': '', - 'priority': 1, - 'reminders': list([ - ]), - 'tags': list([ - ]), - 'text': 'Gesundes Essen/Junkfood', - 'type': 'habit', - 'up': True, - 'updatedAt': '2024-07-07T17:51:53.268Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 0, - }), - dict({ - '_id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'counterDown': 0, - 'counterUp': 0, - 'createdAt': '2024-07-07T17:51:53.266Z', 'down': False, + 'everyX': None, 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, 'completedBy': dict({ + 'date': None, + 'userId': None, }), - }), - 'history': list([ - dict({ - 'date': 1720376763324, - 'scoredDown': 0, - 'scoredUp': 1, - 'value': 1, - }), - ]), - 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', - 'notes': '', - 'priority': 1, - 'reminders': list([ - ]), - 'tags': list([ - ]), - 'text': 'Eine kurze Pause machen', - 'type': 'habit', - 'up': True, - 'updatedAt': '2024-07-12T09:58:45.438Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 0, - }), - dict({ - '_id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'counterDown': 0, - 'counterUp': 0, - 'createdAt': '2024-07-07T17:51:53.265Z', - 'down': True, - 'frequency': 'daily', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'history': list([ ]), - 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', - 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': 1, - 'reminders': list([ - ]), - 'tags': list([ - ]), - 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', - 'type': 'habit', - 'up': False, - 'updatedAt': '2024-07-07T17:51:53.265Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 0, - }), - dict({ - '_id': 'e97659e0-2c42-4599-a7bb-00282adc410d', - 'alias': 'create_a_task', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'counterDown': 0, - 'counterUp': 0, - 'createdAt': '2024-07-07T17:51:53.264Z', - 'down': False, - 'frequency': 'daily', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'history': list([ - dict({ - 'date': 1720376763140, - 'scoredDown': 0, - 'scoredUp': 1, - 'value': 1, - }), - ]), - 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', - 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': 1, - 'reminders': list([ - ]), - 'tags': list([ - ]), - 'text': 'Füge eine Aufgabe zu Habitica hinzu', - 'type': 'habit', - 'up': True, - 'updatedAt': '2024-07-12T09:58:45.438Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 0, - }), - dict({ - '_id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': True, - 'createdAt': '2024-07-07T17:51:53.268Z', - 'daysOfMonth': list([ - ]), - 'everyX': 1, - 'frequency': 'weekly', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'history': list([ - dict({ - 'completed': True, - 'date': 1720376766749, - 'isDue': True, - 'value': 1, - }), - dict({ - 'completed': False, - 'date': 1720545311292, - 'isDue': True, - 'value': 0.02529999999999999, - }), - dict({ - 'completed': False, - 'date': 1720564306719, - 'isDue': True, - 'value': -0.9740518837628547, - }), - dict({ - 'completed': True, - 'date': 1720691096907, - 'isDue': True, - 'value': 0.051222853419153, - }), - dict({ - 'completed': True, - 'date': 1720778325243, - 'isDue': True, - 'value': 1.0499115128458676, - }), - dict({ - 'completed': False, - 'date': 1724185196447, - 'isDue': True, - 'value': 0.07645736684721605, - }), - dict({ - 'completed': False, - 'date': 1724255707692, - 'isDue': True, - 'value': -0.921585289356988, - }), - dict({ - 'completed': False, - 'date': 1726846163640, - 'isDue': True, - 'value': -1.9454824860630637, - }), - dict({ - 'completed': False, - 'date': 1726953787542, - 'isDue': True, - 'value': -2.9966001649571803, - }), - dict({ - 'completed': False, - 'date': 1726956115608, - 'isDue': True, - 'value': -4.07641493832036, - }), - dict({ - 'completed': True, - 'date': 1726957460150, - 'isDue': True, - 'value': -2.9663035443712333, - }), - ]), - 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', - 'isDue': True, + 'id': '30923acd-3b4c-486d-9ef3-c8f57cf56049', + 'isDue': None, 'nextDue': list([ - 'Mon Sep 23 2024 00:00:00 GMT+0200', - 'Tue Sep 24 2024 00:00:00 GMT+0200', - 'Wed Sep 25 2024 00:00:00 GMT+0200', - 'Thu Sep 26 2024 00:00:00 GMT+0200', - 'Fri Sep 27 2024 00:00:00 GMT+0200', - 'Sat Sep 28 2024 00:00:00 GMT+0200', ]), - 'notes': 'Klicke um Änderungen zu machen!', + 'notes': 'task notes', 'priority': 1, 'reminders': list([ ]), - 'repeat': dict({ - 'f': True, - 'm': True, - 's': True, - 'su': True, - 't': True, - 'th': True, - 'w': True, - }), - 'startDate': '2024-07-06T22:00:00.000Z', - 'streak': 1, - 'tags': list([ - ]), - 'text': 'Zahnseide benutzen', - 'type': 'daily', - 'updatedAt': '2024-09-21T22:24:20.154Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': -2.9663035443712333, - 'weeksOfMonth': list([ - ]), - 'yesterDaily': True, - }), - dict({ - '_id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'createdAt': '2024-07-07T17:51:53.266Z', - 'daysOfMonth': list([ - ]), - 'everyX': 1, - 'frequency': 'weekly', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'history': list([ - dict({ - 'completed': True, - 'date': 1720374903074, - 'isDue': True, - 'value': 1, - }), - dict({ - 'completed': False, - 'date': 1720545311291, - 'isDue': True, - 'value': 0.02529999999999999, - }), - dict({ - 'completed': False, - 'date': 1720564306717, - 'isDue': True, - 'value': -0.9740518837628547, - }), - dict({ - 'completed': True, - 'date': 1720682459722, - 'isDue': True, - 'value': 0.051222853419153, - }), - dict({ - 'completed': True, - 'date': 1720778325246, - 'isDue': True, - 'value': 1.0499115128458676, - }), - dict({ - 'completed': True, - 'date': 1720778492219, - 'isDue': True, - 'value': 2.023365658844519, - }), - dict({ - 'completed': False, - 'date': 1724255707691, - 'isDue': True, - 'value': 1.0738942424964806, - }), - dict({ - 'completed': False, - 'date': 1726846163638, - 'isDue': True, - 'value': 0.10103816898038132, - }), - dict({ - 'completed': False, - 'date': 1726953787540, - 'isDue': True, - 'value': -0.8963760215867302, - }), - dict({ - 'completed': False, - 'date': 1726956115607, - 'isDue': True, - 'value': -1.919611992979862, - }), - ]), - 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', - 'isDue': True, - 'nextDue': list([ - '2024-09-22T22:00:00.000Z', - '2024-09-23T22:00:00.000Z', - '2024-09-24T22:00:00.000Z', - '2024-09-25T22:00:00.000Z', - '2024-09-26T22:00:00.000Z', - '2024-09-27T22:00:00.000Z', - ]), - 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': 1, - 'reminders': list([ - dict({ - 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', - 'time': '2024-09-22T20:00:00.0000Z', - }), - ]), - 'repeat': dict({ - 'f': True, - 'm': True, - 's': True, - 'su': True, - 't': True, - 'th': True, - 'w': True, - }), - 'startDate': '2024-07-06T22:00:00.000Z', - 'streak': 0, - 'tags': list([ - ]), - 'text': '5 Minuten ruhig durchatmen', - 'type': 'daily', - 'updatedAt': '2024-09-21T22:51:41.756Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': -1.919611992979862, - 'weeksOfMonth': list([ - ]), - 'yesterDaily': True, - }), - dict({ - '_id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'createdAt': '2024-09-22T11:44:43.774Z', - 'daysOfMonth': list([ - ]), - 'everyX': 1, - 'frequency': 'weekly', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'history': list([ - ]), - 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', - 'isDue': True, - 'nextDue': list([ - '2024-09-24T22:00:00.000Z', - '2024-09-27T22:00:00.000Z', - '2024-09-28T22:00:00.000Z', - '2024-10-01T22:00:00.000Z', - '2024-10-04T22:00:00.000Z', - '2024-10-08T22:00:00.000Z', - ]), - 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': 2, - 'reminders': list([ - ]), 'repeat': dict({ 'f': False, - 'm': False, - 's': True, - 'su': True, - 't': False, + 'm': True, + 's': False, + 'su': False, + 't': True, 'th': False, 'w': True, }), - 'startDate': '2024-09-21T22:00:00.000Z', - 'streak': 0, + 'startDate': None, + 'streak': None, 'tags': list([ - '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), - 'text': 'Fitnessstudio besuchen', - 'type': 'daily', - 'updatedAt': '2024-09-22T11:44:43.774Z', - 'userId': '1343a9af-d891-4027-841a-956d105ca408', - 'value': 0, + 'text': 'task text', + 'up': True, + 'updatedAt': '2024-10-10T15:57:14.287000+00:00', + 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'value': 0.0, 'weeksOfMonth': list([ ]), - 'yesterDaily': True, + 'yesterDaily': None, }), dict({ - '_id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', + 'Type': 'todo', + 'alias': None, 'attribute': 'str', - 'byHabitica': False, + 'byHabitica': True, 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, 'completed': False, - 'createdAt': '2024-09-21T22:17:57.816Z', - 'date': '2024-09-27T22:17:00.000Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', - 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': 1, - 'reminders': list([ - ]), - 'tags': list([ - ]), - 'text': 'Buch zu Ende lesen', - 'type': 'todo', - 'updatedAt': '2024-09-21T22:17:57.816Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 0, - }), - dict({ - '_id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', - 'alias': 'pay_bills', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'createdAt': '2024-09-21T22:17:19.513Z', - 'date': '2024-08-31T22:16:00.000Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', - 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': 1, - 'reminders': list([ - dict({ - 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', - 'time': '2024-09-22T02:00:00.0000Z', - }), - ]), - 'tags': list([ - ]), - 'text': 'Rechnungen bezahlen', - 'type': 'todo', - 'updatedAt': '2024-09-21T22:19:35.576Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 0, - }), - dict({ - '_id': '1aa3137e-ef72-4d1f-91ee-41933602f438', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'createdAt': '2024-09-21T22:16:38.153Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', - 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': 1, - 'reminders': list([ - ]), - 'tags': list([ - ]), - 'text': 'Garten pflegen', - 'type': 'todo', - 'updatedAt': '2024-09-21T22:16:38.153Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 0, - }), - dict({ - '_id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'createdAt': '2024-09-21T22:16:16.756Z', - 'date': '2024-09-21T22:00:00.000Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', - 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': 1, - 'reminders': list([ - ]), - 'tags': list([ - '51076966-2970-4b40-b6ba-d58c6a756dd7', - ]), - 'text': 'Wochenendausflug planen', - 'type': 'todo', - 'updatedAt': '2024-09-21T22:16:16.756Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 0, - }), - dict({ - '_id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'createdAt': '2024-07-07T17:51:53.266Z', - 'group': dict({ - 'assignedUsers': list([ - ]), - 'completedBy': dict({ - }), - }), - 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', - 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': 1, - 'reminders': list([ - ]), - 'tags': list([ - ]), - 'text': 'Belohne Dich selbst', - 'type': 'reward', - 'updatedAt': '2024-07-07T17:51:53.266Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': 10, - }), - dict({ - '_id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', - 'attribute': 'str', - 'byHabitica': False, - 'challenge': dict({ - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'createdAt': '2024-10-10T15:57:14.304Z', + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.290000+00:00', + 'date': None, 'daysOfMonth': list([ ]), - 'everyX': 1, - 'frequency': 'monthly', + 'down': None, + 'everyX': None, + 'frequency': None, 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'task notes', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'task text', + 'up': None, + 'updatedAt': '2024-11-27T19:34:29.001000+00:00', + 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'value': -6.418582324043852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': 'reward', + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.290000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': None, + 'id': '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'task notes', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'task text', + 'up': None, + 'updatedAt': '2024-10-10T15:57:14.290000+00:00', + 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'value': 10.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'Type': 'daily', + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.304000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'history': list([ + dict({ + 'completed': True, + 'date': '2024-10-30T19:37:01.817000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.0, + }), + dict({ + 'completed': True, + 'date': '2024-10-31T23:33:14.890000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.9747, + }), + dict({ + 'completed': False, + 'date': '2024-11-05T18:25:04.730000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 1.024043774264157, + }), + dict({ + 'completed': False, + 'date': '2024-11-21T15:09:07.573000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': 0.049944135963563174, + }), + dict({ + 'completed': False, + 'date': '2024-11-22T00:41:21.228000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -0.9487768368544092, + }), + dict({ + 'completed': False, + 'date': '2024-11-27T19:34:28.973000+00:00', + 'isDue': True, + 'scoredDown': None, + 'scoredUp': None, + 'value': -1.973387732005249, + }), ]), 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', - 'isDue': False, + 'isDue': True, 'nextDue': list([ - '2024-12-14T23:00:00.000Z', - '2025-01-18T23:00:00.000Z', - '2025-02-15T23:00:00.000Z', - '2025-03-15T23:00:00.000Z', - '2025-04-19T23:00:00.000Z', - '2025-05-17T23:00:00.000Z', + '2024-11-27T23:00:00+00:00', + '2024-11-28T23:00:00+00:00', + '2024-11-29T23:00:00+00:00', + '2024-12-01T23:00:00+00:00', + '2024-12-02T23:00:00+00:00', + '2024-12-03T23:00:00+00:00', ]), - 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'notes': 'task notes', 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ - 'f': False, - 'm': False, - 's': False, + 'f': True, + 'm': True, + 's': True, 'su': True, - 't': False, - 'th': False, - 'w': False, + 't': True, + 'th': True, + 'w': True, }), - 'startDate': '2024-09-20T23:00:00.000Z', - 'streak': 1, + 'startDate': '2024-10-09T22:00:00+00:00', + 'streak': 0, 'tags': list([ ]), - 'text': 'Arbeite an einem kreativen Projekt', - 'type': 'daily', - 'updatedAt': '2024-11-27T23:47:29.986Z', - 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', - 'value': -0.9215181434950852, + 'text': 'task text', + 'up': None, + 'updatedAt': '2024-11-27T19:34:29.001000+00:00', + 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'value': -1.973387732005249, 'weeksOfMonth': list([ - 3, ]), 'yesterDaily': True, }), ]), 'user': dict({ - 'api_user': 'test-api-user', + 'achievements': dict({ + 'backToBasics': None, + 'boneCollector': None, + 'challenges': list([ + ]), + 'completedTask': True, + 'createdTask': True, + 'dustDevil': None, + 'fedPet': None, + 'goodAsGold': None, + 'hatchedPet': None, + 'joinedChallenge': None, + 'joinedGuild': None, + 'partyUp': None, + 'perfect': 2, + 'primedForPainting': None, + 'purchasedEquipment': None, + 'quests': dict({ + 'atom1': None, + 'atom2': None, + 'atom3': None, + 'bewilder': None, + 'burnout': None, + 'dilatory': None, + 'dilatory_derby': None, + 'dysheartener': None, + 'evilsanta': None, + 'evilsanta2': None, + 'gryphon': None, + 'harpy': None, + 'stressbeast': None, + 'vice1': None, + 'vice3': None, + }), + 'seeingRed': None, + 'shadyCustomer': None, + 'streak': 0, + 'tickledPink': None, + 'ultimateGearSets': dict({ + 'healer': False, + 'rogue': False, + 'warrior': False, + 'wizard': False, + }), + 'violetsAreBlue': None, + }), 'auth': dict({ + 'apple': None, + 'facebook': None, + 'google': None, 'local': dict({ - 'username': 'test-username', + 'email': None, + 'has_password': None, + 'lowerCaseUsername': None, + 'username': None, + }), + 'timestamps': dict({ + 'created': '2024-10-10T15:57:01.106000+00:00', + 'loggedin': '2024-11-27T19:34:28.887000+00:00', + 'updated': '2024-11-27T20:05:19.047000+00:00', }), }), - 'flags': dict({ - 'classSelected': True, + 'backer': dict({ + 'npc': None, + 'tier': None, + 'tokensApplied': None, }), - 'id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303', + 'balance': 0.0, + 'challenges': list([ + ]), + 'contributor': dict({ + 'contributions': None, + 'level': None, + 'text': None, + }), + 'extra': dict({ + }), + 'flags': dict({ + 'armoireEmpty': False, + 'armoireEnabled': True, + 'armoireOpened': False, + 'cardReceived': False, + 'chatRevoked': None, + 'chatShadowMuted': None, + 'classSelected': False, + 'communityGuidelinesAccepted': True, + 'cronCount': 6, + 'customizationsNotification': True, + 'dropsEnabled': False, + 'itemsEnabled': True, + 'lastFreeRebirth': None, + 'lastNewStuffRead': '', + 'lastWeeklyRecap': '2024-10-10T15:57:01.106000+00:00', + 'lastWeeklyRecapDiscriminator': None, + 'levelDrops': dict({ + }), + 'mathUpdates': None, + 'newStuff': False, + 'onboardingEmailsPhase': None, + 'rebirthEnabled': False, + 'recaptureEmailsPhase': 0, + 'rewrite': True, + 'showTour': True, + 'thirdPartyTools': '2024-11-27T19:32:18.826000+00:00', + 'tour': dict({ + 'challenges': -1, + 'classes': -1, + 'equipment': -1, + 'groupPlans': -1, + 'guilds': -1, + 'hall': -1, + 'intro': -1, + 'market': -1, + 'mounts': -1, + 'party': -1, + 'pets': -1, + 'stats': -1, + 'tavern': -1, + }), + 'tutorial': dict({ + 'common': dict({ + 'classes': True, + 'dailies': True, + 'equipment': True, + 'gems': True, + 'habits': True, + 'inbox': True, + 'items': True, + 'mounts': True, + 'party': True, + 'pets': True, + 'rewards': True, + 'skills': True, + 'stats': True, + 'tavern': True, + 'todos': True, + }), + 'ios': dict({ + 'addTask': False, + 'deleteTask': False, + 'editTask': False, + 'filterTask': False, + 'groupPets': False, + 'inviteParty': False, + 'reorderTask': False, + }), + }), + 'verifiedUsername': True, + 'warnedLowHealth': True, + 'weeklyRecapEmailsPhase': 0, + 'welcomed': True, + }), + 'guilds': list([ + ]), + 'history': dict({ + 'exp': list([ + dict({ + 'completed': None, + 'date': '2024-10-30T19:37:01.970000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 24.0, + }), + dict({ + 'completed': None, + 'date': '2024-10-31T23:33:14.972000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 48.0, + }), + dict({ + 'completed': None, + 'date': '2024-11-05T18:25:04.681000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 66.0, + }), + dict({ + 'completed': None, + 'date': '2024-11-21T15:09:07.501000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 66.0, + }), + dict({ + 'completed': None, + 'date': '2024-11-22T00:41:21.137000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 66.0, + }), + dict({ + 'completed': None, + 'date': '2024-11-27T19:34:28.887000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': 66.0, + }), + ]), + 'todos': list([ + dict({ + 'completed': None, + 'date': '2024-10-30T19:37:01.970000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -5.0, + }), + dict({ + 'completed': None, + 'date': '2024-10-31T23:33:14.972000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -10.129783523135325, + }), + dict({ + 'completed': None, + 'date': '2024-11-05T18:25:04.681000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -16.396221153338182, + }), + dict({ + 'completed': None, + 'date': '2024-11-21T15:09:07.501000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -22.8326979965846, + }), + dict({ + 'completed': None, + 'date': '2024-11-22T00:41:21.137000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -29.448636229365235, + }), + dict({ + 'completed': None, + 'date': '2024-11-27T19:34:28.887000+00:00', + 'isDue': None, + 'scoredDown': None, + 'scoredUp': None, + 'value': -36.25425987861077, + }), + ]), + }), + 'id': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', + 'inbox': dict({ + 'blocks': list([ + ]), + 'messages': dict({ + }), + 'newMessages': 0, + 'optOut': False, + }), + 'invitations': dict({ + 'guilds': list([ + ]), + 'parties': list([ + ]), + 'party': dict({ + }), + }), + 'invitesSent': 0, 'items': dict({ + 'currentMount': '', + 'currentPet': '', + 'eggs': dict({ + 'BearCub': 1, + 'Cactus': 1, + 'Dragon': 1, + 'Wolf': 1, + }), + 'food': dict({ + 'Candy_Red': 1, + 'Chocolate': 1, + 'CottonCandyPink': 1, + 'Meat': 1, + }), 'gear': dict({ + 'costume': dict({ + 'armor': 'armor_base_0', + 'back': None, + 'body': None, + 'eyewear': None, + 'head': 'head_base_0', + 'headAccessory': None, + 'shield': 'shield_base_0', + 'weapon': None, + }), 'equipped': dict({ - 'armor': 'armor_warrior_5', - 'back': 'back_special_heroicAureole', - 'body': 'body_special_aetherAmulet', - 'eyewear': 'eyewear_armoire_plagueDoctorMask', - 'head': 'head_warrior_5', - 'headAccessory': 'headAccessory_armoire_gogglesOfBookbinding', - 'shield': 'shield_warrior_5', - 'weapon': 'weapon_warrior_5', + 'armor': 'armor_base_0', + 'back': None, + 'body': None, + 'eyewear': None, + 'head': 'head_base_0', + 'headAccessory': None, + 'shield': 'shield_base_0', + 'weapon': None, + }), + 'owned': dict({ + 'armor_special_bardRobes': True, + 'eyewear_special_blackHalfMoon': True, + 'eyewear_special_blackTopFrame': True, + 'eyewear_special_blueHalfMoon': True, + 'eyewear_special_blueTopFrame': True, + 'eyewear_special_greenHalfMoon': True, + 'eyewear_special_greenTopFrame': True, + 'eyewear_special_pinkHalfMoon': True, + 'eyewear_special_pinkTopFrame': True, + 'eyewear_special_redHalfMoon': True, + 'eyewear_special_redTopFrame': True, + 'eyewear_special_whiteHalfMoon': True, + 'eyewear_special_whiteTopFrame': True, + 'eyewear_special_yellowHalfMoon': True, + 'eyewear_special_yellowTopFrame': True, + '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, + 'head_special_bardHat': True, + }), + }), + 'hatchingPotions': dict({ + 'RoyalPurple': 1, + 'Skeleton': 1, + 'Zombie': 1, + }), + 'lastDrop': dict({ + 'count': 0, + 'date': '2024-11-05T18:25:04.619000+00:00', + }), + 'mounts': dict({ + }), + 'pets': dict({ + }), + 'quests': dict({ + 'dustbunnies': 1, + }), + 'special': dict({ + 'birthday': 0, + 'birthdayReceived': list([ + ]), + 'congrats': 0, + 'congratsReceived': list([ + ]), + 'getwell': 0, + 'getwellReceived': list([ + ]), + 'goodluck': 0, + 'goodluckReceived': list([ + ]), + 'greeting': 0, + 'greetingReceived': list([ + ]), + 'nye': 0, + 'nyeReceived': list([ + ]), + 'seafoam': 0, + 'shinySeed': 0, + 'snowball': 0, + 'spookySparkles': 0, + 'thankyou': 0, + 'thankyouReceived': list([ + ]), + 'valentine': 0, + 'valentineReceived': list([ + ]), + }), + }), + 'lastCron': '2024-11-27T19:34:28.887000+00:00', + 'loginIncentives': 6, + 'needsCron': None, + 'newMessages': dict({ + }), + 'notifications': list([ + ]), + 'party': dict({ + '_id': '94cd398c-2240-4320-956e-6d345cf2c0de', + 'order': 'level', + 'orderAscending': 'ascending', + 'quest': dict({ + 'RSVPNeeded': True, + 'completed': None, + 'key': 'dustbunnies', + 'progress': dict({ + 'collect': dict({ + }), + 'collectedItems': 0, + 'down': 0.0, + 'up': 0.0, }), }), }), - 'lastCron': '2024-09-21T22:01:55.586Z', - 'needsCron': True, - 'party': dict({ - '_id': '94cd398c-2240-4320-956e-6d345cf2c0de', - 'quest': dict({ - 'RSVPNeeded': True, - 'key': 'dustbunnies', + 'permissions': dict({ + 'challengeAdmin': None, + 'coupons': None, + 'fullAccess': None, + 'moderator': None, + 'news': None, + 'userSupport': None, + }), + 'pinnedItems': list([ + dict({ + 'Type': 'marketGear', + 'path': 'gear.flat.weapon_warrior_0', + }), + dict({ + 'Type': 'marketGear', + 'path': 'gear.flat.armor_warrior_1', + }), + dict({ + 'Type': 'marketGear', + 'path': 'gear.flat.shield_warrior_1', + }), + dict({ + 'Type': 'marketGear', + 'path': 'gear.flat.head_warrior_1', + }), + dict({ + 'Type': 'potion', + 'path': 'potion', + }), + dict({ + 'Type': 'armoire', + 'path': 'armoire', + }), + ]), + 'pinnedItemsOrder': list([ + ]), + 'preferences': dict({ + 'advancedCollapsed': False, + 'allocationMode': 'flat', + 'autoEquip': True, + 'automaticAllocation': None, + 'background': 'violet', + 'chair': 'none', + 'costume': False, + 'dailyDueDefaultView': False, + 'dateFormat': 'MM/dd/yyyy', + 'dayStart': 0, + 'developerMode': False, + 'disableClasses': False, + 'displayInviteToPartyWhenPartyIs1': True, + 'emailNotifications': dict({ + 'contentRelease': True, + 'giftedGems': True, + 'giftedSubscription': True, + 'importantAnnouncements': True, + 'invitedGuild': True, + 'invitedParty': True, + 'invitedQuest': True, + 'kickedGroup': True, + 'majorUpdates': True, + 'newPM': True, + 'onboarding': True, + 'questStarted': True, + 'subscriptionReminders': True, + 'unsubscribeFromAll': False, + 'weeklyRecaps': True, + 'wonChallenge': True, + }), + 'hair': dict({ + 'bangs': 1, + 'base': 3, + 'beard': 0, + 'color': 'red', + 'flower': 1, + 'mustache': 0, + }), + 'hideHeader': False, + 'improvementCategories': list([ + ]), + 'language': 'de', + 'newTaskEdit': False, + 'pushNotifications': dict({ + 'contentRelease': True, + 'giftedGems': True, + 'giftedSubscription': True, + 'invitedGuild': True, + 'invitedParty': True, + 'invitedQuest': True, + 'majorUpdates': True, + 'mentionJoinedGuild': True, + 'mentionParty': True, + 'mentionUnjoinedGuild': True, + 'newPM': True, + 'partyActivity': True, + 'questStarted': True, + 'unsubscribeFromAll': False, + 'wonChallenge': True, + }), + 'reverseChatOrder': False, + 'shirt': 'blue', + 'size': 'slim', + 'skin': '915533', + 'sleep': False, + 'sound': 'rosstavoTheme', + 'stickyHeader': True, + 'suppressModals': dict({ + 'hatchPet': False, + 'levelUp': False, + 'raisePet': False, + 'streak': False, + }), + 'tasks': dict({ + 'activeFilter': dict({ + 'daily': 'all', + 'habit': 'all', + 'reward': 'all', + 'todo': 'remaining', + }), + 'confirmScoreNotes': False, + 'groupByChallenge': False, + 'mirrorGroupTasks': list([ + ]), + }), + 'timezoneOffset': -60, + 'timezoneOffsetAtLastCron': -60, + 'toolbarCollapsed': False, + 'webhooks': dict({ }), }), - 'preferences': dict({ - 'automaticAllocation': True, - 'disableClasses': False, - 'language': 'en', - 'sleep': False, - }), 'profile': dict({ - 'name': 'test-user', + 'blurb': None, + 'imageUrl': None, + 'name': None, }), + 'purchased': dict({ + 'ads': False, + 'background': dict({ + 'blue': True, + 'green': True, + 'purple': True, + 'red': True, + 'violet': True, + 'yellow': True, + }), + 'hair': dict({ + }), + 'mobileChat': None, + 'plan': dict({ + 'consecutive': dict({ + 'count': None, + 'gemCapExtra': None, + 'offset': None, + 'trinkets': None, + }), + 'dateUpdated': None, + 'extraMonths': None, + 'gemsBought': None, + 'mysteryItems': list([ + ]), + 'perkMonthCount': None, + 'quantity': None, + }), + 'shirt': dict({ + }), + 'skin': dict({ + }), + 'txnCount': 0, + }), + 'pushDevices': list([ + ]), + 'secret': None, 'stats': dict({ + 'Class': 'warrior', + 'Int': 0, + 'Str': 0, 'buffs': dict({ - 'con': 26, - 'int': 26, - 'per': 26, + 'Int': 0, + 'Str': 0, + 'con': 0, + 'per': 0, 'seafoam': False, 'shinySeed': False, 'snowball': False, 'spookySparkles': False, 'stealth': 0, - 'str': 26, 'streaks': False, }), - 'class': 'wizard', - 'con': 15, - 'exp': 737, - 'gp': 137.62587214609795, - 'hp': 0, - 'int': 15, - 'lvl': 38, + 'con': 0, + 'exp': 41, + 'gp': 11.100978952781748, + 'hp': 25.40000000000002, + 'lvl': 2, 'maxHealth': 50, - 'maxMP': 166, - 'mp': 50.89999999999998, - 'per': 15, - 'points': 5, - 'str': 15, - 'toNextLevel': 880, + 'maxMP': 32, + 'mp': 32.0, + 'per': 0, + 'points': 2, + 'toNextLevel': 50, + 'training': dict({ + 'Int': 0, + 'Str': 0.0, + 'con': 0, + 'per': 0, + }), }), + 'tags': list([ + dict({ + 'challenge': True, + 'group': None, + 'id': 'c1a35186-9895-4ac0-9cd7-49e7bb875695', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': '53d1deb8-ed2b-4f94-bbfc-955e9e92aa98', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': '29bf6a99-536f-446b-838f-a81d41e1ed4d', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': '1b1297e7-4fd8-460a-b148-e92d7bcfa9a5', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': '05e6cf40-48ea-415a-9b8b-e2ecad258ef6', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': 'fe53f179-59d8-4c28-9bf7-b9068ab552a4', + 'name': 'tag', + }), + dict({ + 'challenge': True, + 'group': None, + 'id': 'c44e9e8c-4bff-42df-98d5-1a1a7b69eada', + 'name': 'tag', + }), + ]), 'tasksOrder': dict({ 'dailys': list([ - 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', - 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', - 'e97659e0-2c42-4599-a7bb-00282adc410d', - '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', - 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', - '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', '6e53f1f5-a315-4edd-984d-8d762e4a08ef', ]), 'habits': list([ - '1d147de6-5c02-4740-8e2f-71d3015a37f4', + '30923acd-3b4c-486d-9ef3-c8f57cf56049', ]), 'rewards': list([ - '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', + '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', ]), 'todos': list([ - '88de7cd9-af2b-49ce-9afd-bf941d87336b', - '2f6fcabc-f670-4ec3-ba65-817e8deea490', - '1aa3137e-ef72-4d1f-91ee-41933602f438', - '86ea2475-d1b5-4020-bdcc-c188c7996afa', + 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', ]), }), + 'unpinnedItems': list([ + ]), + 'webhooks': list([ + ]), }), }), }) diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 7e72d486276..dcd49bb610e 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -7,9 +7,9 @@ 'capabilities': dict({ 'options': list([ 'warrior', - 'healer', - 'wizard', 'rogue', + 'wizard', + 'healer', ]), }), 'config_entry_id': , @@ -35,7 +35,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_class', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_class', 'unit_of_measurement': None, }) # --- @@ -46,9 +46,9 @@ 'friendly_name': 'test-user Class', 'options': list([ 'warrior', - 'healer', - 'wizard', 'rogue', + 'wizard', + 'healer', ]), }), 'context': , @@ -91,7 +91,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_constitution', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_constitution', 'unit_of_measurement': 'CON', }) # --- @@ -143,7 +143,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_dailys', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys', 'unit_of_measurement': 'tasks', }) # --- @@ -151,23 +151,39 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1': dict({ - 'created_at': '2024-09-22T11:44:43.774Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-09-22T11:44:43.774000+00:00', 'every_x': 1, 'frequency': 'weekly', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'is_due': True, 'next_due': list([ - '2024-09-24T22:00:00.000Z', - '2024-09-27T22:00:00.000Z', - '2024-09-28T22:00:00.000Z', - '2024-10-01T22:00:00.000Z', - '2024-10-04T22:00:00.000Z', - '2024-10-08T22:00:00.000Z', + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', 'priority': 2, @@ -180,7 +196,7 @@ 'th': False, 'w': True, }), - 'start_date': '2024-09-21T22:00:00.000Z', + 'start_date': '2024-09-21T22:00:00+00:00', 'tags': list([ '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), @@ -189,24 +205,40 @@ 'yester_daily': True, }), '564b9ac9-c53d-4638-9e7f-1cd96fe19baa': dict({ + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), 'completed': True, - 'created_at': '2024-07-07T17:51:53.268Z', + 'created_at': '2024-07-07T17:51:53.268000+00:00', 'every_x': 1, 'frequency': 'weekly', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'is_due': True, 'next_due': list([ - 'Mon Sep 23 2024 00:00:00 GMT+0200', - 'Tue Sep 24 2024 00:00:00 GMT+0200', - 'Wed Sep 25 2024 00:00:00 GMT+0200', - 'Thu Sep 26 2024 00:00:00 GMT+0200', - 'Fri Sep 27 2024 00:00:00 GMT+0200', - 'Sat Sep 28 2024 00:00:00 GMT+0200', + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', 'priority': 1, @@ -219,7 +251,7 @@ 'th': True, 'w': True, }), - 'start_date': '2024-07-06T22:00:00.000Z', + 'start_date': '2024-07-06T22:00:00+00:00', 'streak': 1, 'text': 'Zahnseide benutzen', 'type': 'daily', @@ -227,22 +259,38 @@ 'yester_daily': True, }), '6e53f1f5-a315-4edd-984d-8d762e4a08ef': dict({ - 'created_at': '2024-10-10T15:57:14.304Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-10-10T15:57:14.304000+00:00', 'every_x': 1, 'frequency': 'monthly', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'next_due': list([ - '2024-12-14T23:00:00.000Z', - '2025-01-18T23:00:00.000Z', - '2025-02-15T23:00:00.000Z', - '2025-03-15T23:00:00.000Z', - '2025-04-19T23:00:00.000Z', - '2025-05-17T23:00:00.000Z', + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', 'priority': 1, @@ -255,7 +303,7 @@ 'th': False, 'w': False, }), - 'start_date': '2024-09-20T23:00:00.000Z', + 'start_date': '2024-09-20T23:00:00+00:00', 'streak': 1, 'text': 'Arbeite an einem kreativen Projekt', 'type': 'daily', @@ -266,23 +314,39 @@ 'yester_daily': True, }), 'f2c85972-1a19-4426-bc6d-ce3337b9d99f': dict({ - 'created_at': '2024-07-07T17:51:53.266Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.266000+00:00', 'every_x': 1, 'frequency': 'weekly', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'is_due': True, 'next_due': list([ - '2024-09-22T22:00:00.000Z', - '2024-09-23T22:00:00.000Z', - '2024-09-24T22:00:00.000Z', - '2024-09-25T22:00:00.000Z', - '2024-09-26T22:00:00.000Z', - '2024-09-27T22:00:00.000Z', + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', 'priority': 1, @@ -295,7 +359,7 @@ 'th': True, 'w': True, }), - 'start_date': '2024-07-06T22:00:00.000Z', + 'start_date': '2024-07-06T22:00:00+00:00', 'text': '5 Minuten ruhig durchatmen', 'type': 'daily', 'value': -1.919611992979862, @@ -341,7 +405,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_display_name', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_display_name', 'unit_of_measurement': None, }) # --- @@ -387,7 +451,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_experience', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_experience', 'unit_of_measurement': 'XP', }) # --- @@ -437,7 +501,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_gems', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_gems', 'unit_of_measurement': 'gems', }) # --- @@ -453,7 +517,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '40', }) # --- # name: test_sensors[sensor.test_user_gold-entry] @@ -488,7 +552,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_gold', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_gold', 'unit_of_measurement': 'GP', }) # --- @@ -535,7 +599,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_habits', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_habits', 'unit_of_measurement': 'tasks', }) # --- @@ -543,60 +607,160 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ '1d147de6-5c02-4740-8e2f-71d3015a37f4': dict({ - 'created_at': '2024-07-07T17:51:53.266Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.266000+00:00', 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Eine kurze Pause machen', 'type': 'habit', 'up': True, }), 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4': dict({ - 'created_at': '2024-07-07T17:51:53.265Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.265000+00:00', 'down': True, 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', 'type': 'habit', }), 'e97659e0-2c42-4599-a7bb-00282adc410d': dict({ - 'created_at': '2024-07-07T17:51:53.264Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.264000+00:00', 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Füge eine Aufgabe zu Habitica hinzu', 'type': 'habit', 'up': True, }), 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a': dict({ - 'created_at': '2024-07-07T17:51:53.268Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.268000+00:00', 'down': True, 'frequency': 'daily', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Gesundes Essen/Junkfood', 'type': 'habit', 'up': True, @@ -644,7 +808,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_health', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health', 'unit_of_measurement': 'HP', }) # --- @@ -659,7 +823,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[sensor.test_user_intelligence-entry] @@ -694,7 +858,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_intelligence', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_intelligence', 'unit_of_measurement': 'INT', }) # --- @@ -746,7 +910,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_level', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_level', 'unit_of_measurement': None, }) # --- @@ -795,7 +959,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_mana', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mana', 'unit_of_measurement': 'MP', }) # --- @@ -842,7 +1006,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_health_max', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health_max', 'unit_of_measurement': 'HP', }) # --- @@ -889,7 +1053,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_mana_max', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_mana_max', 'unit_of_measurement': 'MP', }) # --- @@ -939,7 +1103,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_trinkets', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_trinkets', 'unit_of_measurement': '⧖', }) # --- @@ -987,7 +1151,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_experience_max', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_experience_max', 'unit_of_measurement': 'XP', }) # --- @@ -1037,7 +1201,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_perception', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_perception', 'unit_of_measurement': 'PER', }) # --- @@ -1089,7 +1253,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_rewards', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_rewards', 'unit_of_measurement': 'tasks', }) # --- @@ -1097,18 +1261,43 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b': dict({ - 'created_at': '2024-07-07T17:51:53.266Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-07-07T17:51:53.266000+00:00', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Belohne Dich selbst', 'type': 'reward', - 'value': 10, + 'value': 10.0, }), 'friendly_name': 'test-user Rewards', 'unit_of_measurement': 'tasks', @@ -1153,7 +1342,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_strength', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_strength', 'unit_of_measurement': 'STR', }) # --- @@ -1205,7 +1394,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_todos', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos', 'unit_of_measurement': 'tasks', }) # --- @@ -1213,41 +1402,116 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ '1aa3137e-ef72-4d1f-91ee-41933602f438': dict({ - 'created_at': '2024-09-21T22:16:38.153Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-09-21T22:16:38.153000+00:00', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Rasen mähen und die Pflanzen gießen.', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Garten pflegen', 'type': 'todo', }), '2f6fcabc-f670-4ec3-ba65-817e8deea490': dict({ - 'created_at': '2024-09-21T22:17:19.513Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-09-21T22:17:19.513000+00:00', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Rechnungen bezahlen', 'type': 'todo', }), '86ea2475-d1b5-4020-bdcc-c188c7996afa': dict({ - 'created_at': '2024-09-21T22:16:16.756Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-09-21T22:16:16.756000+00:00', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'tags': list([ '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), @@ -1255,15 +1519,40 @@ 'type': 'todo', }), '88de7cd9-af2b-49ce-9afd-bf941d87336b': dict({ - 'created_at': '2024-09-21T22:17:57.816Z', + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'created_at': '2024-09-21T22:17:57.816000+00:00', 'group': dict({ + 'assignedDate': None, 'assignedUsers': list([ ]), - 'completedBy': dict({ + 'assignedUsersDetail': dict({ }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, }), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', 'priority': 1, + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), 'text': 'Buch zu Ende lesen', 'type': 'todo', }), diff --git a/tests/components/habitica/snapshots/test_switch.ambr b/tests/components/habitica/snapshots/test_switch.ambr index 3affbd11e2a..a865df3a4f4 100644 --- a/tests/components/habitica/snapshots/test_switch.ambr +++ b/tests/components/habitica/snapshots/test_switch.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_sleep', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_sleep', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 8c49cad5436..25976270622 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -129,7 +129,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_dailys', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_dailys', 'unit_of_measurement': None, }) # --- @@ -176,7 +176,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': '00000000-0000-0000-0000-000000000000_todos', + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_todos', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/habitica/test_binary_sensor.py b/tests/components/habitica/test_binary_sensor.py index 1710f8f217e..80acc92385f 100644 --- a/tests/components/habitica/test_binary_sensor.py +++ b/tests/components/habitica/test_binary_sensor.py @@ -1,19 +1,19 @@ """Tests for the Habitica binary sensor platform.""" from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from habiticalib import HabiticaUserResponse import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.habitica.const import ASSETS_URL, DEFAULT_URL, DOMAIN +from homeassistant.components.habitica.const import ASSETS_URL, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -26,7 +26,7 @@ def binary_sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_binary_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -54,23 +54,17 @@ async def test_binary_sensors( async def test_pending_quest_states( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, fixture: str, entity_state: str, entity_picture: str | None, ) -> None: """Test states of pending quest sensor.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture(f"{fixture}.json", DOMAIN), - ) - aioclient_mock.get(f"{DEFAULT_URL}/api/v3/tasks/user", json={"data": []}) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index 09cc1c9d373..adce8dce080 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -2,31 +2,29 @@ from collections.abc import Generator from datetime import timedelta -from http import HTTPStatus -import re -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse, Skill import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN +from homeassistant.components.habitica.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util -from .conftest import mock_called_with +from .conftest import ERROR_BAD_REQUEST, ERROR_NOT_AUTHORIZED, ERROR_TOO_MANY_REQUESTS from tests.common import ( MockConfigEntry, async_fire_time_changed, - load_json_object_fixture, + load_fixture, snapshot_platform, ) -from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(autouse=True) @@ -51,29 +49,15 @@ def button_only() -> Generator[None]: async def test_buttons( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, fixture: str, ) -> None: """Test button entities.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture(f"{fixture}.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json=load_json_object_fixture("completed_todos.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -85,70 +69,87 @@ async def test_buttons( @pytest.mark.parametrize( - ("entity_id", "api_url", "fixture"), + ("entity_id", "call_func", "call_args", "fixture"), [ - ("button.test_user_allocate_all_stat_points", "user/allocate-now", "user"), - ("button.test_user_buy_a_health_potion", "user/buy-health-potion", "user"), - ("button.test_user_revive_from_death", "user/revive", "user"), - ("button.test_user_start_my_day", "cron", "user"), + ( + "button.test_user_allocate_all_stat_points", + "allocate_stat_points", + None, + "user", + ), + ("button.test_user_buy_a_health_potion", "buy_health_potion", None, "user"), + ("button.test_user_revive_from_death", "revive", None, "user"), + ("button.test_user_start_my_day", "run_cron", None, "user"), ( "button.test_user_chilling_frost", - "user/class/cast/frost", + "cast_skill", + Skill.CHILLING_FROST, "wizard_fixture", ), ( "button.test_user_earthquake", - "user/class/cast/earth", + "cast_skill", + Skill.EARTHQUAKE, "wizard_fixture", ), ( "button.test_user_ethereal_surge", - "user/class/cast/mpheal", + "cast_skill", + Skill.ETHEREAL_SURGE, "wizard_fixture", ), ( "button.test_user_stealth", - "user/class/cast/stealth", + "cast_skill", + Skill.STEALTH, "rogue_fixture", ), ( "button.test_user_tools_of_the_trade", - "user/class/cast/toolsOfTrade", + "cast_skill", + Skill.TOOLS_OF_THE_TRADE, "rogue_fixture", ), ( "button.test_user_defensive_stance", - "user/class/cast/defensiveStance", + "cast_skill", + Skill.DEFENSIVE_STANCE, "warrior_fixture", ), ( "button.test_user_intimidating_gaze", - "user/class/cast/intimidate", + "cast_skill", + Skill.INTIMIDATING_GAZE, "warrior_fixture", ), ( "button.test_user_valorous_presence", - "user/class/cast/valorousPresence", + "cast_skill", + Skill.VALOROUS_PRESENCE, "warrior_fixture", ), ( "button.test_user_healing_light", - "user/class/cast/heal", + "cast_skill", + Skill.HEALING_LIGHT, "healer_fixture", ), ( "button.test_user_protective_aura", - "user/class/cast/protectAura", + "cast_skill", + Skill.PROTECTIVE_AURA, "healer_fixture", ), ( "button.test_user_searing_brightness", - "user/class/cast/brightness", + "cast_skill", + Skill.SEARING_BRIGHTNESS, "healer_fixture", ), ( "button.test_user_blessing", - "user/class/cast/healAll", + "cast_skill", + Skill.BLESSING, "healer_fixture", ), ], @@ -156,58 +157,48 @@ async def test_buttons( async def test_button_press( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, entity_id: str, - api_url: str, + call_func: str, + call_args: Skill | None, fixture: str, ) -> None: """Test button press method.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture(f"{fixture}.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json=load_json_object_fixture("completed_todos.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) + 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 - aioclient_mock.post(f"{DEFAULT_URL}/api/v3/{api_url}", json={"data": None}) - + mocked = getattr(habitica, call_func) + mocked.reset_mock() await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - assert mock_called_with(aioclient_mock, "post", f"{DEFAULT_URL}/api/v3/{api_url}") + if call_args: + mocked.assert_awaited_once_with(call_args) + else: + mocked.assert_awaited_once() @pytest.mark.parametrize( - ("entity_id", "api_url"), + ("entity_id", "call_func"), [ - ("button.test_user_allocate_all_stat_points", "user/allocate-now"), - ("button.test_user_buy_a_health_potion", "user/buy-health-potion"), - ("button.test_user_revive_from_death", "user/revive"), - ("button.test_user_start_my_day", "cron"), - ("button.test_user_chilling_frost", "user/class/cast/frost"), - ("button.test_user_earthquake", "user/class/cast/earth"), - ("button.test_user_ethereal_surge", "user/class/cast/mpheal"), + ("button.test_user_allocate_all_stat_points", "allocate_stat_points"), + ("button.test_user_buy_a_health_potion", "buy_health_potion"), + ("button.test_user_revive_from_death", "revive"), + ("button.test_user_start_my_day", "run_cron"), + ("button.test_user_chilling_frost", "cast_skill"), + ("button.test_user_earthquake", "cast_skill"), + ("button.test_user_ethereal_surge", "cast_skill"), ], ids=[ "allocate-points", @@ -220,20 +211,20 @@ async def test_button_press( ], ) @pytest.mark.parametrize( - ("status_code", "msg", "exception"), + ("raise_exception", "msg", "expected_exception"), [ ( - HTTPStatus.TOO_MANY_REQUESTS, + ERROR_TOO_MANY_REQUESTS, "Rate limit exceeded, try again later", - ServiceValidationError, + HomeAssistantError, ), ( - HTTPStatus.BAD_REQUEST, + ERROR_BAD_REQUEST, "Unable to connect to Habitica, try again later", HomeAssistantError, ), ( - HTTPStatus.UNAUTHORIZED, + ERROR_NOT_AUTHORIZED, "Unable to complete action, the required conditions are not met", ServiceValidationError, ), @@ -242,12 +233,12 @@ async def test_button_press( async def test_button_press_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, entity_id: str, - api_url: str, - status_code: HTTPStatus, + call_func: str, + raise_exception: Exception, msg: str, - exception: Exception, + expected_exception: Exception, ) -> None: """Test button press exceptions.""" @@ -257,13 +248,10 @@ async def test_button_press_exceptions( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/{api_url}", - status=status_code, - json={"data": None}, - ) + func = getattr(habitica, call_func) + func.side_effect = raise_exception - with pytest.raises(exception, match=msg): + with pytest.raises(expected_exception, match=msg): await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, @@ -271,8 +259,6 @@ async def test_button_press_exceptions( blocking=True, ) - assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/{api_url}") - @pytest.mark.parametrize( ("fixture", "entity_ids"), @@ -322,21 +308,15 @@ async def test_button_press_exceptions( async def test_button_unavailable( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, fixture: str, entity_ids: list[str], ) -> None: """Test buttons are unavailable if conditions are not met.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture(f"{fixture}.json", DOMAIN), + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get(re.compile(r".*"), json={"data": []}) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -352,9 +332,8 @@ async def test_button_unavailable( async def test_class_change( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test removing and adding skills after class change.""" mage_skills = [ @@ -368,23 +347,9 @@ async def test_class_change( "button.test_user_searing_brightness", "button.test_user_blessing", ] - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture("wizard_fixture.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json=load_json_object_fixture("completed_todos.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture("tasks.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("wizard_fixture.json", DOMAIN) ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -395,13 +360,11 @@ async def test_class_change( for skill in mage_skills: assert hass.states.get(skill) - aioclient_mock._mocks.pop(0) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture("healer_fixture.json", DOMAIN), + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("healer_fixture.json", DOMAIN) ) - - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=60)) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) await hass.async_block_till_done() for skill in mage_skills: diff --git a/tests/components/habitica/test_calendar.py b/tests/components/habitica/test_calendar.py index ff3ffbeb80d..24e640d0888 100644 --- a/tests/components/habitica/test_calendar.py +++ b/tests/components/habitica/test_calendar.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import patch +from freezegun.api import freeze_time import pytest from syrupy.assertion import SnapshotAssertion @@ -31,8 +32,8 @@ async def set_tz(hass: HomeAssistant) -> None: await hass.config.async_set_time_zone("Europe/Berlin") -@pytest.mark.usefixtures("mock_habitica") -@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z") +@pytest.mark.usefixtures("habitica") +@freeze_time("2024-09-20T22:00:00.000Z") async def test_calendar_platform( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -70,8 +71,7 @@ async def test_calendar_platform( "date range in the past", ], ) -@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z") -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_api_events( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 604877f0c47..d8e6c36cd1f 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -1,14 +1,14 @@ """Test the habitica config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock -from aiohttp import ClientResponseError import pytest -from homeassistant import config_entries from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, @@ -17,23 +17,32 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import ERROR_BAD_REQUEST, ERROR_NOT_AUTHORIZED + +from tests.common import MockConfigEntry + +TEST_API_USER = "a380546a-94be-4b8e-8a0b-23e0d5c03303" +TEST_API_KEY = "cd0e5985-17de-4b4f-849e-5d506c5e4382" + + MOCK_DATA_LOGIN_STEP = { CONF_USERNAME: "test-email@example.com", CONF_PASSWORD: "test-password", } MOCK_DATA_ADVANCED_STEP = { - CONF_API_USER: "test-api-user", - CONF_API_KEY: "test-api-key", + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, CONF_VERIFY_SSL: True, } -async def test_form_login(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("habitica") +async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the login form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU @@ -47,55 +56,41 @@ async def test_form_login(hass: HomeAssistant) -> None: assert result["errors"] == {} assert result["step_id"] == "login" - mock_obj = MagicMock() - mock_obj.user.auth.local.login.post = AsyncMock() - mock_obj.user.auth.local.login.post.return_value = { - "id": "test-api-user", - "apiToken": "test-api-key", - "username": "test-username", - } - with ( - patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ), - patch( - "homeassistant.components.habitica.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.habitica.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_LOGIN_STEP, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LOGIN_STEP, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" + assert result["title"] == "test-user" assert result["data"] == { - **MOCK_DATA_ADVANCED_STEP, - CONF_USERNAME: "test-username", + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, + CONF_URL: DEFAULT_URL, + CONF_NAME: "test-user", + CONF_VERIFY_SSL: True, } - assert len(mock_setup.mock_calls) == 1 + assert result["result"].unique_id == TEST_API_USER + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( ("raise_error", "text_error"), [ - (ClientResponseError(MagicMock(), (), status=400), "cannot_connect"), - (ClientResponseError(MagicMock(), (), status=401), "invalid_auth"), + (ERROR_BAD_REQUEST, "cannot_connect"), + (ERROR_NOT_AUTHORIZED, "invalid_auth"), (IndexError(), "unknown"), ], ) -async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) -> None: +async def test_form_login_errors( + hass: HomeAssistant, habitica: AsyncMock, raise_error, text_error +) -> None: """Test we handle invalid credentials error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU @@ -105,26 +100,40 @@ async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) - DOMAIN, context={"source": "login"} ) - mock_obj = MagicMock() - mock_obj.user.auth.local.login.post = AsyncMock(side_effect=raise_error) - with patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_LOGIN_STEP, - ) + habitica.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LOGIN_STEP, + ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": text_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + # recover from errors + habitica.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LOGIN_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-user" + assert result["data"] == { + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, + CONF_URL: DEFAULT_URL, + CONF_NAME: "test-user", + CONF_VERIFY_SSL: True, + } + assert result["result"].unique_id == TEST_API_USER -async def test_form_advanced(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("habitica") +async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU @@ -144,54 +153,41 @@ async def test_form_advanced(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_obj = MagicMock() - mock_obj.user.get = AsyncMock() - mock_obj.user.get.return_value = {"auth": {"local": {"username": "test-username"}}} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ), - patch( - "homeassistant.components.habitica.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.habitica.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_ADVANCED_STEP, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - **MOCK_DATA_ADVANCED_STEP, - CONF_USERNAME: "test-username", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-user" + assert result["data"] == { + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, + CONF_URL: DEFAULT_URL, + CONF_NAME: "test-user", + CONF_VERIFY_SSL: True, } - assert len(mock_setup.mock_calls) == 1 + assert result["result"].unique_id == TEST_API_USER + assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.parametrize( ("raise_error", "text_error"), [ - (ClientResponseError(MagicMock(), (), status=400), "cannot_connect"), - (ClientResponseError(MagicMock(), (), status=401), "invalid_auth"), + (ERROR_BAD_REQUEST, "cannot_connect"), + (ERROR_NOT_AUTHORIZED, "invalid_auth"), (IndexError(), "unknown"), ], ) async def test_form_advanced_errors( - hass: HomeAssistant, raise_error, text_error + hass: HomeAssistant, habitica: AsyncMock, raise_error, text_error ) -> None: """Test we handle invalid credentials error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU @@ -201,17 +197,59 @@ async def test_form_advanced_errors( DOMAIN, context={"source": "advanced"} ) - mock_obj = MagicMock() - mock_obj.user.get = AsyncMock(side_effect=raise_error) + habitica.get_user.side_effect = raise_error - with patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_ADVANCED_STEP, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": text_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + # recover from errors + habitica.get_user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-user" + assert result["data"] == { + CONF_API_USER: TEST_API_USER, + CONF_API_KEY: TEST_API_KEY, + CONF_URL: DEFAULT_URL, + CONF_NAME: "test-user", + CONF_VERIFY_SSL: True, + } + assert result["result"].unique_id == TEST_API_USER + + +@pytest.mark.usefixtures("habitica") +async def test_form_advanced_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort user data set when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/habitica/test_diagnostics.py b/tests/components/habitica/test_diagnostics.py index 68b40fe254a..c6dbc37ff20 100644 --- a/tests/components/habitica/test_diagnostics.py +++ b/tests/components/habitica/test_diagnostics.py @@ -10,7 +10,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index fd8a18b2d44..40f57b0abe5 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -1,8 +1,8 @@ """Test the habitica module.""" import datetime -from http import HTTPStatus import logging +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -11,7 +11,6 @@ from homeassistant.components.habitica.const import ( ATTR_ARGS, ATTR_DATA, ATTR_PATH, - DEFAULT_URL, DOMAIN, EVENT_API_CALL_SUCCESS, SERVICE_API_CALL, @@ -20,16 +19,14 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME from homeassistant.core import Event, HomeAssistant -from tests.common import ( - MockConfigEntry, - async_capture_events, - async_fire_time_changed, - load_json_object_fixture, +from .conftest import ( + ERROR_BAD_REQUEST, + ERROR_NOT_AUTHORIZED, + ERROR_NOT_FOUND, + ERROR_TOO_MANY_REQUESTS, ) -from tests.test_util.aiohttp import AiohttpClientMocker -TEST_API_CALL_ARGS = {"text": "Use API from Home Assistant", "type": "todo"} -TEST_USER_NAME = "test_user" +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed @pytest.fixture @@ -38,7 +35,7 @@ def capture_api_call_success(hass: HomeAssistant) -> list[Event]: return async_capture_events(hass, EVENT_API_CALL_SUCCESS) -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_entry_setup_unload( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -55,12 +52,11 @@ async def test_entry_setup_unload( assert config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_service_call( hass: HomeAssistant, config_entry: MockConfigEntry, capture_api_call_success: list[Event], - mock_habitica: AiohttpClientMocker, ) -> None: """Test integration setup, service call and unload.""" config_entry.add_to_hass(hass) @@ -71,16 +67,10 @@ async def test_service_call( assert len(capture_api_call_success) == 0 - mock_habitica.post( - "https://habitica.com/api/v3/tasks/user", - status=HTTPStatus.CREATED, - json={"data": TEST_API_CALL_ARGS}, - ) - TEST_SERVICE_DATA = { ATTR_NAME: "test-user", ATTR_PATH: ["tasks", "user", "post"], - ATTR_ARGS: TEST_API_CALL_ARGS, + ATTR_ARGS: {"text": "Use API from Home Assistant", "type": "todo"}, } await hass.services.async_call( DOMAIN, SERVICE_API_CALL, TEST_SERVICE_DATA, blocking=True @@ -94,21 +84,23 @@ async def test_service_call( @pytest.mark.parametrize( - ("status"), [HTTPStatus.NOT_FOUND, HTTPStatus.TOO_MANY_REQUESTS] + ("exception"), + [ + ERROR_BAD_REQUEST, + ERROR_TOO_MANY_REQUESTS, + ERROR_NOT_AUTHORIZED, + ], + ids=["BadRequestError", "TooManyRequestsError", "NotAuthorizedError"], ) async def test_config_entry_not_ready( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - status: HTTPStatus, + habitica: AsyncMock, + exception: Exception, ) -> None: """Test config entry not ready.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - status=status, - ) - + habitica.get_user.side_effect = exception config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -119,19 +111,11 @@ async def test_config_entry_not_ready( async def test_coordinator_update_failed( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test coordinator update failed.""" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", - json=load_json_object_fixture("user.json", DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - status=HTTPStatus.NOT_FOUND, - ) - + habitica.get_tasks.side_effect = ERROR_NOT_FOUND config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -142,7 +126,7 @@ async def test_coordinator_update_failed( async def test_coordinator_rate_limited( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, ) -> None: @@ -154,11 +138,7 @@ async def test_coordinator_rate_limited( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.clear_requests() - mock_habitica.get( - f"{DEFAULT_URL}/api/v3/user", - status=HTTPStatus.TOO_MANY_REQUESTS, - ) + habitica.get_user.side_effect = ERROR_TOO_MANY_REQUESTS with caplog.at_level(logging.DEBUG): freezer.tick(datetime.timedelta(seconds=60)) diff --git a/tests/components/habitica/test_sensor.py b/tests/components/habitica/test_sensor.py index defe5a270ae..70f1dc256d7 100644 --- a/tests/components/habitica/test_sensor.py +++ b/tests/components/habitica/test_sensor.py @@ -26,7 +26,7 @@ def sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_habitica", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -44,7 +44,7 @@ async def test_sensors( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) -@pytest.mark.usefixtures("mock_habitica", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") async def test_sensor_deprecation_issue( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index cd363eba3b5..fb40110f2b0 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -1,10 +1,11 @@ """Test Habitica actions.""" from collections.abc import Generator -from http import HTTPStatus from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from uuid import UUID +from habiticalib import Direction, Skill import pytest from homeassistant.components.habitica.const import ( @@ -14,7 +15,6 @@ from homeassistant.components.habitica.const import ( ATTR_SKILL, ATTR_TARGET, ATTR_TASK, - DEFAULT_URL, DOMAIN, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, @@ -31,10 +31,14 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .conftest import load_json_object_fixture, mock_called_with +from .conftest import ( + ERROR_BAD_REQUEST, + ERROR_NOT_AUTHORIZED, + ERROR_NOT_FOUND, + ERROR_TOO_MANY_REQUESTS, +) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later" RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later" @@ -54,7 +58,7 @@ def services_only() -> Generator[None]: async def load_entry( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, services_only: Generator, ) -> None: """Load config entry.""" @@ -75,55 +79,70 @@ def uuid_mock() -> Generator[None]: @pytest.mark.parametrize( - ("service_data", "item", "target_id"), + ( + "service_data", + "call_args", + ), [ ( { ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_SKILL: "pickpocket", }, - "pickPocket", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.PICKPOCKET, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_SKILL: "backstab", }, - "backStab", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BACKSTAB, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_SKILL: "fireball", }, - "fireball", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BURST_OF_FLAMES, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", ATTR_SKILL: "smash", }, - "smash", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BRUTAL_SMASH, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - "smash", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BRUTAL_SMASH, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ( { ATTR_TASK: "pay_bills", ATTR_SKILL: "smash", }, - "smash", - "2f6fcabc-f670-4ec3-ba65-817e8deea490", + { + "skill": Skill.BRUTAL_SMASH, + "target_id": UUID("2f6fcabc-f670-4ec3-ba65-817e8deea490"), + }, ), ], ids=[ @@ -138,18 +157,12 @@ def uuid_mock() -> Generator[None]: async def test_cast_skill( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - item: str, - target_id: str, + call_args: dict[str, Any], ) -> None: """Test Habitica cast skill action.""" - 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_CAST_SKILL, @@ -160,18 +173,13 @@ async def test_cast_skill( return_response=True, blocking=True, ) - - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", - ) + habitica.cast_skill.assert_awaited_once_with(**call_args) @pytest.mark.parametrize( ( "service_data", - "http_status", + "raise_exception", "expected_exception", "expected_exception_msg", ), @@ -181,7 +189,7 @@ async def test_cast_skill( ATTR_TASK: "task-not-found", ATTR_SKILL: "smash", }, - HTTPStatus.OK, + None, ServiceValidationError, "Unable to complete action, could not find the task 'task-not-found'", ), @@ -190,8 +198,8 @@ async def test_cast_skill( ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - HTTPStatus.TOO_MANY_REQUESTS, - ServiceValidationError, + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, RATE_LIMIT_EXCEPTION_MSG, ), ( @@ -199,7 +207,7 @@ async def test_cast_skill( ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - HTTPStatus.NOT_FOUND, + ERROR_NOT_FOUND, ServiceValidationError, "Unable to cast skill, your character does not have the skill or spell smash", ), @@ -208,7 +216,7 @@ async def test_cast_skill( ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - HTTPStatus.UNAUTHORIZED, + ERROR_NOT_AUTHORIZED, ServiceValidationError, "Unable to cast skill, not enough mana. Your character has 50 MP, but the skill costs 10 MP", ), @@ -217,30 +225,24 @@ async def test_cast_skill( ATTR_TASK: "Rechnungen bezahlen", ATTR_SKILL: "smash", }, - HTTPStatus.BAD_REQUEST, + ERROR_BAD_REQUEST, HomeAssistantError, REQUEST_EXCEPTION_MSG, ), ], ) -@pytest.mark.usefixtures("mock_habitica") async def test_cast_skill_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - http_status: HTTPStatus, + raise_exception: Exception, expected_exception: Exception, expected_exception_msg: str, ) -> None: """Test Habitica cast skill action exceptions.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/class/cast/smash?targetId=2f6fcabc-f670-4ec3-ba65-817e8deea490", - json={"success": True, "data": {}}, - status=http_status, - ) - + habitica.cast_skill.side_effect = raise_exception with pytest.raises(expected_exception, match=expected_exception_msg): await hass.services.async_call( DOMAIN, @@ -254,11 +256,9 @@ async def test_cast_skill_exceptions( ) -@pytest.mark.usefixtures("mock_habitica") async def test_get_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, ) -> None: """Test Habitica config entry exceptions.""" @@ -298,31 +298,24 @@ async def test_get_config_entry( @pytest.mark.parametrize( - ("service", "command"), + "service", [ - (SERVICE_ABORT_QUEST, "abort"), - (SERVICE_ACCEPT_QUEST, "accept"), - (SERVICE_CANCEL_QUEST, "cancel"), - (SERVICE_LEAVE_QUEST, "leave"), - (SERVICE_REJECT_QUEST, "reject"), - (SERVICE_START_QUEST, "force-start"), + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, ], - ids=[], ) async def test_handle_quests( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service: str, - command: str, ) -> None: """Test Habitica actions for quest handling.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}", - json={"success": True, "data": {}}, - ) - await hass.services.async_call( DOMAIN, service, @@ -331,63 +324,65 @@ async def test_handle_quests( blocking=True, ) - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}", - ) + getattr(habitica, service).assert_awaited_once() @pytest.mark.parametrize( ( - "http_status", + "raise_exception", "expected_exception", "expected_exception_msg", ), [ ( - HTTPStatus.TOO_MANY_REQUESTS, - ServiceValidationError, + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, RATE_LIMIT_EXCEPTION_MSG, ), ( - HTTPStatus.NOT_FOUND, + ERROR_NOT_FOUND, ServiceValidationError, "Unable to complete action, quest or group not found", ), ( - HTTPStatus.UNAUTHORIZED, + ERROR_NOT_AUTHORIZED, ServiceValidationError, "Action not allowed, only quest leader or group leader can perform this action", ), ( - HTTPStatus.BAD_REQUEST, + ERROR_BAD_REQUEST, HomeAssistantError, REQUEST_EXCEPTION_MSG, ), ], ) -@pytest.mark.usefixtures("mock_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, - mock_habitica: AiohttpClientMocker, - http_status: HTTPStatus, + habitica: AsyncMock, + raise_exception: Exception, + service: str, expected_exception: Exception, expected_exception_msg: str, ) -> None: """Test Habitica handle quests action exceptions.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/groups/party/quests/accept", - json={"success": True, "data": {}}, - status=http_status, - ) - + getattr(habitica, service).side_effect = raise_exception with pytest.raises(expected_exception, match=expected_exception_msg): await hass.services.async_call( DOMAIN, - SERVICE_ACCEPT_QUEST, + service, service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id}, return_response=True, blocking=True, @@ -395,7 +390,7 @@ async def test_handle_quests_exceptions( @pytest.mark.parametrize( - ("service", "service_data", "task_id"), + ("service", "service_data", "call_args"), [ ( SERVICE_SCORE_HABIT, @@ -403,7 +398,10 @@ async def test_handle_quests_exceptions( ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_DIRECTION: "up", }, - "e97659e0-2c42-4599-a7bb-00282adc410d", + { + "task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"), + "direction": Direction.UP, + }, ), ( SERVICE_SCORE_HABIT, @@ -411,14 +409,20 @@ async def test_handle_quests_exceptions( ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_DIRECTION: "down", }, - "e97659e0-2c42-4599-a7bb-00282adc410d", + { + "task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"), + "direction": Direction.DOWN, + }, ), ( SERVICE_SCORE_REWARD, { ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", }, - "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + { + "task_id": UUID("5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), + "direction": Direction.UP, + }, ), ( SERVICE_SCORE_HABIT, @@ -426,7 +430,10 @@ async def test_handle_quests_exceptions( ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu", ATTR_DIRECTION: "up", }, - "e97659e0-2c42-4599-a7bb-00282adc410d", + { + "task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"), + "direction": Direction.UP, + }, ), ( SERVICE_SCORE_HABIT, @@ -434,7 +441,10 @@ async def test_handle_quests_exceptions( ATTR_TASK: "create_a_task", ATTR_DIRECTION: "up", }, - "e97659e0-2c42-4599-a7bb-00282adc410d", + { + "task_id": UUID("e97659e0-2c42-4599-a7bb-00282adc410d"), + "direction": Direction.UP, + }, ), ], ids=[ @@ -448,18 +458,13 @@ async def test_handle_quests_exceptions( async def test_score_task( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service: str, service_data: dict[str, Any], - task_id: str, + call_args: dict[str, Any], ) -> None: """Test Habitica score task action.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}", - json={"success": True, "data": {}}, - ) - await hass.services.async_call( DOMAIN, service, @@ -471,17 +476,13 @@ async def test_score_task( blocking=True, ) - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}", - ) + habitica.update_score.assert_awaited_once_with(**call_args) @pytest.mark.parametrize( ( "service_data", - "http_status", + "raise_exception", "expected_exception", "expected_exception_msg", ), @@ -491,7 +492,7 @@ async def test_score_task( ATTR_TASK: "task does not exist", ATTR_DIRECTION: "up", }, - HTTPStatus.OK, + None, ServiceValidationError, "Unable to complete action, could not find the task 'task does not exist'", ), @@ -500,8 +501,8 @@ async def test_score_task( ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_DIRECTION: "up", }, - HTTPStatus.TOO_MANY_REQUESTS, - ServiceValidationError, + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, RATE_LIMIT_EXCEPTION_MSG, ), ( @@ -509,7 +510,7 @@ async def test_score_task( ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", ATTR_DIRECTION: "up", }, - HTTPStatus.BAD_REQUEST, + ERROR_BAD_REQUEST, HomeAssistantError, REQUEST_EXCEPTION_MSG, ), @@ -518,35 +519,24 @@ async def test_score_task( ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", ATTR_DIRECTION: "up", }, - HTTPStatus.UNAUTHORIZED, + ERROR_NOT_AUTHORIZED, HomeAssistantError, - "Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10 GP", + "Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10.00 GP", ), ], ) -@pytest.mark.usefixtures("mock_habitica") async def test_score_task_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - http_status: HTTPStatus, + raise_exception: Exception, expected_exception: Exception, expected_exception_msg: str, ) -> None: """Test Habitica score task action exceptions.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/e97659e0-2c42-4599-a7bb-00282adc410d/score/up", - json={"success": True, "data": {}}, - status=http_status, - ) - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b/score/up", - json={"success": True, "data": {}}, - status=http_status, - ) - + habitica.update_score.side_effect = raise_exception with pytest.raises(expected_exception, match=expected_exception_msg): await hass.services.async_call( DOMAIN, @@ -561,100 +551,119 @@ async def test_score_task_exceptions( @pytest.mark.parametrize( - ("service_data", "item", "target_id"), + ("service_data", "call_args"), [ ( { ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_ITEM: "shiny_seed", }, - "shinySeed", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SHINY_SEED, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_ITEM: "seafoam", }, - "seafoam", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SEAFOAM, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "a380546a-94be-4b8e-8a0b-23e0d5c03303", ATTR_ITEM: "snowball", }, - "snowball", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SNOWBALL, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "test-user", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "test-username", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "a380546a-94be-4b8e-8a0b-23e0d5c03303", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("a380546a-94be-4b8e-8a0b-23e0d5c03303"), + }, ), ( { ATTR_TARGET: "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"), + }, ), ( { ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"), + }, ), ( { ATTR_TARGET: "test-partymember-displayname", ATTR_ITEM: "spooky_sparkles", }, - "spookySparkles", - "ffce870c-3ff3-4fa4-bad1-87612e52b8e7", + { + "skill": Skill.SPOOKY_SPARKLES, + "target_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7"), + }, ), ], - ids=[], + 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, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, service_data: dict[str, Any], - item: str, - target_id: str, + call_args: dict[str, Any], ) -> 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": {}}, - ) + """Test Habitica use transformation item action.""" await hass.services.async_call( DOMAIN, @@ -667,18 +676,14 @@ async def test_transformation( blocking=True, ) - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", - ) + habitica.cast_skill.assert_awaited_once_with(**call_args) @pytest.mark.parametrize( ( "service_data", - "http_status_members", - "http_status_cast", + "raise_exception_members", + "raise_exception_cast", "expected_exception", "expected_exception_msg", ), @@ -688,8 +693,8 @@ async def test_transformation( ATTR_TARGET: "user-not-found", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.OK, - HTTPStatus.OK, + None, + None, ServiceValidationError, "Unable to find target 'user-not-found' in your party", ), @@ -698,18 +703,8 @@ async def test_transformation( 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, + ERROR_NOT_FOUND, + None, ServiceValidationError, "Unable to find target, you are currently not in a party. You can only target yourself", ), @@ -718,8 +713,8 @@ async def test_transformation( ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.BAD_REQUEST, - HTTPStatus.OK, + ERROR_BAD_REQUEST, + None, HomeAssistantError, "Unable to connect to Habitica, try again later", ), @@ -728,9 +723,9 @@ async def test_transformation( ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.OK, - HTTPStatus.TOO_MANY_REQUESTS, - ServiceValidationError, + None, + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, RATE_LIMIT_EXCEPTION_MSG, ), ( @@ -738,8 +733,8 @@ async def test_transformation( ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.OK, - HTTPStatus.UNAUTHORIZED, + None, + ERROR_NOT_AUTHORIZED, ServiceValidationError, "Unable to use spooky_sparkles, you don't own this item", ), @@ -748,36 +743,27 @@ async def test_transformation( ATTR_TARGET: "test-partymember-username", ATTR_ITEM: "spooky_sparkles", }, - HTTPStatus.OK, - HTTPStatus.BAD_REQUEST, + None, + ERROR_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, + habitica: AsyncMock, service_data: dict[str, Any], - http_status_members: HTTPStatus, - http_status_cast: HTTPStatus, + raise_exception_members: Exception, + raise_exception_cast: Exception, 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, - ) + 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, diff --git a/tests/components/habitica/test_switch.py b/tests/components/habitica/test_switch.py index 55ba7b19b22..c259f53f183 100644 --- a/tests/components/habitica/test_switch.py +++ b/tests/components/habitica/test_switch.py @@ -1,13 +1,11 @@ """Tests for the Habitica switch platform.""" from collections.abc import Generator -from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.habitica.const import DEFAULT_URL from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TOGGLE, @@ -17,13 +15,12 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import mock_called_with +from .conftest import ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS from tests.common import MockConfigEntry, snapshot_platform -from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(autouse=True) @@ -36,7 +33,7 @@ def switch_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_switch( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -66,14 +63,10 @@ async def test_turn_on_off_toggle( hass: HomeAssistant, config_entry: MockConfigEntry, service_call: str, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test switch turn on/off, toggle method.""" - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/sleep", - json={"success": True, "data": False}, - ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -87,7 +80,7 @@ async def test_turn_on_off_toggle( blocking=True, ) - assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/user/sleep") + habitica.toggle_sleep.assert_awaited_once() @pytest.mark.parametrize( @@ -99,19 +92,19 @@ async def test_turn_on_off_toggle( ], ) @pytest.mark.parametrize( - ("status_code", "exception"), + ("raise_exception", "expected_exception"), [ - (HTTPStatus.TOO_MANY_REQUESTS, ServiceValidationError), - (HTTPStatus.BAD_REQUEST, HomeAssistantError), + (ERROR_TOO_MANY_REQUESTS, HomeAssistantError), + (ERROR_BAD_REQUEST, HomeAssistantError), ], ) async def test_turn_on_off_toggle_exceptions( hass: HomeAssistant, config_entry: MockConfigEntry, service_call: str, - mock_habitica: AiohttpClientMocker, - status_code: HTTPStatus, - exception: Exception, + habitica: AsyncMock, + raise_exception: Exception, + expected_exception: Exception, ) -> None: """Test switch turn on/off, toggle method.""" @@ -121,18 +114,12 @@ async def test_turn_on_off_toggle_exceptions( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/user/sleep", - status=status_code, - json={"success": True, "data": False}, - ) + habitica.toggle_sleep.side_effect = raise_exception - with pytest.raises(expected_exception=exception): + with pytest.raises(expected_exception=expected_exception): await hass.services.async_call( SWITCH_DOMAIN, service_call, {ATTR_ENTITY_ID: "switch.test_user_rest_in_the_inn"}, blocking=True, ) - - assert mock_called_with(mock_habitica, "post", f"{DEFAULT_URL}/api/v3/user/sleep") diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 66f741eb39a..ea817013169 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -1,15 +1,16 @@ """Tests for Habitica todo platform.""" from collections.abc import Generator -from http import HTTPStatus -import json -import re -from unittest.mock import patch +from datetime import date +from typing import Any +from unittest.mock import AsyncMock, patch +from uuid import UUID +from habiticalib import Direction, HabiticaTasksResponse, Task, TaskType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN +from homeassistant.components.habitica.const import DOMAIN from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_DUE_DATE, @@ -25,15 +26,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import mock_called_with +from .conftest import ERROR_NOT_FOUND from tests.common import ( MockConfigEntry, async_get_persistent_notifications, - load_json_object_fixture, + load_fixture, snapshot_platform, ) -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -47,7 +47,7 @@ def todo_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_todos( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -72,7 +72,7 @@ async def test_todos( "todo.test_user_dailies", ], ) -@pytest.mark.usefixtures("mock_habitica") +@pytest.mark.usefixtures("habitica") async def test_todo_items( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -111,7 +111,7 @@ async def test_todo_items( async def test_complete_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, snapshot: SnapshotAssertion, entity_id: str, uid: str, @@ -124,10 +124,6 @@ async def test_complete_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/up", - json=load_json_object_fixture("score_with_drop.json", DOMAIN), - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -136,9 +132,7 @@ async def test_complete_todo_item( blocking=True, ) - assert mock_called_with( - mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/up" - ) + habitica.update_score.assert_awaited_once_with(UUID(uid), Direction.UP) # Test notification for item drop notifications = async_get_persistent_notifications(hass) @@ -158,7 +152,7 @@ async def test_complete_todo_item( async def test_uncomplete_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, entity_id: str, uid: str, ) -> None: @@ -170,10 +164,6 @@ async def test_uncomplete_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/down", - json={"data": {}, "success": True}, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -182,9 +172,7 @@ async def test_uncomplete_todo_item( blocking=True, ) - assert mock_called_with( - mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/down" - ) + habitica.update_score.assert_called_once_with(UUID(uid), Direction.DOWN) @pytest.mark.parametrize( @@ -198,7 +186,7 @@ async def test_uncomplete_todo_item( async def test_complete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, uid: str, status: str, ) -> None: @@ -210,10 +198,7 @@ async def test_complete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - re.compile(f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/.+"), - status=HTTPStatus.NOT_FOUND, - ) + habitica.update_score.side_effect = ERROR_NOT_FOUND with pytest.raises( expected_exception=ServiceValidationError, match=r"Unable to update the score for your Habitica to-do `.+`, please try again", @@ -228,30 +213,86 @@ async def test_complete_todo_item_exception( @pytest.mark.parametrize( - ("entity_id", "uid", "date"), + ("entity_id", "service_data", "call_args"), [ ( "todo.test_user_to_do_s", - "88de7cd9-af2b-49ce-9afd-bf941d87336b", - "2024-07-30", + { + ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b", + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: "test-description", + ATTR_DUE_DATE: date(2024, 7, 30), + }, + ( + UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"), + Task( + notes="test-description", + text="test-summary", + date=date(2024, 7, 30), + ), + ), + ), + ( + "todo.test_user_to_do_s", + { + ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b", + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: "test-description", + ATTR_DUE_DATE: None, + }, + ( + UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"), + Task( + notes="test-description", + text="test-summary", + date=None, + ), + ), + ), + ( + "todo.test_user_to_do_s", + { + ATTR_ITEM: "88de7cd9-af2b-49ce-9afd-bf941d87336b", + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: None, + ATTR_DUE_DATE: date(2024, 7, 30), + }, + ( + UUID("88de7cd9-af2b-49ce-9afd-bf941d87336b"), + Task( + notes="", + text="test-summary", + date=date(2024, 7, 30), + ), + ), ), ( "todo.test_user_dailies", - "f2c85972-1a19-4426-bc6d-ce3337b9d99f", - None, + { + ATTR_ITEM: "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: "test-description", + }, + ( + UUID("f2c85972-1a19-4426-bc6d-ce3337b9d99f"), + Task( + notes="test-description", + text="test-summary", + ), + ), ), ], - ids=["todo", "daily"], + ids=["todo", "todo remove date", "todo remove notes", "daily"], ) async def test_update_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, entity_id: str, - uid: str, - date: str, + service_data: dict[str, Any], + call_args: tuple[UUID, Task], ) -> None: - """Test update details of a item on the todo list.""" + """Test update details of an item on the todo list.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -259,38 +300,21 @@ async def test_update_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.put( - f"{DEFAULT_URL}/api/v3/tasks/{uid}", - json={"data": {}, "success": True}, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, - { - ATTR_ITEM: uid, - ATTR_RENAME: "test-summary", - ATTR_DESCRIPTION: "test-description", - ATTR_DUE_DATE: date, - }, + service_data, target={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_call = mock_called_with( - mock_habitica, "PUT", f"{DEFAULT_URL}/api/v3/tasks/{uid}" - ) - assert mock_call - assert json.loads(mock_call[2]) == { - "date": date, - "notes": "test-description", - "text": "test-summary", - } + habitica.update_task.assert_awaited_once_with(*call_args) async def test_update_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test exception when update item on the todo list.""" uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b" @@ -300,10 +324,7 @@ async def test_update_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.put( - f"{DEFAULT_URL}/api/v3/tasks/{uid}", - status=HTTPStatus.NOT_FOUND, - ) + habitica.update_task.side_effect = ERROR_NOT_FOUND with pytest.raises( expected_exception=ServiceValidationError, match="Unable to update the Habitica to-do `test-summary`, please try again", @@ -325,7 +346,7 @@ async def test_update_todo_item_exception( async def test_add_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test add a todo item to the todo list.""" @@ -335,11 +356,6 @@ async def test_add_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/user", - json={"data": {}, "success": True}, - status=HTTPStatus.CREATED, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.ADD_ITEM, @@ -352,24 +368,20 @@ async def test_add_todo_item( blocking=True, ) - mock_call = mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/tasks/user", + habitica.create_task.assert_awaited_once_with( + Task( + date=date(2024, 7, 30), + notes="test-description", + text="test-summary", + type=TaskType.TODO, + ) ) - assert mock_call - assert json.loads(mock_call[2]) == { - "date": "2024-07-30", - "notes": "test-description", - "text": "test-summary", - "type": "todo", - } async def test_add_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test exception when adding a todo item to the todo list.""" @@ -379,10 +391,7 @@ async def test_add_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/user", - status=HTTPStatus.NOT_FOUND, - ) + habitica.create_task.side_effect = ERROR_NOT_FOUND with pytest.raises( expected_exception=ServiceValidationError, match="Unable to create new to-do `test-summary` for Habitica, please try again", @@ -403,7 +412,7 @@ async def test_add_todo_item_exception( async def test_delete_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test deleting a todo item from the todo list.""" @@ -414,10 +423,6 @@ async def test_delete_todo_item( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.delete( - f"{DEFAULT_URL}/api/v3/tasks/{uid}", - json={"data": {}, "success": True}, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.REMOVE_ITEM, @@ -426,15 +431,13 @@ async def test_delete_todo_item( blocking=True, ) - assert mock_called_with( - mock_habitica, "delete", f"{DEFAULT_URL}/api/v3/tasks/{uid}" - ) + habitica.delete_task.assert_awaited_once_with(UUID(uid)) async def test_delete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test exception when deleting a todo item from the todo list.""" @@ -445,10 +448,8 @@ async def test_delete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.delete( - f"{DEFAULT_URL}/api/v3/tasks/{uid}", - status=HTTPStatus.NOT_FOUND, - ) + habitica.delete_task.side_effect = ERROR_NOT_FOUND + with pytest.raises( expected_exception=ServiceValidationError, match="Unable to delete item from Habitica to-do list, please try again", @@ -465,7 +466,7 @@ async def test_delete_todo_item_exception( async def test_delete_completed_todo_items( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test deleting completed todo items from the todo list.""" config_entry.add_to_hass(hass) @@ -474,10 +475,6 @@ async def test_delete_completed_todo_items( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos", - json={"data": {}, "success": True}, - ) await hass.services.async_call( TODO_DOMAIN, TodoServices.REMOVE_COMPLETED_ITEMS, @@ -486,15 +483,13 @@ async def test_delete_completed_todo_items( blocking=True, ) - assert mock_called_with( - mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos" - ) + habitica.delete_completed_todos.assert_awaited_once() async def test_delete_completed_todo_items_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test exception when deleting completed todo items from the todo list.""" config_entry.add_to_hass(hass) @@ -503,10 +498,7 @@ async def test_delete_completed_todo_items_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos", - status=HTTPStatus.NOT_FOUND, - ) + habitica.delete_completed_todos.side_effect = ERROR_NOT_FOUND with pytest.raises( expected_exception=ServiceValidationError, match="Unable to delete completed to-do items from Habitica to-do list, please try again", @@ -539,7 +531,7 @@ async def test_delete_completed_todo_items_exception( async def test_move_todo_item( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, hass_ws_client: WebSocketGenerator, entity_id: str, uid: str, @@ -553,12 +545,6 @@ async def test_move_todo_item( assert config_entry.state is ConfigEntryState.LOADED - for pos in (0, 1): - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/{pos}", - json={"data": {}, "success": True}, - ) - client = await hass_ws_client() # move to second position data = { @@ -572,6 +558,9 @@ async def test_move_todo_item( resp = await client.receive_json() assert resp.get("success") + habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1) + habitica.reorder_task.reset_mock() + # move to top position data = { "id": id, @@ -583,18 +572,13 @@ async def test_move_todo_item( resp = await client.receive_json() assert resp.get("success") - for pos in (0, 1): - assert mock_called_with( - mock_habitica, - "post", - f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/{pos}", - ) + habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) async def test_move_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_habitica: AiohttpClientMocker, + habitica: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test exception when moving todo item.""" @@ -606,11 +590,7 @@ async def test_move_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - mock_habitica.post( - f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/0", - status=HTTPStatus.NOT_FOUND, - ) - + habitica.reorder_task.side_effect = ERROR_NOT_FOUND client = await hass_ws_client() data = { @@ -620,8 +600,15 @@ async def test_move_todo_item_exception( "uid": uid, } await client.send_json_auto_id(data) + resp = await client.receive_json() - assert resp.get("success") is False + habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) + + assert resp["success"] is False + assert ( + resp["error"]["message"] + == "Unable to move the Habitica to-do to position 0, please try again" + ) @pytest.mark.parametrize( @@ -651,31 +638,18 @@ async def test_move_todo_item_exception( async def test_next_due_date( hass: HomeAssistant, fixture: str, - calculated_due_date: tuple | None, + calculated_due_date: str | None, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, + habitica: AsyncMock, ) -> None: """Test next_due_date calculation.""" dailies_entity = "todo.test_user_dailies" - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/user", json=load_json_object_fixture("user.json", DOMAIN) - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - params={"type": "completedTodos"}, - json={"data": []}, - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/tasks/user", - json=load_json_object_fixture(fixture, DOMAIN), - ) - aioclient_mock.get( - f"{DEFAULT_URL}/api/v3/content", - params={"language": "en"}, - json=load_json_object_fixture("content.json", DOMAIN), - ) + habitica.get_tasks.side_effect = [ + HabiticaTasksResponse.from_json(load_fixture(fixture, DOMAIN)), + HabiticaTasksResponse.from_dict({"success": True, "data": []}), + ] config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id)