737 lines
25 KiB
Python
737 lines
25 KiB
Python
"""Comprehensive tests for set_category_budget overlap handling."""
|
|
|
|
from datetime import UTC, date, datetime
|
|
|
|
import pytest
|
|
from sqlalchemy.orm import Session
|
|
|
|
from src.domain_utils import find_category_db, set_category_budget
|
|
from src.models import Category, CategoryBudget, CategoryBudgetDb
|
|
from src.models.category import CategoryDb, upsert_category
|
|
from tests.factories import CategoryFactory
|
|
|
|
# NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes.
|
|
|
|
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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
|
|
|
|
def test_shared_endpoint_is_replaced(self, db_session: Session):
|
|
"""Inclusive ranges that share a date must not overlap after replacement."""
|
|
cat = CategoryFactory.build()
|
|
cat_db = upsert_category(db_session, cat, do_commit=False)
|
|
db_session.commit()
|
|
|
|
set_category_budget(
|
|
db_session,
|
|
CategoryBudget(
|
|
category=cat,
|
|
amount_eur=100.00,
|
|
valid_from=date(2026, 10, 1),
|
|
valid_to=date(2026, 10, 15),
|
|
),
|
|
)
|
|
set_category_budget(
|
|
db_session,
|
|
CategoryBudget(
|
|
category=cat,
|
|
amount_eur=200.00,
|
|
valid_from=date(2026, 10, 15),
|
|
valid_to=date(2026, 10, 31),
|
|
),
|
|
)
|
|
|
|
budgets = (
|
|
db_session.query(CategoryBudgetDb)
|
|
.filter_by(category_id=cat_db.id)
|
|
.order_by(CategoryBudgetDb.valid_from)
|
|
.all()
|
|
)
|
|
assert [(b.amount_eur, b.valid_from, b.valid_to) for b in budgets] == [
|
|
(100.00, date(2026, 10, 1), date(2026, 10, 14)),
|
|
(200.00, date(2026, 10, 15), date(2026, 10, 31)),
|
|
]
|
|
|
|
|
|
class TestSetCategoryBudgetRegressionCases:
|
|
def test_new_range_overlapping_multiple_budgets_is_added_once(
|
|
self, db_session: Session
|
|
):
|
|
cat = CategoryFactory.build()
|
|
cat_db = upsert_category(db_session, cat, do_commit=False)
|
|
db_session.commit()
|
|
|
|
for start, end, amount in (
|
|
(date(2026, 10, 1), date(2026, 10, 31), 100.00),
|
|
(date(2026, 12, 1), date(2026, 12, 31), 200.00),
|
|
):
|
|
set_category_budget(
|
|
db_session,
|
|
CategoryBudget(
|
|
category=cat,
|
|
amount_eur=amount,
|
|
valid_from=start,
|
|
valid_to=end,
|
|
),
|
|
)
|
|
|
|
set_category_budget(
|
|
db_session,
|
|
CategoryBudget(
|
|
category=cat,
|
|
amount_eur=300.00,
|
|
valid_from=date(2026, 10, 1),
|
|
valid_to=date(2026, 12, 31),
|
|
),
|
|
)
|
|
|
|
budgets = (
|
|
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id).all()
|
|
)
|
|
assert len(budgets) == 1
|
|
assert budgets[0].amount_eur == 300.00
|
|
assert budgets[0].valid_from == date(2026, 10, 1)
|
|
assert budgets[0].valid_to == date(2026, 12, 31)
|
|
|
|
@pytest.mark.parametrize(
|
|
("existing_start", "existing_end", "new_start", "new_end", "expected"),
|
|
[
|
|
(
|
|
date(2026, 10, 1),
|
|
date(2026, 12, 31),
|
|
None,
|
|
date(2026, 11, 15),
|
|
[
|
|
(100.00, date(2026, 11, 16), date(2026, 12, 31)),
|
|
(200.00, None, date(2026, 11, 15)),
|
|
],
|
|
),
|
|
(
|
|
date(2026, 10, 1),
|
|
date(2026, 12, 31),
|
|
date(2026, 11, 15),
|
|
None,
|
|
[
|
|
(100.00, date(2026, 10, 1), date(2026, 11, 14)),
|
|
(200.00, date(2026, 11, 15), None),
|
|
],
|
|
),
|
|
(
|
|
None,
|
|
date(2026, 12, 31),
|
|
date(2026, 10, 1),
|
|
date(2026, 10, 31),
|
|
[
|
|
(100.00, None, date(2026, 9, 30)),
|
|
(200.00, date(2026, 10, 1), date(2026, 10, 31)),
|
|
(100.00, date(2026, 11, 1), date(2026, 12, 31)),
|
|
],
|
|
),
|
|
(
|
|
date(2026, 10, 1),
|
|
None,
|
|
date(2026, 11, 1),
|
|
date(2026, 11, 30),
|
|
[
|
|
(100.00, date(2026, 10, 1), date(2026, 10, 31)),
|
|
(200.00, date(2026, 11, 1), date(2026, 11, 30)),
|
|
(100.00, date(2026, 12, 1), None),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
def test_partially_unbounded_ranges_are_split_correctly(
|
|
self,
|
|
db_session: Session,
|
|
existing_start: date | None,
|
|
existing_end: date | None,
|
|
new_start: date | None,
|
|
new_end: date | None,
|
|
expected: list[tuple[float, date | None, date | None]],
|
|
):
|
|
cat = CategoryFactory.build()
|
|
cat_db = upsert_category(db_session, cat, do_commit=False)
|
|
db_session.commit()
|
|
|
|
set_category_budget(
|
|
db_session,
|
|
CategoryBudget(
|
|
category=cat,
|
|
amount_eur=100.00,
|
|
valid_from=existing_start,
|
|
valid_to=existing_end,
|
|
),
|
|
)
|
|
set_category_budget(
|
|
db_session,
|
|
CategoryBudget(
|
|
category=cat,
|
|
amount_eur=200.00,
|
|
valid_from=new_start,
|
|
valid_to=new_end,
|
|
),
|
|
)
|
|
|
|
budgets = (
|
|
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id).all()
|
|
)
|
|
actual = {(float(b.amount_eur), b.valid_from, b.valid_to) for b in budgets}
|
|
assert actual == set(expected)
|
|
|
|
|
|
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
|
|
|
|
def test_find_reintroduced_category_returns_active_version(
|
|
self, db_session: Session
|
|
):
|
|
category = Category(name="Reintroduced")
|
|
historical = CategoryDb(
|
|
name=category.name,
|
|
valid_to=datetime(2026, 1, 1, tzinfo=UTC),
|
|
last_action="retired",
|
|
)
|
|
db_session.add(historical)
|
|
db_session.commit()
|
|
|
|
active = upsert_category(db_session, category, do_commit=False)
|
|
assert active is not None
|
|
db_session.commit()
|
|
|
|
result = find_category_db(db_session, category)
|
|
assert result is not None
|
|
assert result.id == active.id
|
|
|
|
set_category_budget(
|
|
db_session,
|
|
CategoryBudget(category=category, amount_eur=100.00),
|
|
)
|
|
budget = db_session.query(CategoryBudgetDb).one()
|
|
assert budget.category_id == active.id
|
|
|
|
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
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 = CategoryFactory.build()
|
|
|
|
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 = CategoryFactory.build()
|
|
|
|
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()
|