diff --git a/nextcloud_mcp_server/models/cookbook.py b/nextcloud_mcp_server/models/cookbook.py index c058989a..a53f79ba 100644 --- a/nextcloud_mcp_server/models/cookbook.py +++ b/nextcloud_mcp_server/models/cookbook.py @@ -2,7 +2,7 @@ from typing import List, Optional, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from .base import BaseResponse, IdResponse, StatusResponse @@ -40,6 +40,36 @@ class Nutrition(BaseModel): model_config = ConfigDict(populate_by_name=True) + @field_validator( + "calories", + "carbohydrateContent", + "cholesterolContent", + "fatContent", + "fiberContent", + "proteinContent", + "saturatedFatContent", + "servingSize", + "sodiumContent", + "sugarContent", + "transFatContent", + "unsaturatedFatContent", + mode="before", + ) + @classmethod + def coerce_to_str(cls, v: object) -> object: + """Coerce numeric values to strings. + + The schema.org/NutritionInformation spec allows values as either + strings (e.g. '650 kcal') or numbers (e.g. 650). Nextcloud Cookbook + stores whatever the source provided. + """ + if isinstance(v, bool): + msg = "boolean values are not valid for nutrition fields" + raise ValueError(msg) + if isinstance(v, (int, float)): + return str(v) + return v + class RecipeStub(BaseModel): """Stub of a recipe with basic information.""" diff --git a/tests/unit/test_nutrition_model.py b/tests/unit/test_nutrition_model.py new file mode 100644 index 00000000..4e9870b4 --- /dev/null +++ b/tests/unit/test_nutrition_model.py @@ -0,0 +1,74 @@ +"""Unit tests for Nutrition model numeric coercion (issue #708).""" + +import pytest +from pydantic import ValidationError + +from nextcloud_mcp_server.models.cookbook import Nutrition + +NUTRITION_FIELDS = [ + "calories", + "carbohydrateContent", + "cholesterolContent", + "fatContent", + "fiberContent", + "proteinContent", + "saturatedFatContent", + "servingSize", + "sodiumContent", + "sugarContent", + "transFatContent", + "unsaturatedFatContent", +] + + +@pytest.mark.unit +@pytest.mark.parametrize("field", NUTRITION_FIELDS) +def test_nutrition_field_accepts_string(field: str): + """String values should be accepted as-is for every nutrition field.""" + n = Nutrition(**{field: "650 kcal"}) + assert getattr(n, field) == "650 kcal" + + +@pytest.mark.unit +@pytest.mark.parametrize("field", NUTRITION_FIELDS) +def test_nutrition_field_coerces_int(field: str): + """Integer values should be coerced to strings for every nutrition field.""" + n = Nutrition(**{field: 260}) + assert getattr(n, field) == "260" + + +@pytest.mark.unit +@pytest.mark.parametrize("field", NUTRITION_FIELDS) +def test_nutrition_field_coerces_float(field: str): + """Float values should be coerced to strings for every nutrition field.""" + n = Nutrition(**{field: 260.5}) + assert getattr(n, field) == "260.5" + + +@pytest.mark.unit +@pytest.mark.parametrize("field", NUTRITION_FIELDS) +def test_nutrition_field_accepts_none(field: str): + """None should remain None for every nutrition field.""" + n = Nutrition(**{field: None}) + assert getattr(n, field) is None + + +@pytest.mark.unit +@pytest.mark.parametrize("field", NUTRITION_FIELDS) +def test_nutrition_field_rejects_bool(field: str): + """Boolean values should not be silently coerced to strings.""" + with pytest.raises(ValidationError): + Nutrition(**{field: True}) + + +@pytest.mark.unit +def test_nutrition_mixed_types(): + """Nutrition model should handle a mix of string, int, and None values.""" + n = Nutrition( + calories="650 kcal", + proteinContent=18, + fatContent=None, + ) + assert n.calories == "650 kcal" + assert n.proteinContent == "18" + assert n.fatContent is None