From fd723164ca4a6bcda8c483c120196bf8f2dcfbba Mon Sep 17 00:00:00 2001 From: phernandez Date: Wed, 25 Dec 2024 12:33:59 -0600 Subject: [PATCH] add validation tests for observation schema --- src/basic_memory/schemas/base.py | 29 +++++- tests/schemas/test_observation_schemas.py | 102 ++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 tests/schemas/test_observation_schemas.py diff --git a/src/basic_memory/schemas/base.py b/src/basic_memory/schemas/base.py index 57bfd760..e7fb28b1 100644 --- a/src/basic_memory/schemas/base.py +++ b/src/basic_memory/schemas/base.py @@ -69,7 +69,20 @@ def validate_path_format(path: str) -> str: return path + class ObservationCategory(str, Enum): + """Categories for structuring observations. + + Categories help organize knowledge and make it easier to find later: + - tech: Implementation details and technical notes + - design: Architecture decisions and patterns + - feature: User-facing capabilities + - note: General observations (default) + - issue: Problems or concerns + - todo: Future work items + + Categories are case-insensitive for easier use. + """ TECH = "tech" DESIGN = "design" FEATURE = "feature" @@ -77,10 +90,24 @@ class ObservationCategory(str, Enum): ISSUE = "issue" TODO = "todo" + @classmethod + def _missing_(cls, value: str) -> "ObservationCategory": + """Handle case-insensitive lookup.""" + try: + return cls(value.lower()) + except ValueError: + return None + + PathId = Annotated[str, BeforeValidator(to_snake_case), BeforeValidator(validate_path_format)] """Unique identifier in format '{path}/{normalized_name}'.""" -Observation = Annotated[str, MinLen(1), MaxLen(1000)] +Observation = Annotated[ + str, + BeforeValidator(str.strip), # Clean whitespace + MinLen(1), # Ensure non-empty after stripping + MaxLen(1000) # Keep reasonable length +] """A single piece of information about an entity. Must be non-empty and under 1000 characters. Best Practices: diff --git a/tests/schemas/test_observation_schemas.py b/tests/schemas/test_observation_schemas.py new file mode 100644 index 00000000..7b8b0c3e --- /dev/null +++ b/tests/schemas/test_observation_schemas.py @@ -0,0 +1,102 @@ +"""Tests for observation schema validation and categories.""" + +import pytest +from pydantic import ValidationError + +from basic_memory.schemas import ObservationResponse +from basic_memory.schemas.base import ObservationCategory +from basic_memory.schemas.request import ObservationCreate + + +def test_observation_create_minimal(): + """Test creating ObservationCreate with minimal fields.""" + data = {"content": "Test observation"} + obs = ObservationCreate.model_validate(data) + assert obs.content == "Test observation" + assert obs.category == ObservationCategory.NOTE # Default category + + +def test_observation_create_complete(): + """Test creating ObservationCreate with all fields.""" + data = {"content": "Test observation", "category": "tech", "context": "Test context"} + obs = ObservationCreate.model_validate(data) + assert obs.content == "Test observation" + assert obs.category == ObservationCategory.TECH + + +def test_observation_create_category_validation(): + """Test category validation in ObservationCreate.""" + # Valid categories + for category in ObservationCategory: + data = {"content": "Test", "category": category.value} + obs = ObservationCreate.model_validate(data) + assert obs.category == category + + # Invalid category + with pytest.raises(ValidationError): + ObservationCreate.model_validate({"content": "Test", "category": "invalid_category"}) + + +def test_observation_response(): + """Test ObservationResponse validation and conversion.""" + data = { + "id": 1, + "path_id": 1, + "content": "Test observation", + "category": "tech", + "context": "Test context", + "created_at": "2024-12-25T12:00:00", + "updated_at": "2024-12-25T12:00:00", + } + obs = ObservationResponse.model_validate(data) + assert obs.content == "Test observation" + assert obs.category == ObservationCategory.TECH + assert obs.context == "Test context" + + +def test_observation_create_empty_content(): + """Test validation of empty content.""" + with pytest.raises(ValidationError): + ObservationCreate.model_validate({"content": "", "category": "tech"}) + + with pytest.raises(ValidationError): + ObservationCreate.model_validate( + { + "content": " ", # Just whitespace + "category": "tech", + } + ) + + +def test_observation_create_content_length(): + """Test content length validation.""" + # Create string just over max length + long_content = "x" * 1001 + + with pytest.raises(ValidationError): + ObservationCreate.model_validate({"content": long_content, "category": "tech"}) + + +def test_observation_category_coercion(): + """Test category accepts both string and enum.""" + # Test with string + obs1 = ObservationCreate.model_validate({"content": "Test", "category": "tech"}) + assert obs1.category == ObservationCategory.TECH + + # Test with enum + obs2 = ObservationCreate.model_validate( + {"content": "Test", "category": ObservationCategory.TECH} + ) + assert obs2.category == ObservationCategory.TECH + + # Both should be equal + assert obs1.category == obs2.category + + +def test_observation_category_case_insensitive(): + """Test category validation is case insensitive.""" + variations = ["TECH", "tech", "Tech", "TEcH"] + + for variant in variations: + obs = ObservationCreate.model_validate({"content": "Test", "category": variant}) + assert obs.category == ObservationCategory.TECH