diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index c994b7e3b0b..b5ceadd2762 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -3,9 +3,11 @@ from unittest.mock import patch 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 @@ -21,6 +23,23 @@ def disable_plumbum(): 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] == method.upper() and call[1] == URL(url) + ), + None, + ) + + @pytest.fixture def mock_habitica(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: """Mock aiohttp requests.""" @@ -54,3 +73,9 @@ def mock_config_entry() -> MockConfigEntry: }, unique_id="00000000-0000-0000-0000-000000000000", ) + + +@pytest.fixture +async def set_tz(hass: HomeAssistant) -> None: + """Fixture to set timezone.""" + await hass.config.async_set_time_zone("Europe/Berlin") diff --git a/tests/components/habitica/fixtures/duedate_fixture_1.json b/tests/components/habitica/fixtures/duedate_fixture_1.json new file mode 100644 index 00000000000..d44d5f38498 --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_1.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "daily", + "everyX": 1, + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "streak": 1, + "nextDue": ["2024-09-22T22:00:00.000Z", "2024-09-23T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-07-06T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": true, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/duedate_fixture_2.json b/tests/components/habitica/fixtures/duedate_fixture_2.json new file mode 100644 index 00000000000..99cf4e89454 --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_2.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "daily", + "everyX": 1, + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "streak": 1, + "nextDue": ["2024-09-22T22:00:00.000Z", "2024-09-23T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-23T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/duedate_fixture_3.json b/tests/components/habitica/fixtures/duedate_fixture_3.json new file mode 100644 index 00000000000..78b66ad6643 --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_3.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "monthly", + "everyX": 1, + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "streak": 1, + "nextDue": ["2024-10-22T22:00:00.000Z", "2024-11-22T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-10-22T22:00:00.000Z", + "daysOfMonth": [23], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/duedate_fixture_4.json b/tests/components/habitica/fixtures/duedate_fixture_4.json new file mode 100644 index 00000000000..7e14e3339e2 --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_4.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "yearly", + "everyX": 1, + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "streak": 1, + "nextDue": ["2024-10-22T22:00:00.000Z", "2025-10-22T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-10-22T22:00:00.000Z", + "daysOfMonth": [22], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/duedate_fixture_5.json b/tests/components/habitica/fixtures/duedate_fixture_5.json new file mode 100644 index 00000000000..d8d5f4cd773 --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_5.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "weekly", + "everyX": 1, + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "streak": 1, + "nextDue": ["2024-09-20T22:00:00.000Z", "2024-09-27T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-25T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/duedate_fixture_6.json b/tests/components/habitica/fixtures/duedate_fixture_6.json new file mode 100644 index 00000000000..dce177b1abc --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_6.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "monthly", + "everyX": 1, + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "streak": 1, + "nextDue": ["2024-09-20T22:00:00.000Z", "2024-10-20T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-25T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/duedate_fixture_7.json b/tests/components/habitica/fixtures/duedate_fixture_7.json new file mode 100644 index 00000000000..723ee40062d --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_7.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "monthly", + "everyX": 0, + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "streak": 1, + "nextDue": ["2024-09-22T22:00:00.000Z", "2024-09-23T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-23T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/duedate_fixture_8.json b/tests/components/habitica/fixtures/duedate_fixture_8.json new file mode 100644 index 00000000000..21a40a0a649 --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_8.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "daily", + "everyX": 1, + "repeat": { + "m": true, + "t": true, + "w": true, + "th": true, + "f": true, + "s": true, + "su": true + }, + "streak": 1, + "nextDue": [], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-23T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/score_with_drop.json b/tests/components/habitica/fixtures/score_with_drop.json new file mode 100644 index 00000000000..f25838d6c37 --- /dev/null +++ b/tests/components/habitica/fixtures/score_with_drop.json @@ -0,0 +1,69 @@ +{ + "success": true, + "data": { + "delta": 0.9999999781878414, + "_tmp": { + "quest": { + "progressDelta": 1.049999977097233 + }, + "drop": { + "value": 3, + "key": "Dragon", + "type": "Egg", + "dialog": "You've found a Dragon Egg!" + } + }, + "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.100000000000016, + "mp": 24, + "exp": 196, + "gp": 30.453660284128997, + "lvl": 20, + "class": "warrior", + "points": 2, + "str": 0, + "con": 0, + "int": 0, + "per": 0 + }, + "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": 623, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr new file mode 100644 index 00000000000..863c23c114b --- /dev/null +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_complete_todo_item[daily] + tuple( + 'Habitica', + ''' + ![Dragon](https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Egg_Dragon.png) + You've found a Dragon Egg! + ''', + ) +# --- +# name: test_complete_todo_item[todo] + tuple( + 'Habitica', + ''' + ![Dragon](https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Egg_Dragon.png) + You've found a Dragon Egg! + ''', + ) +# --- +# name: test_todo_items[todo.test_user_dailies] + dict({ + 'todo.test_user_dailies': dict({ + 'items': list([ + dict({ + 'description': 'Klicke um Änderungen zu machen!', + 'due': '2024-09-22', + 'status': 'completed', + 'summary': 'Zahnseide benutzen', + 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', + }), + dict({ + 'description': 'Klicke um Deinen Terminplan festzulegen!', + 'due': '2024-09-21', + 'status': 'needs_action', + 'summary': '5 Minuten ruhig durchatmen', + 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', + }), + dict({ + 'description': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', + 'due': '2024-09-21', + 'status': 'needs_action', + 'summary': 'Fitnessstudio besuchen', + 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', + }), + ]), + }), + }) +# --- +# name: test_todo_items[todo.test_user_to_do_s] + dict({ + 'todo.test_user_to_do_s': dict({ + 'items': list([ + dict({ + 'description': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', + 'due': '2024-09-27', + 'status': 'needs_action', + 'summary': 'Buch zu Ende lesen', + 'uid': '88de7cd9-af2b-49ce-9afd-bf941d87336b', + }), + dict({ + 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.', + 'due': '2024-08-31', + 'status': 'needs_action', + 'summary': 'Rechnungen bezahlen', + 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490', + }), + dict({ + 'description': 'Rasen mähen und die Pflanzen gießen.', + 'status': 'needs_action', + 'summary': 'Garten pflegen', + 'uid': '1aa3137e-ef72-4d1f-91ee-41933602f438', + }), + dict({ + 'description': 'Den Ausflug für das kommende Wochenende organisieren.', + 'due': '2024-09-26', + 'status': 'needs_action', + 'summary': 'Wochenendausflug planen', + 'uid': '86ea2475-d1b5-4020-bdcc-c188c7996afa', + }), + dict({ + 'description': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', + 'status': 'completed', + 'summary': 'Wocheneinkauf erledigen', + 'uid': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', + }), + dict({ + 'description': 'Wohnzimmer und Küche gründlich aufräumen.', + 'status': 'completed', + 'summary': 'Wohnung aufräumen', + 'uid': '3fa06743-aa0f-472b-af1a-f27c755e329c', + }), + ]), + }), + }) +# --- +# name: test_todos[todo.test_user_dailies-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'todo', + 'entity_category': None, + 'entity_id': 'todo.test_user_dailies', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dailies', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_dailys', + 'unit_of_measurement': None, + }) +# --- +# name: test_todos[todo.test_user_dailies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test-user Dailies', + 'supported_features': , + }), + 'context': , + 'entity_id': 'todo.test_user_dailies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_todos[todo.test_user_to_do_s-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'todo', + 'entity_category': None, + 'entity_id': 'todo.test_user_to_do_s', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': "To-Do's", + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_todos', + 'unit_of_measurement': None, + }) +# --- +# name: test_todos[todo.test_user_to_do_s-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': "test-user To-Do's", + 'supported_features': , + }), + 'context': , + 'entity_id': 'todo.test_user_to_do_s', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py new file mode 100644 index 00000000000..88947caba2d --- /dev/null +++ b/tests/components/habitica/test_todo.py @@ -0,0 +1,695 @@ +"""Tests for Habitica todo platform.""" + +from collections.abc import Generator +from datetime import datetime +from http import HTTPStatus +import json +import re +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN +from homeassistant.components.todo import ( + ATTR_DESCRIPTION, + ATTR_DUE_DATE, + ATTR_ITEM, + ATTR_RENAME, + ATTR_STATUS, + DOMAIN as TODO_DOMAIN, + TodoServices, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +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 tests.common import ( + MockConfigEntry, + async_get_persistent_notifications, + load_json_object_fixture, + snapshot_platform, +) +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def switch_only() -> Generator[None]: + """Enable only the todo platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.TODO], + ): + yield + + +@pytest.mark.usefixtures("mock_habitica") +async def test_todos( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test todo platform.""" + + 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 + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id"), + [ + "todo.test_user_to_do_s", + "todo.test_user_dailies", + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_todo_items( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test items on todo lists.""" + + 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 + + result = await hass.services.async_call( + TODO_DOMAIN, + TodoServices.GET_ITEMS, + {}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + return_response=True, + ) + + assert result == snapshot + + +@pytest.mark.freeze_time("2024-09-21 00:00:00") +@pytest.mark.parametrize( + ("entity_id", "uid"), + [ + ("todo.test_user_to_do_s", "88de7cd9-af2b-49ce-9afd-bf941d87336b"), + ("todo.test_user_dailies", "f2c85972-1a19-4426-bc6d-ce3337b9d99f"), + ], + ids=["todo", "daily"], +) +async def test_complete_todo_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + snapshot: SnapshotAssertion, + entity_id: str, + uid: str, +) -> None: + """Test completing an item on the todo list.""" + + 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 + + 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, + {ATTR_ITEM: uid, ATTR_STATUS: "completed"}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/up" + ) + + # Test notification for item drop + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + _id, *_ = notifications + assert snapshot == (notifications[_id]["title"], notifications[_id]["message"]) + + +@pytest.mark.parametrize( + ("entity_id", "uid"), + [ + ("todo.test_user_to_do_s", "162f0bbe-a097-4a06-b4f4-8fbeed85d2ba"), + ("todo.test_user_dailies", "564b9ac9-c53d-4638-9e7f-1cd96fe19baa"), + ], + ids=["todo", "daily"], +) +async def test_uncomplete_todo_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + entity_id: str, + uid: str, +) -> None: + """Test uncompleting an item on the todo list.""" + + 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 + + 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, + {ATTR_ITEM: uid, ATTR_STATUS: "needs_action"}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/down" + ) + + +@pytest.mark.parametrize( + ("uid", "status"), + [ + ("88de7cd9-af2b-49ce-9afd-bf941d87336b", "completed"), + ("162f0bbe-a097-4a06-b4f4-8fbeed85d2ba", "needs_action"), + ], + ids=["completed", "needs_action"], +) +async def test_complete_todo_item_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + uid: str, + status: str, +) -> None: + """Test exception when completing/uncompleting an item on the todo list.""" + + 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 + + mock_habitica.post( + re.compile(f"{DEFAULT_URL}/api/v3/tasks/{uid}/score/.+"), + status=HTTPStatus.NOT_FOUND, + ) + with pytest.raises( + expected_exception=ServiceValidationError, + match=r"Unable to update the score for your Habitica to-do `.+`, please try again", + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + {ATTR_ITEM: uid, ATTR_STATUS: status}, + target={ATTR_ENTITY_ID: "todo.test_user_to_do_s"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("entity_id", "uid", "date"), + [ + ( + "todo.test_user_to_do_s", + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2024-07-30", + ), + ( + "todo.test_user_dailies", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + None, + ), + ], + ids=["todo", "daily"], +) +async def test_update_todo_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + entity_id: str, + uid: str, + date: str, +) -> None: + """Test update details of a item on the todo list.""" + + 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 + + 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, + }, + 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", + } + + +async def test_update_todo_item_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, +) -> None: + """Test exception when update item on the todo list.""" + uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b" + 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 + + mock_habitica.put( + f"{DEFAULT_URL}/api/v3/tasks/{uid}", + status=HTTPStatus.NOT_FOUND, + ) + with pytest.raises( + expected_exception=ServiceValidationError, + match="Unable to update the Habitica to-do `test-summary`, please try again", + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + { + ATTR_ITEM: uid, + ATTR_RENAME: "test-summary", + ATTR_DESCRIPTION: "test-description", + ATTR_DUE_DATE: "2024-07-30", + }, + target={ATTR_ENTITY_ID: "todo.test_user_to_do_s"}, + blocking=True, + ) + + +async def test_add_todo_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, +) -> None: + """Test add a todo item to the todo list.""" + + 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 + + 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, + { + ATTR_ITEM: "test-summary", + ATTR_DESCRIPTION: "test-description", + ATTR_DUE_DATE: "2024-07-30", + }, + target={ATTR_ENTITY_ID: "todo.test_user_to_do_s"}, + blocking=True, + ) + + mock_call = mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/tasks/user", + ) + 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, +) -> None: + """Test exception when adding a todo item to the todo list.""" + + 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 + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/user", + status=HTTPStatus.NOT_FOUND, + ) + with pytest.raises( + expected_exception=ServiceValidationError, + match="Unable to create new to-do `test-summary` for Habitica, please try again", + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + { + ATTR_ITEM: "test-summary", + ATTR_DESCRIPTION: "test-description", + ATTR_DUE_DATE: "2024-07-30", + }, + target={ATTR_ENTITY_ID: "todo.test_user_to_do_s"}, + blocking=True, + ) + + +async def test_delete_todo_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, +) -> None: + """Test deleting a todo item from the todo list.""" + + uid = "2f6fcabc-f670-4ec3-ba65-817e8deea490" + 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 + + mock_habitica.delete( + f"{DEFAULT_URL}/api/v3/tasks/{uid}", + json={"data": {}, "success": True}, + ) + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + {ATTR_ITEM: uid}, + target={ATTR_ENTITY_ID: "todo.test_user_to_do_s"}, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, "delete", f"{DEFAULT_URL}/api/v3/tasks/{uid}" + ) + + +async def test_delete_todo_item_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, +) -> None: + """Test exception when deleting a todo item from the todo list.""" + + uid = "2f6fcabc-f670-4ec3-ba65-817e8deea490" + 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 + + mock_habitica.delete( + f"{DEFAULT_URL}/api/v3/tasks/{uid}", + status=HTTPStatus.NOT_FOUND, + ) + with pytest.raises( + expected_exception=ServiceValidationError, + match="Unable to delete item from Habitica to-do list, please try again", + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + {ATTR_ITEM: uid}, + target={ATTR_ENTITY_ID: "todo.test_user_to_do_s"}, + blocking=True, + ) + + +async def test_delete_completed_todo_items( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, +) -> None: + """Test deleting completed todo items from the todo list.""" + 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 + + 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, + {}, + target={ATTR_ENTITY_ID: "todo.test_user_to_do_s"}, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, "post", f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos" + ) + + +async def test_delete_completed_todo_items_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, +) -> None: + """Test exception when deleting completed todo items from the todo list.""" + 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 + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/clearCompletedTodos", + status=HTTPStatus.NOT_FOUND, + ) + with pytest.raises( + expected_exception=ServiceValidationError, + match="Unable to delete completed to-do items from Habitica to-do list, please try again", + ): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_COMPLETED_ITEMS, + {}, + target={ATTR_ENTITY_ID: "todo.test_user_to_do_s"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("entity_id", "uid", "previous_uid"), + [ + ( + "todo.test_user_to_do_s", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + ), + ( + "todo.test_user_dailies", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + ), + ], + ids=["todo", "daily"], +) +async def test_move_todo_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, + entity_id: str, + uid: str, + previous_uid: str, +) -> None: + """Test move todo items.""" + + 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 + + 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 = { + "id": id, + "type": "todo/item/move", + "entity_id": entity_id, + "uid": uid, + "previous_uid": previous_uid, + } + await client.send_json_auto_id(data) + resp = await client.receive_json() + assert resp.get("success") + + # move to top position + data = { + "id": id, + "type": "todo/item/move", + "entity_id": entity_id, + "uid": uid, + } + await client.send_json_auto_id(data) + 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}", + ) + + +async def test_move_todo_item_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test exception when moving todo item.""" + + uid = "1aa3137e-ef72-4d1f-91ee-41933602f438" + 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 + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/{uid}/move/to/0", + status=HTTPStatus.NOT_FOUND, + ) + + client = await hass_ws_client() + + data = { + "id": id, + "type": "todo/item/move", + "entity_id": "todo.test_user_to_do_s", + "uid": uid, + } + await client.send_json_auto_id(data) + resp = await client.receive_json() + assert resp.get("success") is False + + +@pytest.mark.parametrize( + ("fixture", "calculated_due_date"), + [ + ("duedate_fixture_1.json", (2024, 9, 23)), + ("duedate_fixture_2.json", (2024, 9, 24)), + ("duedate_fixture_3.json", (2024, 10, 23)), + ("duedate_fixture_4.json", (2024, 10, 23)), + ("duedate_fixture_5.json", (2024, 9, 28)), + ("duedate_fixture_6.json", (2024, 10, 21)), + ("duedate_fixture_7.json", None), + ("duedate_fixture_8.json", None), + ], + ids=[ + "default", + "daily starts on startdate", + "monthly starts on startdate", + "yearly starts on startdate", + "weekly", + "monthly starts on fixed day", + "grey daily", + "empty nextDue", + ], +) +@pytest.mark.usefixtures("set_tz") +async def test_next_due_date( + hass: HomeAssistant, + fixture: str, + calculated_due_date: tuple | None, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> 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), + ) + + 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 + + result = await hass.services.async_call( + TODO_DOMAIN, + TodoServices.GET_ITEMS, + {}, + target={ATTR_ENTITY_ID: dailies_entity}, + blocking=True, + return_response=True, + ) + + assert ( + result[dailies_entity]["items"][0].get("due") is None + if not calculated_due_date + else datetime(*calculated_due_date).date() + )