From 1c0440821ce2c80f8da956be1dcfe89f34cf4da8 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 25 Aug 2026 18:07:32 +0100 Subject: [PATCH] Add update and delete mealplan actions to Mealie (#177539) --- homeassistant/components/mealie/const.py | 1 + homeassistant/components/mealie/icons.json | 6 + homeassistant/components/mealie/services.py | 110 ++++++++ homeassistant/components/mealie/services.yaml | 48 ++++ homeassistant/components/mealie/strings.json | 51 ++++ tests/components/mealie/conftest.py | 2 + .../mealie/snapshots/test_services.ambr | 120 +++++++++ tests/components/mealie/test_services.py | 248 +++++++++++++++++- 8 files changed, 577 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index 4f8c4773b9e1..d75dc20d745f 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -10,6 +10,7 @@ LOGGER = logging.getLogger(__package__) ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" +ATTR_MEALPLAN_ID = "mealplan_id" ATTR_RECIPE_ID = "recipe_id" ATTR_URL = "url" ATTR_INCLUDE_TAGS = "include_tags" diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index c7bc5e0772e8..47fd1805ffff 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -24,6 +24,9 @@ } }, "services": { + "delete_mealplan": { + "service": "mdi:food-off" + }, "get_mealplan": { "service": "mdi:food" }, @@ -44,6 +47,9 @@ }, "set_random_mealplan": { "service": "mdi:dice-multiple" + }, + "update_mealplan": { + "service": "mdi:food" } } } diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 5a37770f97ff..9e3579935fd3 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -28,6 +28,7 @@ from .const import ( ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, + ATTR_MEALPLAN_ID, ATTR_NOTE_TEXT, ATTR_NOTE_TITLE, ATTR_RECIPE_ID, @@ -109,6 +110,39 @@ SERVICE_SET_MEALPLAN_SCHEMA = vol.Any( } ), ) +SERVICE_DELETE_MEALPLAN = "delete_mealplan" +SERVICE_DELETE_MEALPLAN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEALPLAN_ID): str, + } +) +SERVICE_UPDATE_MEALPLAN = "update_mealplan" +SERVICE_UPDATE_MEALPLAN_SCHEMA = vol.Any( + vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEALPLAN_ID): str, + vol.Required(ATTR_DATE): cv.date, + vol.Required(ATTR_ENTRY_TYPE): vol.In( + [x.lower() for x in MealplanEntryType] + ), + vol.Required(ATTR_RECIPE_ID): str, + } + ), + vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEALPLAN_ID): str, + vol.Required(ATTR_DATE): cv.date, + vol.Required(ATTR_ENTRY_TYPE): vol.In( + [x.lower() for x in MealplanEntryType] + ), + vol.Required(ATTR_NOTE_TITLE): str, + vol.Optional(ATTR_NOTE_TEXT): str, + } + ), +) def _validate_mealplan_type(version: AwesomeVersion, entry_type: str) -> None: @@ -278,6 +312,69 @@ async def _async_set_mealplan(call: ServiceCall) -> ServiceResponse: return None +async def _async_delete_mealplan(call: ServiceCall) -> ServiceResponse: + """Delete a mealplan.""" + entry: MealieConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + mealplan_id = call.data[ATTR_MEALPLAN_ID] + client = entry.runtime_data.client + + try: + await client.delete_mealplan( + mealplan_id, + ) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + except MealieNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mealplan_not_found", + translation_placeholders={"mealplan_id": mealplan_id}, + ) from err + return None + + +async def _async_update_mealplan(call: ServiceCall) -> ServiceResponse: + """Update a mealplan.""" + entry: MealieConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + mealplan_id = call.data[ATTR_MEALPLAN_ID] + mealplan_date = call.data[ATTR_DATE] + entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) + client = entry.runtime_data.client + + _validate_mealplan_type(entry.runtime_data.version, entry_type.value) + + try: + mealplan = await client.update_mealplan( + mealplan_id, + mealplan_date, + entry_type, + recipe_id=call.data.get(ATTR_RECIPE_ID), + note_title=call.data.get(ATTR_NOTE_TITLE), + note_text=call.data.get(ATTR_NOTE_TEXT), + ) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + except MealieNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mealplan_not_found", + translation_placeholders={"mealplan_id": mealplan_id}, + ) from err + if call.return_response: + return {"mealplan": asdict(mealplan)} + return None + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Mealie integration.""" @@ -324,6 +421,19 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_SET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_MEALPLAN, + _async_delete_mealplan, + schema=SERVICE_DELETE_MEALPLAN_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_MEALPLAN, + _async_update_mealplan, + schema=SERVICE_UPDATE_MEALPLAN_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) service.async_register_platform_entity_service( hass, DOMAIN, diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 6eef192dfabf..c0d798e4f892 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -1,3 +1,15 @@ +delete_mealplan: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mealie + mealplan_id: + required: true + selector: + text: + get_mealplan: fields: config_entry_id: @@ -120,3 +132,39 @@ set_mealplan: note_text: selector: text: + +update_mealplan: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mealie + mealplan_id: + required: true + selector: + text: + date: + selector: + date: + entry_type: + selector: + select: + options: + - breakfast + - lunch + - dinner + - side + - dessert + - snack + - drink + translation_key: mealplan_entry_type + recipe_id: + selector: + text: + note_title: + selector: + text: + note_text: + selector: + text: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 1d6efc91a107..fc491c63b527 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -138,6 +138,9 @@ "item_not_found_error": { "message": "Item {shopping_list_item} not found." }, + "mealplan_not_found": { + "message": "Mealplan with ID `{mealplan_id}` not found." + }, "no_recipes_found": { "message": "No recipes found matching your search." }, @@ -180,6 +183,20 @@ } }, "services": { + "delete_mealplan": { + "description": "Deletes a mealplan", + "fields": { + "config_entry_id": { + "description": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::description%]", + "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]" + }, + "mealplan_id": { + "description": "The mealplan ID to delete.", + "name": "Mealplan ID" + } + }, + "name": "Delete a mealplan" + }, "get_mealplan": { "description": "Gets a mealplan from Mealie", "fields": { @@ -299,6 +316,40 @@ } }, "name": "Set random mealplan" + }, + "update_mealplan": { + "description": "Updates a mealplan", + "fields": { + "config_entry_id": { + "description": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::description%]", + "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]" + }, + "date": { + "description": "[%key:component::mealie::services::set_random_mealplan::fields::date::description%]", + "name": "[%key:component::mealie::services::set_random_mealplan::fields::date::name%]" + }, + "entry_type": { + "description": "The type of dish to update the recipe to.", + "name": "[%key:component::mealie::services::set_random_mealplan::fields::entry_type::name%]" + }, + "mealplan_id": { + "description": "The mealplan ID to update.", + "name": "Mealplan ID" + }, + "note_text": { + "description": "Meal note text for when planning without recipe.", + "name": "Note text" + }, + "note_title": { + "description": "Meal note title for when planning without recipe.", + "name": "Meal note title" + }, + "recipe_id": { + "description": "The recipe ID or the slug of the recipe to set.", + "name": "Recipe ID" + } + }, + "name": "Update a mealplan" } } } diff --git a/tests/components/mealie/conftest.py b/tests/components/mealie/conftest.py index 422b1c3de446..223baa6e3eb3 100644 --- a/tests/components/mealie/conftest.py +++ b/tests/components/mealie/conftest.py @@ -79,6 +79,8 @@ def mock_mealie_client() -> Generator[AsyncMock]: mealplan = Mealplan.from_json(load_fixture("mealplan.json", DOMAIN)) client.random_mealplan.return_value = mealplan client.set_mealplan.return_value = mealplan + client.update_mealplan.return_value = mealplan + client.delete_mealplan.return_value = mealplan yield client diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 0515011c176f..ce9b3e353979 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -5022,3 +5022,123 @@ }), }) # --- +# name: test_service_update_mealplan[payload0-kwargs0] + dict({ + 'mealplan': dict({ + 'description': None, + 'entry_type': , + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': datetime.date(2024, 1, 22), + 'mealplan_id': 230, + 'recipe': dict({ + 'categories': list([ + ]), + 'date_added': datetime.date(2024, 1, 22), + 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'AiIo', + 'last_made': None, + 'name': 'Zoete aardappel curry traybake', + 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, + 'rating': None, + 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, + 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, + 'slug': 'zoete-aardappel-curry-traybake', + 'tags': list([ + ]), + 'tools': list([ + ]), + 'total_time': '40 Minutes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + }) +# --- +# name: test_service_update_mealplan[payload1-kwargs1] + dict({ + 'mealplan': dict({ + 'description': None, + 'entry_type': , + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': datetime.date(2024, 1, 22), + 'mealplan_id': 230, + 'recipe': dict({ + 'categories': list([ + ]), + 'date_added': datetime.date(2024, 1, 22), + 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'AiIo', + 'last_made': None, + 'name': 'Zoete aardappel curry traybake', + 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, + 'rating': None, + 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, + 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, + 'slug': 'zoete-aardappel-curry-traybake', + 'tags': list([ + ]), + 'tools': list([ + ]), + 'total_time': '40 Minutes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + }) +# --- +# name: test_service_update_mealplan[payload2-kwargs2] + dict({ + 'mealplan': dict({ + 'description': None, + 'entry_type': , + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'mealplan_date': datetime.date(2024, 1, 22), + 'mealplan_id': 230, + 'recipe': dict({ + 'categories': list([ + ]), + 'date_added': datetime.date(2024, 1, 22), + 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, + 'image': 'AiIo', + 'last_made': None, + 'name': 'Zoete aardappel curry traybake', + 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', + 'perform_time': None, + 'prep_time': None, + 'rating': None, + 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, + 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, + 'slug': 'zoete-aardappel-curry-traybake', + 'tags': list([ + ]), + 'tools': list([ + ]), + 'total_time': '40 Minutes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + 'title': None, + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + }) +# --- diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index eff5f252f2ca..e9478905584f 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -18,6 +18,7 @@ from homeassistant.components.mealie.const import ( ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, + ATTR_MEALPLAN_ID, ATTR_NOTE_TEXT, ATTR_NOTE_TITLE, ATTR_RECIPE_ID, @@ -28,6 +29,7 @@ from homeassistant.components.mealie.const import ( DOMAIN, ) from homeassistant.components.mealie.services import ( + SERVICE_DELETE_MEALPLAN, SERVICE_GET_MEALPLAN, SERVICE_GET_RECIPE, SERVICE_GET_RECIPES, @@ -35,6 +37,7 @@ from homeassistant.components.mealie.services import ( SERVICE_IMPORT_RECIPE, SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, + SERVICE_UPDATE_MEALPLAN, ) from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import HomeAssistant @@ -396,6 +399,177 @@ async def test_service_set_mealplan_invalid_entry_type( mock_mealie_client.set_mealplan.assert_not_called() +async def test_service_delete_mealplan( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the delete_mealplan service.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEALPLAN_ID: "mealplan_id", + }, + blocking=True, + ) + mock_mealie_client.delete_mealplan.assert_called_with("mealplan_id") + + +async def test_service_delete_mealplan_not_found( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the delete_mealplan service with invalid mealplan ID.""" + await setup_integration(hass, mock_config_entry) + + mock_mealie_client.delete_mealplan.side_effect = MealieNotFoundError + + with pytest.raises(ServiceValidationError, match="Mealplan with ID"): + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEALPLAN_ID: "invalid_mealplan_id", + }, + blocking=True, + ) + mock_mealie_client.delete_mealplan.assert_called_once() + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_RECIPE_ID: "recipe_id", + }, + {"recipe_id": "recipe_id", "note_title": None, "note_text": None}, + ), + ( + { + ATTR_NOTE_TITLE: "Note Title", + ATTR_NOTE_TEXT: "Note Text", + }, + {"recipe_id": None, "note_title": "Note Title", "note_text": "Note Text"}, + ), + ( + { + ATTR_NOTE_TITLE: "Note Title", + }, + {"recipe_id": None, "note_title": "Note Title", "note_text": None}, + ), + ], +) +async def test_service_update_mealplan( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + payload: dict[str, str], + kwargs: dict[str, str], +) -> None: + """Test the update_mealplan service.""" + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "lunch", + } + | payload, + blocking=True, + return_response=True, + ) + assert response == snapshot + mock_mealie_client.update_mealplan.assert_called_with( + "mealplan_id", date(2023, 10, 21), MealplanEntryType.LUNCH, **kwargs + ) + + mock_mealie_client.update_mealplan.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "lunch", + } + | payload, + blocking=True, + return_response=False, + ) + mock_mealie_client.update_mealplan.assert_called_with( + "mealplan_id", date(2023, 10, 21), MealplanEntryType.LUNCH, **kwargs + ) + + +async def test_service_update_mealplan_invalid_entry_type( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the update_mealplan service with invalid entry types for version.""" + mock_mealie_client.get_about.return_value = About(version="v3.6.0") + + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "dessert", + ATTR_NOTE_TITLE: "Note Title", + }, + blocking=True, + return_response=True, + ) + mock_mealie_client.update_mealplan.assert_not_called() + + +async def test_service_update_mealplan_not_found( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the update_mealplan service with invalid mealplan ID.""" + await setup_integration(hass, mock_config_entry) + + mock_mealie_client.update_mealplan.side_effect = MealieNotFoundError + + with pytest.raises(ServiceValidationError, match="Mealplan with ID"): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEALPLAN_ID: "invalid_mealplan_id", + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "lunch", + ATTR_RECIPE_ID: "recipe_id", + }, + blocking=True, + return_response=True, + ) + mock_mealie_client.update_mealplan.assert_called_once() + + async def test_service_get_shopping_list_items( hass: HomeAssistant, mock_mealie_client: AsyncMock, @@ -438,7 +612,15 @@ async def test_service_get_shopping_list_items_connection_error( @pytest.mark.parametrize( - ("service", "payload", "function", "exception", "raised_exception", "message"), + ( + "service", + "payload", + "function", + "exception", + "raised_exception", + "message", + "return_response", + ), [ ( SERVICE_GET_MEALPLAN, @@ -447,6 +629,7 @@ async def test_service_get_shopping_list_items_connection_error( MealieConnectionError, HomeAssistantError, "Error connecting to Mealie instance", + True, ), ( SERVICE_GET_RECIPE, @@ -455,6 +638,7 @@ async def test_service_get_shopping_list_items_connection_error( MealieConnectionError, HomeAssistantError, "Error connecting to Mealie instance", + True, ), ( SERVICE_GET_RECIPE, @@ -463,6 +647,7 @@ async def test_service_get_shopping_list_items_connection_error( MealieNotFoundError, ServiceValidationError, "Recipe with ID or slug `recipe_id` not found", + True, ), ( SERVICE_GET_RECIPES, @@ -471,6 +656,7 @@ async def test_service_get_shopping_list_items_connection_error( MealieConnectionError, HomeAssistantError, "Error connecting to Mealie instance", + True, ), ( SERVICE_GET_RECIPES, @@ -479,6 +665,7 @@ async def test_service_get_shopping_list_items_connection_error( MealieNotFoundError, ServiceValidationError, "No recipes found matching your search", + True, ), ( SERVICE_IMPORT_RECIPE, @@ -487,6 +674,7 @@ async def test_service_get_shopping_list_items_connection_error( MealieConnectionError, HomeAssistantError, "Error connecting to Mealie instance", + True, ), ( SERVICE_IMPORT_RECIPE, @@ -495,6 +683,7 @@ async def test_service_get_shopping_list_items_connection_error( MealieValidationError, ServiceValidationError, "Mealie could not import the recipe from the URL", + True, ), ( SERVICE_SET_RANDOM_MEALPLAN, @@ -503,6 +692,7 @@ async def test_service_get_shopping_list_items_connection_error( MealieConnectionError, HomeAssistantError, "Error connecting to Mealie instance", + True, ), ( SERVICE_SET_MEALPLAN, @@ -515,6 +705,30 @@ async def test_service_get_shopping_list_items_connection_error( MealieConnectionError, HomeAssistantError, "Error connecting to Mealie instance", + True, + ), + ( + SERVICE_DELETE_MEALPLAN, + {ATTR_MEALPLAN_ID: "mealplan_id"}, + "delete_mealplan", + MealieConnectionError, + HomeAssistantError, + "Error connecting to Mealie instance", + False, + ), + ( + SERVICE_UPDATE_MEALPLAN, + { + ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "lunch", + ATTR_RECIPE_ID: "recipe_id", + }, + "update_mealplan", + MealieConnectionError, + HomeAssistantError, + "Error connecting to Mealie instance", + True, ), ], ) @@ -528,6 +742,7 @@ async def test_services_connection_error( exception: Exception, raised_exception: type[Exception], message: str, + return_response: bool, ) -> None: """Test a connection error in the services.""" @@ -541,24 +756,26 @@ async def test_services_connection_error( service, {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, blocking=True, - return_response=True, + return_response=return_response, ) @pytest.mark.parametrize( - ("service", "payload"), + ("service", "payload", "return_response"), [ - (SERVICE_GET_MEALPLAN, {}), - (SERVICE_GET_RECIPE, {ATTR_RECIPE_ID: "recipe_id"}), - (SERVICE_GET_RECIPES, {}), + (SERVICE_GET_MEALPLAN, {}, True), + (SERVICE_GET_RECIPE, {ATTR_RECIPE_ID: "recipe_id"}, True), + (SERVICE_GET_RECIPES, {}, True), ( SERVICE_GET_RECIPES, {ATTR_SEARCH_TERMS: "pasta", ATTR_RESULT_LIMIT: 5}, + True, ), - (SERVICE_IMPORT_RECIPE, {ATTR_URL: "http://example.com"}), + (SERVICE_IMPORT_RECIPE, {ATTR_URL: "http://example.com"}, True), ( SERVICE_SET_RANDOM_MEALPLAN, {ATTR_DATE: "2023-10-21", ATTR_ENTRY_TYPE: "lunch"}, + True, ), ( SERVICE_SET_MEALPLAN, @@ -567,6 +784,18 @@ async def test_services_connection_error( ATTR_ENTRY_TYPE: "lunch", ATTR_RECIPE_ID: "recipe_id", }, + True, + ), + (SERVICE_DELETE_MEALPLAN, {ATTR_MEALPLAN_ID: "mealplan_id"}, False), + ( + SERVICE_UPDATE_MEALPLAN, + { + ATTR_MEALPLAN_ID: "mealplan_id", + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "lunch", + ATTR_RECIPE_ID: "recipe_id", + }, + True, ), ], ) @@ -576,6 +805,7 @@ async def test_service_entry_availability( mock_config_entry: MockConfigEntry, service: str, payload: dict[str, str], + return_response: bool, ) -> None: """Test the services without valid entry.""" mock_config_entry.add_to_hass(hass) @@ -590,7 +820,7 @@ async def test_service_entry_availability( service, {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id} | payload, blocking=True, - return_response=True, + return_response=return_response, ) assert err.value.translation_key == "service_config_entry_not_loaded" @@ -600,6 +830,6 @@ async def test_service_entry_availability( service, {ATTR_CONFIG_ENTRY_ID: "bad-config_id"} | payload, blocking=True, - return_response=True, + return_response=return_response, ) assert err.value.translation_key == "service_config_entry_not_found"