From cb88d2b0628b701fef83e0a0dbd0cae61d5c110f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:49:34 +0000 Subject: [PATCH 1/3] fix: coerce numeric nutrition values to strings in Cookbook model (fixes #708) Agent-Logs-Url: https://github.com/dylanlangston/nextcloud-mcp-server/sessions/d7163bb6-ad5d-4406-8d11-145c5317ec19 Co-authored-by: dylanlangston <16236219+dylanlangston@users.noreply.github.com> --- nextcloud_mcp_server/models/cookbook.py | 29 +++++++++- tests/unit/test_nutrition_model.py | 77 +++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_nutrition_model.py diff --git a/nextcloud_mcp_server/models/cookbook.py b/nextcloud_mcp_server/models/cookbook.py index c058989a..d713358e 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,33 @@ 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, (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..e2b7ba01 --- /dev/null +++ b/tests/unit/test_nutrition_model.py @@ -0,0 +1,77 @@ +"""Unit tests for Nutrition model numeric coercion (issue #708).""" + +import pytest + +from nextcloud_mcp_server.models.cookbook import Nutrition + + +@pytest.mark.unit +def test_nutrition_calories_accepts_string(): + """String calories values should be accepted as-is.""" + n = Nutrition(calories="650 kcal") + assert n.calories == "650 kcal" + + +@pytest.mark.unit +def test_nutrition_calories_coerces_int(): + """Integer calories values should be coerced to strings.""" + n = Nutrition(calories=260) + assert n.calories == "260" + + +@pytest.mark.unit +def test_nutrition_calories_coerces_float(): + """Float calories values should be coerced to strings.""" + n = Nutrition(calories=260.5) + assert n.calories == "260.5" + + +@pytest.mark.unit +def test_nutrition_calories_accepts_none(): + """None calories should remain None.""" + n = Nutrition(calories=None) + assert n.calories is None + + +@pytest.mark.unit +def test_nutrition_all_fields_coerce_int(): + """All nutrition content fields should coerce integer values to strings.""" + n = Nutrition( + calories=260, + carbohydrateContent=30, + cholesterolContent=10, + fatContent=15, + fiberContent=5, + proteinContent=20, + saturatedFatContent=3, + servingSize=1, + sodiumContent=500, + sugarContent=8, + transFatContent=0, + unsaturatedFatContent=12, + ) + assert n.calories == "260" + assert n.carbohydrateContent == "30" + assert n.cholesterolContent == "10" + assert n.fatContent == "15" + assert n.fiberContent == "5" + assert n.proteinContent == "20" + assert n.saturatedFatContent == "3" + assert n.servingSize == "1" + assert n.sodiumContent == "500" + assert n.sugarContent == "8" + assert n.transFatContent == "0" + assert n.unsaturatedFatContent == "12" + + +@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 From 6ae30acc6f760ffcb6dfc1691609847ae10fe57e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:02:56 +0000 Subject: [PATCH 2/3] address review: exclude bool from coercion, parameterize tests over all fields Agent-Logs-Url: https://github.com/dylanlangston/nextcloud-mcp-server/sessions/0360ab28-8913-450e-8c61-697e71ba9742 Co-authored-by: dylanlangston <16236219+dylanlangston@users.noreply.github.com> --- nextcloud_mcp_server/models/cookbook.py | 2 + tests/unit/test_nutrition_model.py | 89 ++++++++++++------------- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/nextcloud_mcp_server/models/cookbook.py b/nextcloud_mcp_server/models/cookbook.py index d713358e..5452ca5f 100644 --- a/nextcloud_mcp_server/models/cookbook.py +++ b/nextcloud_mcp_server/models/cookbook.py @@ -63,6 +63,8 @@ class Nutrition(BaseModel): strings (e.g. '650 kcal') or numbers (e.g. 650). Nextcloud Cookbook stores whatever the source provided. """ + if isinstance(v, bool): + return v if isinstance(v, (int, float)): return str(v) return v diff --git a/tests/unit/test_nutrition_model.py b/tests/unit/test_nutrition_model.py index e2b7ba01..4e9870b4 100644 --- a/tests/unit/test_nutrition_model.py +++ b/tests/unit/test_nutrition_model.py @@ -1,67 +1,64 @@ """Unit tests for Nutrition model numeric coercion (issue #708).""" import pytest +from pydantic import ValidationError from nextcloud_mcp_server.models.cookbook import Nutrition - -@pytest.mark.unit -def test_nutrition_calories_accepts_string(): - """String calories values should be accepted as-is.""" - n = Nutrition(calories="650 kcal") - assert n.calories == "650 kcal" +NUTRITION_FIELDS = [ + "calories", + "carbohydrateContent", + "cholesterolContent", + "fatContent", + "fiberContent", + "proteinContent", + "saturatedFatContent", + "servingSize", + "sodiumContent", + "sugarContent", + "transFatContent", + "unsaturatedFatContent", +] @pytest.mark.unit -def test_nutrition_calories_coerces_int(): - """Integer calories values should be coerced to strings.""" - n = Nutrition(calories=260) - assert n.calories == "260" +@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 -def test_nutrition_calories_coerces_float(): - """Float calories values should be coerced to strings.""" - n = Nutrition(calories=260.5) - assert n.calories == "260.5" +@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 -def test_nutrition_calories_accepts_none(): - """None calories should remain None.""" - n = Nutrition(calories=None) - assert n.calories is None +@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 -def test_nutrition_all_fields_coerce_int(): - """All nutrition content fields should coerce integer values to strings.""" - n = Nutrition( - calories=260, - carbohydrateContent=30, - cholesterolContent=10, - fatContent=15, - fiberContent=5, - proteinContent=20, - saturatedFatContent=3, - servingSize=1, - sodiumContent=500, - sugarContent=8, - transFatContent=0, - unsaturatedFatContent=12, - ) - assert n.calories == "260" - assert n.carbohydrateContent == "30" - assert n.cholesterolContent == "10" - assert n.fatContent == "15" - assert n.fiberContent == "5" - assert n.proteinContent == "20" - assert n.saturatedFatContent == "3" - assert n.servingSize == "1" - assert n.sodiumContent == "500" - assert n.sugarContent == "8" - assert n.transFatContent == "0" - assert n.unsaturatedFatContent == "12" +@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 From 480504f8b5845a29d5a57d79acbc83f82829b824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:04:18 +0000 Subject: [PATCH 3/3] raise ValueError for bool inputs instead of returning as-is Agent-Logs-Url: https://github.com/dylanlangston/nextcloud-mcp-server/sessions/0360ab28-8913-450e-8c61-697e71ba9742 Co-authored-by: dylanlangston <16236219+dylanlangston@users.noreply.github.com> --- nextcloud_mcp_server/models/cookbook.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nextcloud_mcp_server/models/cookbook.py b/nextcloud_mcp_server/models/cookbook.py index 5452ca5f..a53f79ba 100644 --- a/nextcloud_mcp_server/models/cookbook.py +++ b/nextcloud_mcp_server/models/cookbook.py @@ -64,7 +64,8 @@ class Nutrition(BaseModel): stores whatever the source provided. """ if isinstance(v, bool): - return v + msg = "boolean values are not valid for nutrition fields" + raise ValueError(msg) if isinstance(v, (int, float)): return str(v) return v