"""Comprehensive tests for set_category_budget overlap handling.""" from datetime import date import pytest from sqlalchemy.orm import Session from src.domain_utils import set_category_budget, find_category_db from src.models import Category, CategoryBudget, CategoryBudgetDb from src.models.category import upsert_category class TestSetCategoryBudgetNoOverlap: """Test budgets that don't overlap with existing ones.""" def test_no_overlap_add_new(self, db_session: Session): """When new budget has no overlap with existing, add both.""" cat = Category(name="NoOverlap") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # First budget: Oct 1 - Nov 30 budget1 = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, budget1) # Second budget: Dec 1 - Dec 31 (no overlap with Oct-Nov range) budget2 = CategoryBudget( category=cat, amount_eur=150.00, valid_from=date(2026, 12, 1), valid_to=date(2026, 12, 31), ) set_category_budget(db_session, budget2) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 2 def test_completely_before_no_overlap(self, db_session: Session): """New budget completely before existing - should add both.""" cat = Category(name="BeforeExisting") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # Existing budget covering Oct 15 - Dec 30 existing_budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 15), valid_to=date(2026, 12, 30), ) set_category_budget(db_session, existing_budget) # New budget covering Sep 1 - Sep 30 (completely before) new_budget = CategoryBudget( category=cat, amount_eur=50.00, valid_from=date(2026, 9, 1), valid_to=date(2026, 9, 30), ) set_category_budget(db_session, new_budget) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 2 class TestSetCategoryBudgetFullOverlap: """Test when new budget fully overlaps existing.""" def test_full_overlap_replace(self, db_session: Session): """When new budget fully overlaps existing, replace entirely.""" cat = Category(name="FullOverlap") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # Existing budget covering Oct 1 - Nov 30 existing_budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, existing_budget) # New budget covering Oct 1 - Nov 30 (same range, different amount) new_budget = CategoryBudget( category=cat, amount_eur=200.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, new_budget) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) # Should have only 1 budget with the new amount (replaced old one) assert len(budgets) == 1 assert budgets[0].amount_eur == 200.00 class TestSetCategoryBudgetPartialOverlapStart: """Test partial overlap where new budget overlaps at start.""" def test_partial_overlap_at_start(self, db_session: Session): """When new budget overlaps existing from the start, keep end and cut before.""" cat = Category(name="OverlapStart") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # Existing budget covering Oct 1 - Nov 30 existing_budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, existing_budget) # New budget covering Oct 1 - Nov 15 (overlaps from start) new_budget = CategoryBudget( category=cat, amount_eur=120.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 15), ) set_category_budget(db_session, new_budget) # Should have 2 budgets: # 1. Oct 1 - Nov 15 (new budget) # 2. Dec 1 - Dec 30 (remainder from old budget, after overlap end + 1 day) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 2 class TestSetCategoryBudgetPartialOverlapEnd: """Test partial overlap where new budget overlaps at end.""" def test_partial_overlap_at_end(self, db_session: Session): """When new budget overlaps existing at the end, keep start and cut after.""" cat = Category(name="OverlapEnd") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # Existing budget covering Oct 1 - Nov 30 existing_budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, existing_budget) # New budget covering Oct 15 - Nov 30 (overlaps at end) new_budget = CategoryBudget( category=cat, amount_eur=120.00, valid_from=date(2026, 10, 15), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, new_budget) # Should have 2 budgets: # 1. Oct 1 - Oct 14 (remainder from old budget, before overlap start - 1 day) # 2. Oct 15 - Nov 30 (new budget) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 2 class TestSetCategoryBudgetMiddleOverlap: """Test overlap in the middle that splits existing budget.""" def test_complex_overlap_middle(self, db_session: Session): """Handle overlap in the middle that splits existing budget into two parts.""" cat = Category(name="SplitMiddle") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # Existing budget covering Oct 1 - Dec 31 existing_budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 12, 31), ) set_category_budget(db_session, existing_budget) # New budget covering Oct 15 - Nov 30 (overlaps in middle) new_budget = CategoryBudget( category=cat, amount_eur=150.00, valid_from=date(2026, 10, 15), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, new_budget) # Should have 3 budgets: # 1. Oct 1 - Oct 14 (before new range, original amount) # 2. Oct 15 - Nov 30 (new budget, new amount) # 3. Dec 1 - Dec 31 (after new range, original amount) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 3 class TestSetCategoryBudgetUnbounded: """Test budgets with unbounded dates.""" def test_unbounded_start(self, db_session: Session): """Budget with None valid_from (unbounded start).""" cat = Category(name="UnboundedStart") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=None, # Unbounded start valid_to=date(2026, 12, 31), ) set_category_budget(db_session, budget) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 1 assert budgets[0].valid_from is None assert budgets[0].valid_to == date(2026, 12, 31) def test_unbounded_end(self, db_session: Session): """Budget with None valid_to (unbounded end).""" cat = Category(name="UnboundedEnd") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=None, # Unbounded end ) set_category_budget(db_session, budget) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 1 assert budgets[0].valid_from == date(2026, 10, 1) assert budgets[0].valid_to is None def test_unbounded_both(self, db_session: Session): """Budget with both dates unbounded (None).""" cat = Category(name="UnboundedBoth") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=None, # Unbounded start valid_to=None, # Unbounded end ) set_category_budget(db_session, budget) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 1 assert budgets[0].valid_from is None assert budgets[0].valid_to is None class TestSetCategoryBudgetWithExistingUnbounded: """Test adding bounded budgets to categories with existing unbounded budgets.""" def test_add_bounded_after_unbounded(self, db_session: Session): """Add a bounded budget after an unbounded one (no overlap).""" cat = Category(name="AfterUnbounded") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # First, add unbounded budget unbounded_budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=None, valid_to=None, ) set_category_budget(db_session, unbounded_budget) # Add bounded budget after (Nov 1 - Dec 31) bounded_budget = CategoryBudget( category=cat, amount_eur=150.00, valid_from=date(2026, 11, 1), valid_to=date(2026, 12, 31), ) set_category_budget(db_session, bounded_budget) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 2 def test_add_bounded_before_unbounded(self, db_session: Session): """Add a bounded budget before an unbounded one (no overlap).""" cat = Category(name="BeforeUnbounded") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # First, add unbounded budget unbounded_budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=None, valid_to=None, ) set_category_budget(db_session, unbounded_budget) # Add bounded budget before (Sep 1 - Oct 31) bounded_budget = CategoryBudget( category=cat, amount_eur=75.00, valid_from=date(2026, 9, 1), valid_to=date(2026, 10, 31), ) set_category_budget(db_session, bounded_budget) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 2 class TestSetCategoryBudgetMultipleWithSameAmount: """Test multiple budgets with same amount and overlapping dates.""" def test_same_range_different_amount(self, db_session: Session): """When two budgets have same range but different amounts, only one should remain.""" cat = Category(name="SameRangeDifferentAmount") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # First budget budget1 = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, budget1) # Same range but different amount budget2 = CategoryBudget( category=cat, amount_eur=200.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, budget2) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 1 assert budgets[0].amount_eur == 200.00 class TestSetCategoryBudgetDateEdgeCases: """Test edge cases around date boundaries.""" def test_adjacent_budgets_no_overlap(self, db_session: Session): """Two budgets that are adjacent (end of one is start-1 of next) should both exist.""" cat = Category(name="Adjacent") cat_db = upsert_category(db_session, cat, do_commit=False) db_session.commit() # Budget 1: Oct 1 - Oct 31 budget1 = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 10, 31), ) set_category_budget(db_session, budget1) # Budget 2: Nov 1 - Nov 30 (adjacent to Oct 31) budget2 = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 11, 1), valid_to=date(2026, 11, 30), ) set_category_budget(db_session, budget2) budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) assert len(budgets) == 2 class TestFindCategoryDb: """Test find_category_db function.""" def test_find_simple(self, db_session: Session): """Find a simple category without parent.""" cat = Category(name="SimpleCategory") upsert_category(db_session, cat, do_commit=False) db_session.commit() result = find_category_db(db_session, cat) assert result is not None assert str(result.name) == "SimpleCategory" def test_find_with_parent_hierarchy(self, db_session: Session): """Find a category with parent hierarchy.""" grandfather = Category(name="grandfather") upsert_category(db_session, grandfather, do_commit=False) db_session.commit() father = Category(name="father", parent=grandfather) upsert_category(db_session, father, do_commit=False) db_session.commit() child = Category(name="child", parent=father) upsert_category(db_session, child, do_commit=False) db_session.commit() # Find the child via find_category_db result = find_category_db(db_session, child) assert result is not None assert str(result.parent.name) == "father" def test_find_nonexistent(self, db_session: Session): """Return None for non-existent category.""" # Create a category but don't add it to session (already in memory from fixtures) result = find_category_db(db_session, Category(name="NonExistent")) assert result is None class TestSetCategoryBudgetErrorHandling: """Test error handling in set_category_budget.""" def test_missing_category_raises_error(self, db_session: Session): """set_category_budget should raise ValueError for missing category.""" cat = Category(name="NonExistent") budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 10, 31), ) with pytest.raises(ValueError) as exc_info: set_category_budget(db_session, budget) assert "NonExistent" in str(exc_info.value) def test_missing_parent_raises_error(self, db_session: Session): """set_category_budget should raise ValueError for missing parent.""" cat = Category(name="ChildOfMissing") budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 10, 31), ) with pytest.raises(ValueError) as exc_info: set_category_budget(db_session, budget) # The error should mention that the category was not found (including its parent) assert "ChildOfMissing" in str(exc_info.value).lower() or \ "not found" in str(exc_info.value).lower() or \ "no matching" in str(exc_info.value).lower() class TestSetCategoryBudgetValidation: """Test CategoryBudget model validation rules.""" def test_valid_budget_no_dates(self): """Budget with None dates is valid.""" cat = Category.from_str("test") budget = CategoryBudget( category=cat, amount_eur=100.00, ) assert budget.valid_from is None assert budget.valid_to is None def test_valid_budget_with_dates(self): """Budget with dates is valid.""" cat = Category.from_str("test") budget = CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) assert budget.valid_from == date(2026, 10, 1) assert budget.valid_to == date(2026, 11, 30) def test_invalid_date_range_raises(self): """Budget with valid_from > valid_to should raise validation error.""" cat = Category.from_str("test") with pytest.raises(ValueError) as exc_info: CategoryBudget( category=cat, amount_eur=100.00, valid_from=date(2026, 11, 30), valid_to=date(2026, 10, 1), ) assert "valid_from" in str(exc_info.value) or "valid_to" in str(exc_info.value) def test_amount_must_be_positive(self): """Amount must be greater than zero.""" cat = Category.from_str("test") with pytest.raises(ValueError) as exc_info: CategoryBudget( category=cat, amount_eur=-100.00, valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) assert "greater than" in str(exc_info.value).lower()