"""Tests for parameter aliasing using Pydantic AliasChoices.
P1-1.5 uses Pydantic's AliasChoices with Field(validation_alias=...) to accept
both snake_case and camelCase parameter names at the FastMCP validation layer.
"""
import pytest
from pydantic import AliasChoices, BaseModel, Field
from typing import Annotated
class TestAliasChoicesPattern:
"""Tests demonstrating the AliasChoices pattern for parameter aliasing."""
def test_alias_choices_accepts_snake_case(self):
"""AliasChoices accepts snake_case parameter names."""
class TestModel(BaseModel):
search_term: Annotated[
str,
Field(validation_alias=AliasChoices("search_term", "searchTerm"))
]
m = TestModel.model_validate({"search_term": "test"})
assert m.search_term == "test"
def test_alias_choices_accepts_camel_case(self):
"""AliasChoices accepts camelCase parameter names."""
class TestModel(BaseModel):
search_term: Annotated[
str,
Field(validation_alias=AliasChoices("search_term", "searchTerm"))
]
m = TestModel.model_validate({"searchTerm": "test"})
assert m.search_term == "test"
def test_snake_case_takes_precedence(self):
"""When both are provided, the first alias choice wins."""
class TestModel(BaseModel):
search_term: Annotated[
str,
Field(validation_alias=AliasChoices("search_term", "searchTerm"))
]
# First matching alias wins
m = TestModel.model_validate({"search_term": "snake", "searchTerm": "camel"})
assert m.search_term == "snake"
def test_alias_choices_with_default_value(self):
"""AliasChoices works with optional parameters that have defaults."""
class TestModel(BaseModel):
search_method: Annotated[
str,
Field(
default="by_name",
validation_alias=AliasChoices("search_method", "searchMethod")
)
]
# Default is used when not provided
m1 = TestModel.model_validate({})
assert m1.search_method == "by_name"
# snake_case overrides default
m2 = TestModel.model_validate({"search_method": "by_tag"})
assert m2.search_method == "by_tag"
# camelCase overrides default
m3 = TestModel.model_validate({"searchMethod": "by_id"})
assert m3.search_method == "by_id"
def test_alias_choices_with_optional_none(self):
"""AliasChoices works with Optional parameters defaulting to None."""
class TestModel(BaseModel):
page_size: Annotated[
int | None,
Field(
default=None,
validation_alias=AliasChoices("page_size", "pageSize")
)
]
# None default
m1 = TestModel.model_validate({})
assert m1.page_size is None
# snake_case
m2 = TestModel.model_validate({"page_size": 50})
assert m2.page_size == 50
# camelCase
m3 = TestModel.model_validate({"pageSize": 100})
assert m3.page_size == 100
def test_alias_choices_with_bool_coercion(self):
"""AliasChoices works with boolean parameters."""
class TestModel(BaseModel):
include_inactive: Annotated[
bool | str | None,
Field(
default=None,
validation_alias=AliasChoices("include_inactive", "includeInactive")
)
]
# camelCase with bool
m1 = TestModel.model_validate({"includeInactive": True})
assert m1.include_inactive is True
# snake_case with string (common from JSON)
m2 = TestModel.model_validate({"include_inactive": "true"})
assert m2.include_inactive == "true" # Note: string coercion happens in tool
def test_alias_choices_multiple_params(self):
"""Multiple parameters can each have AliasChoices."""
class TestModel(BaseModel):
search_term: Annotated[
str,
Field(validation_alias=AliasChoices("search_term", "searchTerm"))
]
search_method: Annotated[
str,
Field(
default="by_name",
validation_alias=AliasChoices("search_method", "searchMethod")
)
]
page_size: Annotated[
int | None,
Field(
default=None,
validation_alias=AliasChoices("page_size", "pageSize")
)
]
# Mix of snake_case and camelCase
m = TestModel.model_validate({
"searchTerm": "Player",
"search_method": "by_tag",
"pageSize": 25
})
assert m.search_term == "Player"
assert m.search_method == "by_tag"
assert m.page_size == 25