merge main incl. conflict resolution
This commit is contained in:
+9
-1
@@ -87,13 +87,19 @@ def populated_db_b4w(
|
||||
Only the first-layer ingestion table is filled; no further processing.
|
||||
``**kwargs`` are forwarded to ``B4wExportFactory.build``; callable values
|
||||
are resolved per row (e.g. ``ValDt=lambda: faker.date_between(...)``).
|
||||
An optional ``setup`` callback runs after database creation and before rows
|
||||
are inserted.
|
||||
``Id1`` is reserved: it is set per row (``1..row_count``) and cannot be
|
||||
overridden via ``**kwargs``.
|
||||
The returned session is *not* closed by the fixture; close it in the test.
|
||||
"""
|
||||
|
||||
def populate_db_b4w(
|
||||
name: str, row_count: int = DEFAULT_B4W_ROWS, **kwargs: Any
|
||||
name: str,
|
||||
row_count: int = DEFAULT_B4W_ROWS,
|
||||
*,
|
||||
setup: Callable[[Session], None] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Session:
|
||||
if "Id1" in kwargs:
|
||||
raise ValueError(
|
||||
@@ -101,6 +107,8 @@ def populated_db_b4w(
|
||||
"cannot be overridden via **kwargs"
|
||||
)
|
||||
session = db_session_factory(name)
|
||||
if setup is not None:
|
||||
setup(session)
|
||||
for i in range(row_count):
|
||||
row_kwargs = {k: (v() if callable(v) else v) for k, v in kwargs.items()}
|
||||
row = B4wExportFactory.build(Id1=i + 1, **cast(dict[str, Any], row_kwargs))
|
||||
|
||||
+31
-1
@@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from faker import Faker
|
||||
@@ -6,10 +7,39 @@ from polyfactory.factories.pydantic_factory import ModelFactory
|
||||
from polyfactory.fields import Use
|
||||
|
||||
from src.models.b4w import B4wExport
|
||||
from src.models.category import Category
|
||||
from src.models.comdirect import ComdirectCreditCard, ComdirectDepot, ComdirectGiro
|
||||
from src.models.transactions import Transaction
|
||||
|
||||
DEFAULT_CATEGORY = "root_category:parent_category:leaf_category"
|
||||
CATEGORY_PATHS = tuple(
|
||||
line.strip()
|
||||
for line in (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "scripts/initial_load/categories/categories.txt"
|
||||
)
|
||||
.read_text(encoding="utf-8")
|
||||
.splitlines()
|
||||
if line.strip()
|
||||
)
|
||||
|
||||
|
||||
def _category_from_path(path: str) -> Category:
|
||||
category = Category.from_str(path)
|
||||
if category is None:
|
||||
raise ValueError("Category list contains an empty path")
|
||||
return category
|
||||
|
||||
|
||||
class CategoryFactory:
|
||||
__faker__ = Faker("de_DE")
|
||||
|
||||
@classmethod
|
||||
def build(cls) -> Category:
|
||||
return _category_from_path(cls.__faker__.random_element(CATEGORY_PATHS))
|
||||
|
||||
@staticmethod
|
||||
def build_all() -> list[Category]:
|
||||
return [_category_from_path(path) for path in CATEGORY_PATHS]
|
||||
|
||||
|
||||
def _faker_or_none(faker: Faker, non_default: Any, default: Any = None, p_value=0.5):
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.category import CategoryBudgetDb, CategoryDb
|
||||
from tests.factories import CategoryFactory
|
||||
|
||||
|
||||
def test_category_budget_schema(db_session: Session):
|
||||
inspector = inspect(db_session.get_bind())
|
||||
columns = {c["name"]: c for c in inspector.get_columns("category_budget")}
|
||||
|
||||
assert set(columns) == {
|
||||
"id",
|
||||
"category_id",
|
||||
"amount_eur",
|
||||
"valid_from",
|
||||
"valid_to",
|
||||
}
|
||||
assert columns["id"]["nullable"] is False
|
||||
assert columns["category_id"]["nullable"] is False
|
||||
assert columns["amount_eur"]["nullable"] is False
|
||||
assert columns["valid_from"]["nullable"] is True
|
||||
assert columns["valid_to"]["nullable"] is True
|
||||
assert str(columns["amount_eur"]["type"]) == "NUMERIC(12, 2)"
|
||||
|
||||
foreign_keys = inspector.get_foreign_keys("category_budget")
|
||||
assert len(foreign_keys) == 1
|
||||
assert foreign_keys[0]["constrained_columns"] == ["category_id"]
|
||||
assert foreign_keys[0]["referred_table"] == "category"
|
||||
|
||||
|
||||
def test_category_budget_defaults_null_dates(db_session: Session):
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
budget = CategoryBudgetDb(category_id=category.id, amount_eur=Decimal("150.00"))
|
||||
db_session.add(budget)
|
||||
db_session.commit()
|
||||
db_session.refresh(budget)
|
||||
|
||||
assert budget.id is not None
|
||||
assert budget.category_id == category.id
|
||||
assert budget.amount_eur == Decimal("150.00")
|
||||
# Null means unbounded in either direction.
|
||||
assert budget.valid_from is None
|
||||
assert budget.valid_to is None
|
||||
|
||||
|
||||
def test_category_budget_date_window(db_session: Session):
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
budget = CategoryBudgetDb(
|
||||
category_id=category.id,
|
||||
amount_eur=Decimal("200.50"),
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
)
|
||||
db_session.add(budget)
|
||||
db_session.commit()
|
||||
db_session.refresh(budget)
|
||||
|
||||
assert budget.valid_from == date(2026, 10, 1)
|
||||
assert budget.valid_to == date(2026, 10, 31)
|
||||
|
||||
|
||||
def test_category_budget_relationship_both_ways(db_session: Session):
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
budget = CategoryBudgetDb(category_id=category.id, amount_eur=Decimal("1.00"))
|
||||
db_session.add(budget)
|
||||
db_session.commit()
|
||||
|
||||
# Force re-read from the database so the relationship is loaded, not
|
||||
# just resolved from the session identity map.
|
||||
db_session.expire_all()
|
||||
assert [b.id for b in category.budgets] == [budget.id]
|
||||
assert budget.category.id == category.id
|
||||
|
||||
|
||||
def test_category_has_multiple_budgets(db_session: Session):
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
budgets = [
|
||||
CategoryBudgetDb(
|
||||
category_id=category.id,
|
||||
amount_eur=Decimal("1200.00"),
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
),
|
||||
CategoryBudgetDb(
|
||||
category_id=category.id,
|
||||
amount_eur=Decimal("1300.00"),
|
||||
valid_from=date(2026, 11, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
),
|
||||
]
|
||||
for budget in budgets:
|
||||
db_session.add(budget)
|
||||
db_session.commit()
|
||||
db_session.expire_all()
|
||||
|
||||
assert [b.id for b in category.budgets] == [b.id for b in budgets]
|
||||
@@ -0,0 +1,153 @@
|
||||
from datetime import date
|
||||
|
||||
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 hasattr(c, "hash_id")
|
||||
assert isinstance(c.hash_id, str)
|
||||
# Hash should be deterministic for same inputs
|
||||
c2 = Category.from_str("single")
|
||||
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 hasattr(c1, "hash_id")
|
||||
|
||||
c2 = Category.from_str("level1:level2:level3")
|
||||
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")
|
||||
# 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 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.to_str() == "single"
|
||||
|
||||
def test_to_str_nested(self):
|
||||
"""Test to_str() for nested category."""
|
||||
c = Category.from_str("parent:child:grandchild")
|
||||
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 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=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=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=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=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=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=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
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests for db_utils.py update path in upsert function.
|
||||
|
||||
The upsert function has two paths:
|
||||
1. Row exists -> either return existing (do_update=False) or update it (do_update=True)
|
||||
2. Row doesn't exist -> insert new row
|
||||
|
||||
These tests cover the update path (lines 25-26).
|
||||
|
||||
NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db_utils import upsert
|
||||
|
||||
|
||||
class TestUpsertUpdatePath:
|
||||
"""Tests for the update path in db_utils.upsert."""
|
||||
|
||||
def test_upsert_updates_existing_row(self, db_session: Session):
|
||||
"""Test that upsert updates an existing row when do_update=True."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="initial", value="old_value")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Verify it was inserted
|
||||
result = db_session.query(TestModel).filter_by(name="initial").one()
|
||||
assert result.value == "old_value"
|
||||
|
||||
def test_upsert_skips_update_when_do_update_false(self, db_session: Session):
|
||||
"""Test that upsert doesn't modify existing row when do_update=False."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_2"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="skip_update", value="original")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Upsert with do_update=False should not modify existing row
|
||||
same_row = TestModel(name="skip_update", value="should_not_change")
|
||||
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "skip_update"},
|
||||
same_row.__dict__,
|
||||
do_update=False,
|
||||
do_commit=True,
|
||||
)
|
||||
|
||||
# The row should still have original values
|
||||
assert result.value == "original"
|
||||
|
||||
def test_upsert_updates_and_returns_existing(self, db_session: Session):
|
||||
"""Test that upsert returns the existing (now updated) row when do_update=True."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_3"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="update_test", value="old")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Upsert with do_update=True should update and return the same object
|
||||
new_values = TestModel(name="update_test", value="new_value")
|
||||
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "update_test"},
|
||||
new_values.__dict__,
|
||||
do_update=True,
|
||||
do_commit=True,
|
||||
)
|
||||
|
||||
# Result should be the same row (not a new one)
|
||||
assert result is not None
|
||||
# Should have updated values
|
||||
assert result.value == "new_value"
|
||||
|
||||
def test_upsert_returns_updated_row_by_value_when_not_same_object(
|
||||
self, db_session: Session
|
||||
):
|
||||
"""Test update path when the returned row is same by identity (session cache)."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_4"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="identity_test", value="initial")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Get the row from DB again (fresh load)
|
||||
before_update = (
|
||||
db_session.query(TestModel).filter_by(name="identity_test").one()
|
||||
)
|
||||
|
||||
# Now upsert should return same object due to session cache + identity map
|
||||
new_values = TestModel(name="identity_test", value="updated")
|
||||
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "identity_test"},
|
||||
new_values.__dict__,
|
||||
do_update=True,
|
||||
do_commit=False,
|
||||
) # don't commit yet
|
||||
|
||||
# Should be same identity due to session cache
|
||||
assert result is before_update
|
||||
assert result.value == "updated"
|
||||
|
||||
|
||||
class TestUpsertUpdateEdgeCases:
|
||||
"""Test edge cases for the update path."""
|
||||
|
||||
def test_upsert_partial_update(self, db_session: Session):
|
||||
"""Test that only specified fields are updated."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_5"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
extra: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row with all fields
|
||||
initial = TestModel(
|
||||
name="partial", value="original_value", extra="extra_original"
|
||||
)
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Create new values with only some fields set (note: we skip 'extra' field)
|
||||
partial_update = TestModel(name="partial", value="new_value", extra=None)
|
||||
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "partial"},
|
||||
partial_update.__dict__,
|
||||
do_update=True,
|
||||
do_commit=True,
|
||||
)
|
||||
|
||||
# value should be updated since it was in the dict
|
||||
assert result.value == "new_value"
|
||||
# extra field handling depends on what's in the __dict__
|
||||
|
||||
|
||||
class TestUpsertUpdateEmptyDict:
|
||||
"""Test edge case of empty update dictionary."""
|
||||
|
||||
def test_upsert_with_empty_update_dict(self, db_session: Session):
|
||||
"""Test upsert with empty parameters_new dict - should still work if existing found."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_6"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="empty_dict", value="test")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Try to upsert with empty new parameters (edge case)
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "empty_dict"},
|
||||
{},
|
||||
do_update=True,
|
||||
do_commit=False,
|
||||
)
|
||||
|
||||
# Should return the existing row unchanged
|
||||
assert result is not None
|
||||
assert result.name == "empty_dict"
|
||||
@@ -0,0 +1,709 @@
|
||||
"""Comprehensive tests for set_category_budget overlap handling."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for extract_categories.py - extracting unique categories from b4w CSV exports."""
|
||||
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from scripts.initial_load.categories.extract_categories import extract_unique_categories
|
||||
|
||||
|
||||
def write_csv(path: Path, categories: list[str]) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["Id", "AcctId", "Category"])
|
||||
writer.writeheader()
|
||||
for i, category in enumerate(categories, 1):
|
||||
writer.writerow({"Id": str(i), "AcctId": "587713594", "Category": category})
|
||||
|
||||
|
||||
def read_lines(path: Path) -> list[str]:
|
||||
return path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_extract_unique_categories_basic(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen"])
|
||||
|
||||
result = extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert result == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]
|
||||
assert read_lines(output_file) == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]
|
||||
|
||||
|
||||
def test_extract_unique_categories_no_duplicates(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(
|
||||
csv_file,
|
||||
["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Lebenshaltung"],
|
||||
)
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert len(read_lines(output_file)) == 2
|
||||
|
||||
|
||||
def test_extract_unique_categories_output_format(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(
|
||||
csv_file,
|
||||
["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Privat"],
|
||||
)
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
lines = read_lines(output_file)
|
||||
assert len(lines) == 3
|
||||
# Plain list: no numbering prefix, no blank lines.
|
||||
assert all(line == line.strip() and not line[0].isdigit() for line in lines)
|
||||
|
||||
|
||||
def test_extract_unique_categories_german_special_chars(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(
|
||||
csv_file,
|
||||
["Girokonto:Freizeit:Urlaub&Ausflüge", "Girokonto:Privat:Mitgliedsbeiträge"],
|
||||
)
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert read_lines(output_file) == [
|
||||
"Girokonto:Freizeit:Urlaub&Ausflüge",
|
||||
"Girokonto:Privat:Mitgliedsbeiträge",
|
||||
]
|
||||
|
||||
|
||||
def test_extract_unique_categories_handles_empty_csv(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
csv_file.write_text("Id,AcctId,Category\n", encoding="utf-8")
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert output_file.exists()
|
||||
assert read_lines(output_file) == []
|
||||
|
||||
|
||||
def test_extract_unique_categories_handles_csv_with_all_empty_categories(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
csv_file.write_text(
|
||||
"Id,AcctId,Category\n1,587713594,\n2,587713594,\n3,587713594,\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert read_lines(output_file) == []
|
||||
|
||||
|
||||
def test_extract_unique_categories_rerun_is_idempotent(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen"])
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
first_run = read_lines(output_file)
|
||||
|
||||
# A new category appears in the CSV on the second run.
|
||||
write_csv(
|
||||
csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Privat"]
|
||||
)
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
second_run = read_lines(output_file)
|
||||
|
||||
# Existing entries are merged, not duplicated; list stays sorted and plain.
|
||||
assert second_run == [
|
||||
"Girokonto:Lebenshaltung",
|
||||
"Girokonto:Privat",
|
||||
"Girokonto:Sparen",
|
||||
]
|
||||
assert first_run == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]
|
||||
@@ -1,56 +1,93 @@
|
||||
from src.models import Category
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.category import Category, CategoryDb, upsert_category
|
||||
|
||||
|
||||
def test_single_level_category():
|
||||
c = Category.from_str("single")
|
||||
assert c
|
||||
assert c.name == "single"
|
||||
assert c.parent is None
|
||||
|
||||
|
||||
def test_two_level_category():
|
||||
c = Category.from_str("first:second")
|
||||
assert c
|
||||
assert c.name == "second"
|
||||
assert isinstance(c.parent, Category)
|
||||
assert c.parent.name == "first"
|
||||
assert c.parent.parent is None
|
||||
|
||||
|
||||
def test_three_level_category():
|
||||
c = Category.from_str("first:second:third")
|
||||
assert c
|
||||
assert c.name == "third"
|
||||
assert isinstance(c.parent, Category)
|
||||
assert c.parent.name == "second"
|
||||
assert isinstance(c.parent.parent, Category)
|
||||
assert c.parent.parent.name == "first"
|
||||
assert c.parent.parent.parent is None
|
||||
|
||||
|
||||
def test_single_level_category_to_string():
|
||||
c = Category.from_str("single")
|
||||
assert c
|
||||
assert str(c) == "single"
|
||||
|
||||
|
||||
def test_two_level_category_to_string():
|
||||
c = Category(name="second", parent=Category(name="first"))
|
||||
assert c.to_str() == "first:second"
|
||||
|
||||
|
||||
def test_three_level_category_to_string():
|
||||
c = Category(
|
||||
name="third", parent=Category(name="second", parent=Category(name="first"))
|
||||
def test_upsert_category_returns_orm_object(db_session: Session):
|
||||
result = upsert_category(
|
||||
db_session,
|
||||
Category.from_str("dummy_parent_1:dummy_parent_2:dummy"),
|
||||
do_commit=False,
|
||||
)
|
||||
assert c.to_str() == "first:second:third"
|
||||
assert result
|
||||
assert isinstance(result, CategoryDb)
|
||||
assert str(result.name) == "dummy"
|
||||
assert str(result.parent.name) == "dummy_parent_2"
|
||||
assert str(result.parent.parent.name) == "dummy_parent_1"
|
||||
|
||||
|
||||
def test_category_str():
|
||||
c = Category.from_str("first:second:third")
|
||||
assert str(c) == "first:second:third"
|
||||
def test_upsert_category_writes_new_to_db(db_session: Session):
|
||||
upsert_category(
|
||||
db_session,
|
||||
Category.from_str("grand_father:father:new_category"),
|
||||
do_commit=True,
|
||||
)
|
||||
result = db_session.execute(
|
||||
select(CategoryDb).filter_by(name="new_category")
|
||||
).scalar_one()
|
||||
assert result
|
||||
assert str(result.name) == "new_category"
|
||||
assert str(result.parent.name) == "father"
|
||||
assert str(result.parent.parent.name) == "grand_father"
|
||||
|
||||
|
||||
def test_category_repr():
|
||||
c = Category.from_str("first:second:third")
|
||||
assert repr(c) == 'Category.from_str("first:second:third")'
|
||||
def test_upsert_category_skips_existing(db_session: Session):
|
||||
for _ in range(3):
|
||||
upsert_category(
|
||||
db_session,
|
||||
Category.from_str("grand_father:father:try_again_and_again"),
|
||||
do_commit=True,
|
||||
)
|
||||
result = db_session.execute(
|
||||
select(CategoryDb).filter_by(name="try_again_and_again")
|
||||
).all()
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_category_hash_id_computed_field():
|
||||
"""Test hash_id computed field is present and deterministic."""
|
||||
c1 = Category.from_str("single")
|
||||
c2 = Category.from_str("single")
|
||||
assert hasattr(c1, "hash_id")
|
||||
assert c1.hash_id == c2.hash_id
|
||||
|
||||
|
||||
def test_category_hash_id_different_inputs():
|
||||
"""Test that different category hierarchies have different hash_ids."""
|
||||
c1 = Category.from_str("A:B")
|
||||
c2 = Category.from_str("A:C")
|
||||
# Different last level -> different hash_id
|
||||
assert c1.hash_id != c2.hash_id
|
||||
|
||||
|
||||
def test_category_to_str_single_level():
|
||||
"""Test to_str() for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
assert c.to_str() == "single"
|
||||
|
||||
|
||||
def test_category_to_str_nested():
|
||||
"""Test to_str() for nested category."""
|
||||
c = Category.from_str("parent:child:grandchild")
|
||||
assert c.to_str() == "parent:child:grandchild"
|
||||
|
||||
|
||||
def test_category_str_delegates_to_to_str():
|
||||
"""Test that __str__ delegates to to_str()."""
|
||||
c = Category.from_str("A:B:C")
|
||||
assert str(c) == c.to_str() == "A:B:C"
|
||||
|
||||
|
||||
def test_category_repr_simple():
|
||||
"""Test repr() for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
expected = 'Category.from_str("single")'
|
||||
assert repr(c) == expected
|
||||
|
||||
|
||||
def test_category_repr_nested():
|
||||
"""Test repr() for nested category."""
|
||||
c = Category.from_str("parent:child")
|
||||
expected = 'Category.from_str("parent:child")'
|
||||
assert repr(c) == expected
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for common model utilities."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from src.models.common import ExportBaseModel, Sign, date2str
|
||||
|
||||
|
||||
class TestSignEnum:
|
||||
"""Test Sign enum from common module."""
|
||||
|
||||
def test_sign_str(self):
|
||||
"""Test Sign.__str__ method."""
|
||||
assert str(Sign.DEBIT) == "DBIT"
|
||||
assert str(Sign.CREDIT) == "CRDT"
|
||||
|
||||
def test_sign_repr(self):
|
||||
"""Test Sign.__repr__ method (line 24)."""
|
||||
repr_debit = repr(Sign.DEBIT)
|
||||
assert repr_debit == "Sign.DEBIT"
|
||||
|
||||
repr_credit = repr(Sign.CREDIT)
|
||||
assert repr_credit == "Sign.CREDIT"
|
||||
|
||||
def test_sign_to_sign(self):
|
||||
"""Test Sign.to_sign method."""
|
||||
assert Sign.DEBIT.to_sign() == -1
|
||||
assert Sign.CREDIT.to_sign() == 1
|
||||
|
||||
|
||||
class TestDate2Str:
|
||||
"""Test date2str utility function from common module."""
|
||||
|
||||
def test_with_date(self):
|
||||
"""date2str with a valid date."""
|
||||
test_date = date(2026, 10, 15)
|
||||
result = date2str(test_date)
|
||||
assert result == "2026-10-15"
|
||||
|
||||
def test_with_none_default(self):
|
||||
"""date2str with None value (line 40)."""
|
||||
result = date2str(None)
|
||||
assert result == "--"
|
||||
|
||||
def test_with_none_custom_default(self):
|
||||
"""date2str with None and custom default."""
|
||||
result = date2str(None, default="N/A")
|
||||
assert result == "N/A"
|
||||
|
||||
def test_with_date_custom_format(self):
|
||||
"""date2str with custom format."""
|
||||
test_date = date(2026, 10, 15)
|
||||
result = date2str(test_date, date_format="%d.%m.%Y")
|
||||
assert result == "15.10.2026"
|
||||
|
||||
def test_with_none_custom_default_and_format(self):
|
||||
"""date2str with None, custom default and format."""
|
||||
result = date2str(None, default="--", date_format="%d.%m.%Y")
|
||||
assert result == "--"
|
||||
|
||||
|
||||
class TestExportBaseModel:
|
||||
"""Test ExportBaseModel methods from common module."""
|
||||
|
||||
def test_parameters_to_dict_empty(self):
|
||||
"""parameters_to_dict returns empty dict (line 70)."""
|
||||
|
||||
class TestModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
model = TestModel()
|
||||
result = model.parameters_to_dict()
|
||||
assert result == {}
|
||||
|
||||
def test_parameters_to_str_no_params(self):
|
||||
"""parameters_to_str with no parameters."""
|
||||
|
||||
class TestModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
model = TestModel()
|
||||
result = model.parameters_to_str()
|
||||
assert result == ""
|
||||
|
||||
def test_repr_empty_params(self):
|
||||
"""__repr__ with empty parameters (line 79)."""
|
||||
|
||||
class TestModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
model = TestModel()
|
||||
|
||||
# __repr__ should return string representation with empty params
|
||||
repr_result = repr(model)
|
||||
assert "TestModel" in repr_result
|
||||
|
||||
def test_str_empty_params(self):
|
||||
"""__str__ with empty parameters."""
|
||||
|
||||
class TestModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
model = TestModel()
|
||||
|
||||
# __str__ should return string representation
|
||||
str_result = str(model)
|
||||
assert "TestModel" in str_result
|
||||
|
||||
def test_repr_with_params(self):
|
||||
"""Test repr with actual parameters."""
|
||||
|
||||
class ConfigurableModel(ExportBaseModel):
|
||||
def parameters_to_dict(self) -> dict[str, str]:
|
||||
return {"key": "value"}
|
||||
|
||||
model = ConfigurableModel()
|
||||
|
||||
# Test that repr works correctly with params
|
||||
repr_result = repr(model)
|
||||
assert "key" in repr_result or "value" in repr_result
|
||||
|
||||
def test_str_with_params(self):
|
||||
"""Test str with actual parameters."""
|
||||
|
||||
class ConfigurableModel(ExportBaseModel):
|
||||
def parameters_to_dict(self) -> dict[str, str]:
|
||||
return {"name": "test"}
|
||||
|
||||
model = ConfigurableModel()
|
||||
|
||||
# Test that str works correctly with params
|
||||
str_result = str(model)
|
||||
assert "name" in str_result or "test" in str_result
|
||||
|
||||
|
||||
class TestMyNameMethod:
|
||||
"""Test ExportBaseModel._my_name classmethod."""
|
||||
|
||||
def test_my_name_returns_classname(self):
|
||||
"""_my_name should return the class name."""
|
||||
|
||||
class SubModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
result = SubModel._my_name()
|
||||
assert result == "SubModel"
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for src.models.__init__.py lazy loading and __dir__."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import models
|
||||
|
||||
|
||||
class TestLazyLoading:
|
||||
"""Tests for lazy attribute loading via __getattr__."""
|
||||
|
||||
def test_load_b4wexport(self):
|
||||
"""Test loading B4wExport model."""
|
||||
from src.models.b4w import B4wExport as DirectB4wExport
|
||||
|
||||
b4w = models.B4wExport
|
||||
assert b4w is DirectB4wExport
|
||||
|
||||
def test_load_category(self):
|
||||
"""Test loading Category model."""
|
||||
from src.models.category import Category as DirectCategory
|
||||
|
||||
cat = models.Category
|
||||
assert cat is DirectCategory
|
||||
|
||||
def test_load_categorybudgetdb(self):
|
||||
"""Test loading CategoryBudgetDb model."""
|
||||
from src.models.category import CategoryBudgetDb as DirectCategoryBudgetDb
|
||||
|
||||
cbdb = models.CategoryBudgetDb
|
||||
assert cbdb is DirectCategoryBudgetDb
|
||||
|
||||
def test_load_comdirectgiro(self):
|
||||
"""Test loading ComdirectGiro model."""
|
||||
from src.models.comdirect import ComdirectGiro as DirectComdirectGiro
|
||||
|
||||
giro = models.ComdirectGiro
|
||||
assert giro is DirectComdirectGiro
|
||||
|
||||
def test_load_transaction(self):
|
||||
"""Test loading Transaction model."""
|
||||
from src.models.transactions import Transaction as DirectTransaction
|
||||
|
||||
tx = models.Transaction
|
||||
assert tx is DirectTransaction
|
||||
|
||||
def test_load_account(self):
|
||||
"""Test loading Account model."""
|
||||
from src.models.settings import Account as DirectAccount
|
||||
|
||||
acc = models.Account
|
||||
assert acc is DirectAccount
|
||||
|
||||
def test_load_source(self):
|
||||
"""Test loading Source model."""
|
||||
from src.models.settings import Source as DirectSource
|
||||
|
||||
src = models.Source
|
||||
assert src is DirectSource
|
||||
|
||||
def test_nonexistent_attribute_raises(self):
|
||||
"""Test that non-existent attributes raise AttributeError."""
|
||||
with pytest.raises(AttributeError) as exc_info:
|
||||
# This is intentional: testing that accessing a non-existent attribute raises
|
||||
_ = models.NonExistentModel # type: ignore[name-defined]
|
||||
|
||||
expected_msg = "module 'src.models' has no attribute 'NonExistentModel'"
|
||||
assert expected_msg in str(exc_info.value)
|
||||
|
||||
|
||||
class TestDirMethod:
|
||||
"""Tests for __dir__() method."""
|
||||
|
||||
def test_dir_returns_sorted_list(self):
|
||||
"""Test that __dir__() returns a sorted list."""
|
||||
result = models.__dir__()
|
||||
assert isinstance(result, list)
|
||||
# Should be alphabetically sorted
|
||||
assert result == sorted(result)
|
||||
|
||||
def test_dir_includes_all_exports(self):
|
||||
"""Test that __dir__() includes all exported models."""
|
||||
result = models.__dir__()
|
||||
|
||||
expected_exports = {
|
||||
"B4wExport",
|
||||
"Category",
|
||||
"CategoryBudgetDb",
|
||||
"ComdirectCreditCard",
|
||||
"ComdirectDepot",
|
||||
"ComdirectGiro",
|
||||
"Account",
|
||||
"Source",
|
||||
"Transaction",
|
||||
"TransactionDb",
|
||||
}
|
||||
|
||||
# All exports should be in dir()
|
||||
for export_name in expected_exports:
|
||||
assert export_name in result
|
||||
|
||||
def test_dir_includes_globals(self):
|
||||
"""Test that __dir__() includes module-level globals."""
|
||||
result = models.__dir__()
|
||||
assert "_EXPORTS" in result
|
||||
|
||||
def test_dir_is_alphabetically_sorted(self):
|
||||
"""Verify the list is truly sorted alphabetically."""
|
||||
result = models.__dir__()
|
||||
# Double-check: if we sort it again, it should be identical
|
||||
assert result == sorted(result)
|
||||
@@ -1,8 +1,17 @@
|
||||
from datetime import date
|
||||
|
||||
import faker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.b4w import B4wDb
|
||||
from src.models.category import CategoryDb, upsert_category
|
||||
from tests.factories import CategoryFactory
|
||||
|
||||
|
||||
def _load_initial_categories(session: Session) -> None:
|
||||
for category in CategoryFactory.build_all():
|
||||
upsert_category(session, category, do_commit=False)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_b4w_populate(populated_db_b4w):
|
||||
@@ -10,10 +19,12 @@ def test_b4w_populate(populated_db_b4w):
|
||||
session = populated_db_b4w(
|
||||
"b4w_basic",
|
||||
100,
|
||||
setup=_load_initial_categories,
|
||||
ValDt=lambda: faker.Faker().date_between(val_dt_min, val_dt_max),
|
||||
)
|
||||
assert session
|
||||
assert session.query(B4wDb).count() == 100
|
||||
assert session.query(CategoryDb).count() >= len(CategoryFactory.build_all())
|
||||
# ``ValDt`` was given as a per-row callable: it must have been resolved
|
||||
# and persisted for every row.
|
||||
dates = [d for (d,) in session.query(B4wDb.date_valid).all()]
|
||||
|
||||
@@ -15,7 +15,7 @@ from src.ingest.transactions import (
|
||||
from src.models.b4w import B4wDb
|
||||
from src.models.comdirect import ComdirectGiroDb
|
||||
from src.models.transactions import Transaction, TransactionDb
|
||||
from tests.factories import B4wExportFactory
|
||||
from tests.factories import B4wExportFactory, CategoryFactory
|
||||
|
||||
B4W_PATH = Path("tests/test_data/b4w_export_dummy_01.csv")
|
||||
COMDIRECT_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt")
|
||||
@@ -108,7 +108,7 @@ def test_consolidate_b4w_signed_amount_and_category(db_session: Session):
|
||||
export = B4wExportFactory.build(
|
||||
Amt="50.00",
|
||||
RmtInf="Supermarket",
|
||||
Category="Lebenshaltung:Lebensmittel",
|
||||
Category=CategoryFactory.build().to_str(),
|
||||
CdtDbtInd="DBIT",
|
||||
)
|
||||
staging = upsert_b4w(db_session, export, do_commit=False)
|
||||
|
||||
Reference in New Issue
Block a user