diff --git a/.claude/rules/tools.md b/.claude/rules/tools.md index df8c5bd..db92c08 100644 --- a/.claude/rules/tools.md +++ b/.claude/rules/tools.md @@ -47,7 +47,9 @@ Optional caller arguments translate to the generated client's `UNSET` sentinel v ## List tools -A paginated list tool follows a fixed shape: default `page=1, per_page=50`, call `require_per_page(per_page)` first (the shared ceiling is 100), forward optional `order_by` through `to_unset` and `order_direction` through `parse_order_direction`, and return the raw pagination envelope via `expect_dict`. See `list_shopping_lists` in `households_shopping_lists.py`. +A paginated list tool follows a fixed shape: default `page=1, per_page=50`, call `require_pagination(page, per_page)` first, forward optional `order_by` through `to_unset` and `order_direction` through `parse_order_direction`, and return the raw pagination envelope via `expect_dict`. See `list_shopping_lists` in `households_shopping_lists.py`. + +The pagination bound is two-sided, and its job is bounding tool output size, not server politeness. `per_page` is held to `1..100`: the ceiling caps how much a single call returns, and the floor rejects the two low values Mealie mishandles, `-1` (an unbounded "all rows" fetch that defeats the ceiling) and `0` (an empty page). `page` is held to `>= 1`, since Mealie silently coerces `0` to page 1 and reads a negative page as the last page, so an out-of-range value returns a surprising result rather than an error. ## Scoping a list @@ -55,6 +57,8 @@ Do not expose Mealie's generic `queryFilter` expression as a list-scoping input. The rule is scoped to list-scoping inputs. When the filter DSL is the resource's own persisted field rather than a parameter that narrows a result set, exposing it verbatim is correct: `create_cookbook` and `update_cookbook` in `households_cookbooks.py` take `query_filter_string` directly, because a cookbook stores that string as its own definition. +When a tool interpolates a value into a `queryFilter` it builds internally, that value must first be validated to a shape that cannot alter the expression: UUID-parse ids, never interpolate free text. A value carrying a quote or a DSL operator would otherwise change the parsed filter. `list_recipe_timeline_events` UUID-parses `recipe_id` before interpolating it. + ## Building a body from caller input When a tool builds a generated-client body from caller-supplied data with `Model.from_dict(...)`, wrap the call in `try/except (AttributeError, KeyError, TypeError, ValueError)` and re-raise as `ToolError`, so malformed input surfaces as a clean tool error rather than a stack trace. See `update_recipe` in `recipe_crud.py`. @@ -71,7 +75,7 @@ Some write endpoints return no useful body, for example setting a rating or addi Tool modules are grouped by Mealie OpenAPI tag, one module per group, mirroring `mealie_mcp.client.api`. Tool names follow `mealie__`. A new tool group is a single new file with a `register(mcp, get_client)` callable; `register_all` auto-discovers it. -A non-underscore module that defines no `register` callable is rejected: `_iter_tool_modules` raises rather than skipping it, so a group whose tools would never be exposed fails boot instead of vanishing behind a green merge gate. The per-group `call_tool` round-trip (see the live-test rubric) doubles as the registration check for a wired group: it fails if the group's tools are not registered. Together they mean a missing or misnamed `register` cannot ship silently, so a group needs no separate name-presence assertion against `mcp.list_tools()`. +A non-underscore module that defines no `register` callable is rejected: `_require_register` raises rather than skipping it, so a group whose tools would never be exposed fails boot instead of vanishing behind a green merge gate. The per-group `call_tool` round-trip (see the live-test rubric) doubles as the registration check for a wired group: it fails if the group's tools are not registered. Together they mean a missing or misnamed `register` cannot ship silently, so a group needs no separate name-presence assertion against `mcp.list_tools()`. ## Update bodies diff --git a/CLAUDE.md b/CLAUDE.md index 1f37ade..ab7f265 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ Review is CI's job. Do not spawn a review agent locally before pushing. Running - Each MCP tool is a typed module-level function plus a thin `@mcp.tool()` wrapper inside a `register(mcp, get_client)` function. The typed function is the testable unit; the wrapper calls `get_client()` and forwards. - Tool modules are grouped by Mealie OpenAPI tag, one module per group, mirroring `mealie_mcp.client.api`. Tool names follow `mealie__`. Test files mirror the same grouping under `tests/unit/` and `tests/live/`. `register_all` auto-discovers a new module. -- Shared helpers in `src/mealie_mcp/tools/_common.py` cover validation (`require_non_empty`, `require_per_page`), optional-argument translation (`to_unset`, `parse_order_direction`), response decoding (`expect_dict`, `expect_list`, `expect_str`, plus the lower-level `decode` and `raise_api_error`), and the delete contract (`ack_delete`). Do not re-implement these inline. +- Shared helpers in `src/mealie_mcp/tools/_common.py` cover validation (`require_non_empty`, `require_pagination`, `parse_recipe_uuid`), optional-argument translation (`to_unset`, `parse_order_direction`), response decoding (`expect_dict`, `expect_list`, `expect_str`, plus the lower-level `decode` and `raise_api_error`), and the delete contract (`ack_delete`). Do not re-implement these inline. The detailed implementation rubric lives in `.claude/rules/tools.md` and loads on demand when files under `src/mealie_mcp/tools/` are read. diff --git a/src/mealie_mcp/tools/_common.py b/src/mealie_mcp/tools/_common.py index 0c438ee..fbbf6d8 100644 --- a/src/mealie_mcp/tools/_common.py +++ b/src/mealie_mcp/tools/_common.py @@ -5,6 +5,7 @@ import json from http import HTTPStatus from typing import Any +from uuid import UUID from fastmcp.exceptions import ToolError @@ -44,10 +45,20 @@ def require_non_empty(name: str, value: str) -> None: MAX_PER_PAGE = 100 -def require_per_page(per_page: int) -> None: - """Raise a `ToolError` if `per_page` exceeds the shared list-tool ceiling.""" - if per_page > MAX_PER_PAGE: - raise ToolError(f"per_page must be <= {MAX_PER_PAGE} (got {per_page})") +def require_pagination(page: int, per_page: int) -> None: + """Raise a `ToolError` if the pagination window is outside the supported range. + + `per_page` is bounded to `1..MAX_PER_PAGE` on both sides. The ceiling caps + tool output size; the floor stops the two broken low values Mealie accepts, + `-1` (an unbounded "all rows" fetch that defeats the ceiling) and `0` (an + empty page). `page` is bounded to `>= 1` for the same reason: Mealie + silently coerces `0` to page 1 and reads a negative page as the last page, + so an out-of-range value returns a surprising result instead of an error. + """ + if page < 1: + raise ToolError(f"page must be >= 1 (got {page})") + if per_page < 1 or per_page > MAX_PER_PAGE: + raise ToolError(f"per_page must be between 1 and {MAX_PER_PAGE} (got {per_page})") def to_unset[T](value: T | None) -> T | Unset: @@ -73,6 +84,14 @@ def ack_delete(action: str, response: Response[Any], ack_id: str) -> dict[str, A return {"id": ack_id, "deleted": True} +def parse_recipe_uuid(value: str) -> UUID: + """Parse a recipe id into a UUID or raise `ToolError`.""" + try: + return UUID(value) + except ValueError as exc: + raise ToolError(f"recipe_id must be a recipe UUID: {exc}") from exc + + def parse_order_direction(value: str | None) -> OrderDirection | Unset: """Coerce a caller-supplied 'asc'/'desc' into the typed enum.""" if value is None: diff --git a/src/mealie_mcp/tools/households_cookbooks.py b/src/mealie_mcp/tools/households_cookbooks.py index 14ef43d..28159ae 100644 --- a/src/mealie_mcp/tools/households_cookbooks.py +++ b/src/mealie_mcp/tools/households_cookbooks.py @@ -32,7 +32,7 @@ expect_dict, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -45,7 +45,7 @@ def list_cookbooks( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List the household's cookbooks, paginated. Returns the pagination envelope.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_households_cookbooks_get.sync_detailed( client=client, page=page, @@ -147,7 +147,7 @@ def _list_cookbooks( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. order_by: Optional column name to sort on (e.g. ``"name"``). order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/households_mealplans.py b/src/mealie_mcp/tools/households_mealplans.py index 91e4c45..e97a0e8 100644 --- a/src/mealie_mcp/tools/households_mealplans.py +++ b/src/mealie_mcp/tools/households_mealplans.py @@ -33,7 +33,8 @@ ack_delete, expect_dict, parse_order_direction, - require_per_page, + parse_recipe_uuid, + require_pagination, to_unset, ) @@ -68,10 +69,7 @@ def _parse_recipe_id(value: str | None) -> UUID | Unset: """Parse an optional recipe UUID string into a UUID or UNSET.""" if value is None: return UNSET - try: - return UUID(value) - except ValueError as exc: - raise ToolError(f"recipe_id must be a recipe UUID: {exc}") from exc + return parse_recipe_uuid(value) def list_mealplans( @@ -84,7 +82,7 @@ def list_mealplans( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List meal plan entries, paginated and optionally date-range filtered.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_households_mealplans_get.sync_detailed( client=client, page=page, @@ -190,7 +188,7 @@ def _list_mealplans( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. start_date: Optional inclusive lower bound as ``YYYY-MM-DD``. end_date: Optional inclusive upper bound as ``YYYY-MM-DD``. order_by: Optional column name to sort on (e.g. ``"date"``). diff --git a/src/mealie_mcp/tools/households_shopping_lists.py b/src/mealie_mcp/tools/households_shopping_lists.py index 266166f..18aa553 100644 --- a/src/mealie_mcp/tools/households_shopping_lists.py +++ b/src/mealie_mcp/tools/households_shopping_lists.py @@ -47,7 +47,7 @@ expect_dict, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -60,7 +60,7 @@ def list_shopping_lists( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List shopping lists for the household, paginated.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_households_shopping_lists_get.sync_detailed( client=client, page=page, @@ -208,7 +208,7 @@ def _list_shopping_lists( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. order_by: Optional column name to sort on (e.g. ``"name"``). order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/organizer_categories.py b/src/mealie_mcp/tools/organizer_categories.py index a3ee771..1e90245 100644 --- a/src/mealie_mcp/tools/organizer_categories.py +++ b/src/mealie_mcp/tools/organizer_categories.py @@ -27,7 +27,7 @@ expect_dict, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -41,7 +41,7 @@ def list_categories( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List recipe categories, paginated. Returns the pagination envelope.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_organizers_categories_get.sync_detailed( client=client, page=page, @@ -121,7 +121,7 @@ def _list_categories( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. search: Optional free-text search. order_by: Optional column name to sort on. order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/organizer_tags.py b/src/mealie_mcp/tools/organizer_tags.py index 54a2207..11c0d04 100644 --- a/src/mealie_mcp/tools/organizer_tags.py +++ b/src/mealie_mcp/tools/organizer_tags.py @@ -27,7 +27,7 @@ expect_dict, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -41,7 +41,7 @@ def list_tags( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List recipe tags, paginated. Returns the pagination envelope.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_organizers_tags_get.sync_detailed( client=client, page=page, @@ -121,7 +121,7 @@ def _list_tags( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. search: Optional free-text search. order_by: Optional column name to sort on. order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/organizer_tools.py b/src/mealie_mcp/tools/organizer_tools.py index 2858025..a23d4bc 100644 --- a/src/mealie_mcp/tools/organizer_tools.py +++ b/src/mealie_mcp/tools/organizer_tools.py @@ -27,7 +27,7 @@ expect_dict, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -41,7 +41,7 @@ def list_tools( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List recipe tools, paginated. Returns the pagination envelope.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_organizers_tools_get.sync_detailed( client=client, page=page, @@ -132,7 +132,7 @@ def _list_tools( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. search: Optional free-text search. order_by: Optional column name to sort on. order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/recipe_comments.py b/src/mealie_mcp/tools/recipe_comments.py index 17434f2..555936f 100644 --- a/src/mealie_mcp/tools/recipe_comments.py +++ b/src/mealie_mcp/tools/recipe_comments.py @@ -29,7 +29,7 @@ expect_list, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -61,7 +61,7 @@ def list_comments( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List all comments across recipes, paginated. Returns the page payload.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_comments_get.sync_detailed( client=client, page=page, @@ -140,7 +140,7 @@ def _list_comments( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. order_by: Optional column name to sort on (e.g. ``"createdAt"``). order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/recipe_crud.py b/src/mealie_mcp/tools/recipe_crud.py index 4bb038f..e3bcc95 100644 --- a/src/mealie_mcp/tools/recipe_crud.py +++ b/src/mealie_mcp/tools/recipe_crud.py @@ -40,7 +40,7 @@ expect_str, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -91,7 +91,7 @@ def list_recipes( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List recipes, paginated. Returns the pagination envelope.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_recipes_get.sync_detailed( client=client, page=page, @@ -342,7 +342,7 @@ def _list_recipes( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. search: Optional free-text search. categories: Optional list of category slugs to filter by. tags: Optional list of tag slugs to filter by. diff --git a/src/mealie_mcp/tools/recipe_timeline.py b/src/mealie_mcp/tools/recipe_timeline.py index ca4523e..0d76d3d 100644 --- a/src/mealie_mcp/tools/recipe_timeline.py +++ b/src/mealie_mcp/tools/recipe_timeline.py @@ -33,8 +33,9 @@ ack_delete, expect_dict, parse_order_direction, + parse_recipe_uuid, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -71,11 +72,14 @@ def list_recipe_timeline_events( ) -> dict[str, Any]: """List a recipe's timeline events, paginated. Returns the pagination envelope.""" require_non_empty("recipe_id", recipe_id) - require_per_page(per_page) + # UUID-parse before interpolating so a caller value carrying a quote or a + # DSL operator cannot alter the parsed queryFilter expression. + recipe_uuid = parse_recipe_uuid(recipe_id) + require_pagination(page, per_page) response = get_all_api_recipes_timeline_events_get.sync_detailed( client=client, - query_filter=f'recipe_id="{recipe_id}"', + query_filter=f'recipe_id="{recipe_uuid}"', page=page, per_page=per_page, order_by=to_unset(order_by), @@ -184,7 +188,7 @@ def _list_recipe_timeline_events( Args: recipe_id: UUID of the recipe whose events to list. page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. order_by: Optional column name to sort on (e.g. ``"timestamp"``). order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/recipes_foods.py b/src/mealie_mcp/tools/recipes_foods.py index c58e946..b03b8b8 100644 --- a/src/mealie_mcp/tools/recipes_foods.py +++ b/src/mealie_mcp/tools/recipes_foods.py @@ -26,7 +26,7 @@ expect_dict, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -40,7 +40,7 @@ def list_foods( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List ingredient foods, paginated. Returns the pagination envelope.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_foods_get.sync_detailed( client=client, page=page, @@ -115,7 +115,7 @@ def _list_foods( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. search: Optional free-text search. order_by: Optional column name to sort on. order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/recipes_units.py b/src/mealie_mcp/tools/recipes_units.py index 19cafc6..405f319 100644 --- a/src/mealie_mcp/tools/recipes_units.py +++ b/src/mealie_mcp/tools/recipes_units.py @@ -26,7 +26,7 @@ expect_dict, parse_order_direction, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -40,7 +40,7 @@ def list_units( order_direction: Literal["asc", "desc"] | None = None, ) -> dict[str, Any]: """List ingredient units, paginated. Returns the pagination envelope.""" - require_per_page(per_page) + require_pagination(page, per_page) response = get_all_api_units_get.sync_detailed( client=client, page=page, @@ -115,7 +115,7 @@ def _list_units( Args: page: 1-indexed page number. Defaults to 1. - per_page: Page size. Defaults to 50. Capped at 100. + per_page: Page size, 1 to 100. Defaults to 50. search: Optional free-text search. order_by: Optional column name to sort on. order_direction: ``"asc"`` or ``"desc"``. diff --git a/src/mealie_mcp/tools/users_ratings.py b/src/mealie_mcp/tools/users_ratings.py index 79af01f..0be9460 100644 --- a/src/mealie_mcp/tools/users_ratings.py +++ b/src/mealie_mcp/tools/users_ratings.py @@ -32,17 +32,40 @@ require_non_empty, ) +RATING_MIN = 0.0 +RATING_MAX = 5.0 + +_user_id_by_token: dict[str, str] = {} + def _current_user_id(client: AuthenticatedClient) -> str: - """Resolve the acting user's id from the authenticated token.""" + """Resolve the acting user's id from the authenticated token. + + The id is stable for the token's life, so it is cached per token to spare a + ``GET /api/users/self`` on every rating and favorite call. + """ + cached = _user_id_by_token.get(client.token) + if cached is not None: + return cached response = get_logged_in_user_api_users_self_get.sync_detailed(client=client) user = expect_dict("resolve current user", response) user_id = user.get("id") if not isinstance(user_id, str) or not user_id: raise ToolError("Could not resolve the current user id from Mealie") + _user_id_by_token[client.token] = user_id return user_id +def _require_rating_in_range(rating: float) -> None: + """Raise a `ToolError` if a rating is outside Mealie's ``0..5`` convention. + + Mealie stores whatever float it is sent, so an out-of-range value silently + corrupts the user's ratings rather than being rejected. + """ + if not RATING_MIN <= rating <= RATING_MAX: + raise ToolError(f"rating must be between {RATING_MIN:g} and {RATING_MAX:g} (got {rating})") + + def _ratings_list(action: str, response: Any) -> list[Any]: """Pull the ``ratings`` array out of a user-rating collection envelope.""" payload = expect_dict(action, response) @@ -55,6 +78,7 @@ def _ratings_list(action: str, response: Any) -> list[Any]: def set_recipe_rating(client: AuthenticatedClient, slug: str, rating: float) -> dict[str, Any]: """Set the acting user's rating for a recipe. Returns a confirmation.""" require_non_empty("slug", slug) + _require_rating_in_range(rating) user_id = _current_user_id(client) response = set_rating_api_users_id_ratings_slug_post.sync_detailed( @@ -112,7 +136,7 @@ def _set_recipe_rating(slug: str, rating: float) -> dict[str, Any]: Args: slug: Recipe slug. - rating: Numeric rating to store for the recipe. + rating: Rating from 0 to 5 to store for the recipe. Returns: A confirmation ``{"slug": , "rating": }``. diff --git a/tests/unit/test_common.py b/tests/unit/test_common.py index e69c9f3..e0b262e 100644 --- a/tests/unit/test_common.py +++ b/tests/unit/test_common.py @@ -18,9 +18,10 @@ expect_list, expect_str, parse_order_direction, + parse_recipe_uuid, raise_api_error, require_non_empty, - require_per_page, + require_pagination, to_unset, ) @@ -94,16 +95,42 @@ def test_accepts_value(self) -> None: require_non_empty("name", "x") -class TestRequirePerPage: - def test_accepts_value_at_max(self) -> None: - require_per_page(MAX_PER_PAGE) +class TestRequirePagination: + def test_accepts_per_page_at_max(self) -> None: + require_pagination(1, MAX_PER_PAGE) - def test_accepts_value_below_max(self) -> None: - require_per_page(1) + def test_accepts_per_page_at_floor(self) -> None: + require_pagination(1, 1) - def test_rejects_value_above_max(self) -> None: - with pytest.raises(ToolError, match=rf"per_page must be <= {MAX_PER_PAGE} \(got 250\)"): - require_per_page(250) + def test_rejects_per_page_above_max(self) -> None: + with pytest.raises( + ToolError, match=rf"per_page must be between 1 and {MAX_PER_PAGE} \(got 250\)" + ): + require_pagination(1, 250) + + @pytest.mark.parametrize("per_page", [0, -1]) + def test_rejects_per_page_below_floor(self, per_page: int) -> None: + with pytest.raises( + ToolError, match=rf"per_page must be between 1 and {MAX_PER_PAGE} \(got {per_page}\)" + ): + require_pagination(1, per_page) + + @pytest.mark.parametrize("page", [0, -1]) + def test_rejects_page_below_one(self, page: int) -> None: + with pytest.raises(ToolError, match=rf"page must be >= 1 \(got {page}\)"): + require_pagination(page, 50) + + +class TestParseRecipeUuid: + def test_parses_valid_uuid(self) -> None: + assert ( + str(parse_recipe_uuid("11111111-1111-1111-1111-111111111111")) + == "11111111-1111-1111-1111-111111111111" + ) + + def test_rejects_non_uuid(self) -> None: + with pytest.raises(ToolError, match="recipe_id must be a recipe UUID"): + parse_recipe_uuid('x" or true') class TestExpectDict: diff --git a/tests/unit/test_households_cookbooks.py b/tests/unit/test_households_cookbooks.py index d2baa3f..7015bee 100644 --- a/tests/unit/test_households_cookbooks.py +++ b/tests/unit/test_households_cookbooks.py @@ -22,7 +22,7 @@ def client() -> AuthenticatedClient: class TestListCookbooks: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): households_cookbooks.list_cookbooks(client, per_page=101) diff --git a/tests/unit/test_households_mealplans.py b/tests/unit/test_households_mealplans.py index 8079ee8..eda6b9b 100644 --- a/tests/unit/test_households_mealplans.py +++ b/tests/unit/test_households_mealplans.py @@ -21,7 +21,7 @@ def client() -> AuthenticatedClient: class TestListMealplans: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): households_mealplans.list_mealplans(client, per_page=101) def test_rejects_malformed_start_date(self, client: AuthenticatedClient) -> None: diff --git a/tests/unit/test_households_shopping_lists.py b/tests/unit/test_households_shopping_lists.py index 098f4fa..2de7987 100644 --- a/tests/unit/test_households_shopping_lists.py +++ b/tests/unit/test_households_shopping_lists.py @@ -22,7 +22,7 @@ def client() -> AuthenticatedClient: class TestListShoppingLists: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): households_shopping_lists.list_shopping_lists(client, per_page=101) diff --git a/tests/unit/test_organizer_categories.py b/tests/unit/test_organizer_categories.py index 2658518..da47250 100644 --- a/tests/unit/test_organizer_categories.py +++ b/tests/unit/test_organizer_categories.py @@ -21,7 +21,7 @@ def client() -> AuthenticatedClient: class TestListCategories: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): organizer_categories.list_categories(client, per_page=101) diff --git a/tests/unit/test_organizer_tags.py b/tests/unit/test_organizer_tags.py index 1460988..01e7522 100644 --- a/tests/unit/test_organizer_tags.py +++ b/tests/unit/test_organizer_tags.py @@ -21,7 +21,7 @@ def client() -> AuthenticatedClient: class TestListTags: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): organizer_tags.list_tags(client, per_page=101) diff --git a/tests/unit/test_organizer_tools.py b/tests/unit/test_organizer_tools.py index c07c6e2..4648822 100644 --- a/tests/unit/test_organizer_tools.py +++ b/tests/unit/test_organizer_tools.py @@ -21,7 +21,7 @@ def client() -> AuthenticatedClient: class TestListTools: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): organizer_tools.list_tools(client, per_page=101) diff --git a/tests/unit/test_recipe_comments.py b/tests/unit/test_recipe_comments.py index 673753f..5df0f4c 100644 --- a/tests/unit/test_recipe_comments.py +++ b/tests/unit/test_recipe_comments.py @@ -37,7 +37,7 @@ def test_rejects_empty_id(self, client: AuthenticatedClient) -> None: class TestListComments: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): recipe_comments.list_comments(client, per_page=101) def test_rejects_bad_order_direction(self, client: AuthenticatedClient) -> None: diff --git a/tests/unit/test_recipe_crud.py b/tests/unit/test_recipe_crud.py index 5b91309..7056554 100644 --- a/tests/unit/test_recipe_crud.py +++ b/tests/unit/test_recipe_crud.py @@ -31,7 +31,7 @@ def test_rejects_whitespace_name(self, client: AuthenticatedClient) -> None: class TestListRecipes: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): recipe_crud.list_recipes(client, per_page=101) diff --git a/tests/unit/test_recipe_timeline.py b/tests/unit/test_recipe_timeline.py index ea09d19..101e16b 100644 --- a/tests/unit/test_recipe_timeline.py +++ b/tests/unit/test_recipe_timeline.py @@ -12,6 +12,8 @@ from mealie_mcp.client.client import AuthenticatedClient from mealie_mcp.tools import recipe_timeline +RECIPE_UUID = "11111111-1111-1111-1111-111111111111" + @pytest.fixture def client() -> AuthenticatedClient: @@ -24,15 +26,19 @@ def test_rejects_empty_recipe_id(self, client: AuthenticatedClient) -> None: with pytest.raises(ToolError, match="recipe_id must be a non-empty string"): recipe_timeline.list_recipe_timeline_events(client, recipe_id="") + def test_rejects_non_uuid_recipe_id(self, client: AuthenticatedClient) -> None: + with pytest.raises(ToolError, match="recipe_id must be a recipe UUID"): + recipe_timeline.list_recipe_timeline_events(client, recipe_id='x" or true') + def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): - recipe_timeline.list_recipe_timeline_events(client, recipe_id="abc", per_page=101) + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): + recipe_timeline.list_recipe_timeline_events(client, recipe_id=RECIPE_UUID, per_page=101) def test_rejects_bad_order_direction(self, client: AuthenticatedClient) -> None: with pytest.raises(ToolError, match="order_direction must be 'asc' or 'desc'"): recipe_timeline.list_recipe_timeline_events( client, - recipe_id="abc", + recipe_id=RECIPE_UUID, order_direction="sideways", ) diff --git a/tests/unit/test_recipes_foods.py b/tests/unit/test_recipes_foods.py index fbec2bd..c79cd9f 100644 --- a/tests/unit/test_recipes_foods.py +++ b/tests/unit/test_recipes_foods.py @@ -21,7 +21,7 @@ def client() -> AuthenticatedClient: class TestListFoods: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): recipes_foods.list_foods(client, per_page=101) diff --git a/tests/unit/test_recipes_units.py b/tests/unit/test_recipes_units.py index 7cf90ef..e5a9619 100644 --- a/tests/unit/test_recipes_units.py +++ b/tests/unit/test_recipes_units.py @@ -21,7 +21,7 @@ def client() -> AuthenticatedClient: class TestListUnits: def test_rejects_per_page_above_max(self, client: AuthenticatedClient) -> None: - with pytest.raises(ToolError, match=r"per_page must be <= 100 \(got 101\)"): + with pytest.raises(ToolError, match=r"per_page must be between 1 and 100 \(got 101\)"): recipes_units.list_units(client, per_page=101) diff --git a/tests/unit/test_users_ratings.py b/tests/unit/test_users_ratings.py index 05d2326..b0182ad 100644 --- a/tests/unit/test_users_ratings.py +++ b/tests/unit/test_users_ratings.py @@ -24,6 +24,27 @@ def test_rejects_empty_slug(self, client: AuthenticatedClient) -> None: with pytest.raises(ToolError, match="slug must be a non-empty string"): users_ratings.set_recipe_rating(client, slug=" ", rating=4.5) + @pytest.mark.parametrize("rating", [6.0, -1.0]) + def test_rejects_out_of_range_rating(self, client: AuthenticatedClient, rating: float) -> None: + with pytest.raises(ToolError, match=r"rating must be between 0 and 5 \(got"): + users_ratings.set_recipe_rating(client, slug="valid-slug", rating=rating) + + +class TestCurrentUserId: + def test_returns_cached_id_without_calling_the_client( + self, client: AuthenticatedClient + ) -> None: + """A pre-seeded cache short-circuits the ``GET /api/users/self`` lookup. + + The client points at a host with no server, so a cache miss would raise + on the HTTP call. Returning the seeded id proves the cache-hit path. + """ + users_ratings._user_id_by_token[client.token] = "cached-user-id" + try: + assert users_ratings._current_user_id(client) == "cached-user-id" + finally: + users_ratings._user_id_by_token.pop(client.token, None) + class TestAddFavorite: def test_rejects_empty_slug(self, client: AuthenticatedClient) -> None: