from datetime import date from decimal import Decimal import pytest from src.models.category import Category, CategoryBudget from tests.factories import CategoryFactory class TestCategoryComputedFields: """Tests for Category's computed fields and special methods.""" def test_hash_id_computed_field_simple(self): """Test hash_id for single-level category.""" c = Category.from_str("single") assert c assert hasattr(c, "hash_id") assert isinstance(c.hash_id, str) # Hash should be deterministic for same inputs c2 = Category.from_str("single") assert c2 assert c.hash_id == c2.hash_id def test_hash_id_computed_field_nested(self): """Test hash_id for multi-level category hierarchy.""" c1 = Category.from_str("level1:level2:level3") assert c1 assert hasattr(c1, "hash_id") c2 = Category.from_str("level1:level2:level3") assert c2 assert c1.hash_id == c2.hash_id def test_hash_id_different_hierarchy(self): """Test that different hierarchies have different hash_ids.""" c1 = Category.from_str("A:B") c2 = Category.from_str("A:C") assert c1 assert c2 # Different last level -> different hash_id assert c1.hash_id != c2.hash_id def test_hash_id_same_name_different_parent(self): """Test that same name but different parent gives different hash.""" root = Category(name="Budget") cat1 = Category(name="Budget", parent=root) # Create another with same name but no parent orphan = Category.from_str("Budget") assert orphan assert cat1.hash_id != orphan.hash_id def test_to_str_single_level(self): """Test to_str() for single-level category.""" c = Category.from_str("single") assert c assert c.to_str() == "single" def test_to_str_nested(self): """Test to_str() for nested category.""" c = Category.from_str("parent:child:grandchild") assert c assert c.to_str() == "parent:child:grandchild" def test_str_uses_to_str(self): """Test that __str__ delegates to to_str().""" c = Category.from_str("A:B:C") assert c assert str(c) == c.to_str() == "A:B:C" def test_repr_simple(self): """Test repr() for single-level category.""" c = Category.from_str("single") expected = 'Category.from_str("single")' assert repr(c) == expected def test_repr_nested(self): """Test repr() for nested category.""" c = Category.from_str("parent:child") expected = 'Category.from_str("parent:child")' assert repr(c) == expected class TestCategoryBudgetValidation: """Tests for CategoryBudget's date range validation.""" def test_valid_budget_no_dates(self): """Budget with None dates is valid.""" cat = CategoryFactory.build() budget = CategoryBudget( category=cat, amount_eur=Decimal("100.00"), ) assert budget.valid_from is None assert budget.valid_to is None def test_valid_budget_with_dates(self): """Budget with valid date range is accepted.""" cat = CategoryFactory.build() budget = CategoryBudget( category=cat, amount_eur=Decimal("100.00"), valid_from=date(2026, 10, 1), valid_to=date(2026, 10, 31), ) assert budget.valid_from == date(2026, 10, 1) assert budget.valid_to == date(2026, 10, 31) def test_invalid_date_range_raises(self): """Budget with valid_from > valid_to raises ValueError.""" cat = CategoryFactory.build() with pytest.raises(ValueError) as exc_info: CategoryBudget( category=cat, amount_eur=Decimal("100.00"), valid_from=date(2026, 10, 31), valid_to=date(2026, 10, 1), ) assert "valid_from" in str(exc_info.value) assert "valid_to" in str(exc_info.value) assert "nor in order" in str(exc_info.value) def test_valid_budget_exact_boundary(self): """Budget with exact same from and to is valid (single day).""" cat = CategoryFactory.build() budget = CategoryBudget( category=cat, amount_eur=Decimal("100.00"), valid_from=date(2026, 10, 1), valid_to=date(2026, 10, 1), ) assert budget.valid_from == date(2026, 10, 1) assert budget.valid_to == date(2026, 10, 1) def test_validation_preserves_original_values(self): """Validation doesn't change the model's original values.""" cat = CategoryFactory.build() budget = CategoryBudget( category=cat, amount_eur=Decimal("150.75"), valid_from=date(2026, 9, 1), valid_to=date(2026, 9, 30), ) # Create an invalid one (valid_from > valid_to) and verify validation catches it try: CategoryBudget( category=cat, amount_eur=Decimal("200.00"), valid_from=date(2026, 12, 31), valid_to=date(2026, 12, 30), ) except ValueError: pass # Expected # Valid one should still have original values assert budget.amount_eur == 150.75