Category overhaul #20
+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))
|
||||
|
||||
+29
-1
@@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from faker import Faker
|
||||
@@ -6,10 +7,37 @@ 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):
|
||||
|
||||
@@ -5,6 +5,7 @@ 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):
|
||||
@@ -32,7 +33,7 @@ def test_category_budget_schema(db_session: Session):
|
||||
|
||||
|
||||
def test_category_budget_defaults_null_dates(db_session: Session):
|
||||
category = CategoryDb(name="Lebenshaltung")
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
@@ -50,7 +51,7 @@ def test_category_budget_defaults_null_dates(db_session: Session):
|
||||
|
||||
|
||||
def test_category_budget_date_window(db_session: Session):
|
||||
category = CategoryDb(name="Freizeit")
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
@@ -69,7 +70,7 @@ def test_category_budget_date_window(db_session: Session):
|
||||
|
||||
|
||||
def test_category_budget_relationship_both_ways(db_session: Session):
|
||||
category = CategoryDb(name="Beruf")
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
@@ -85,7 +86,7 @@ def test_category_budget_relationship_both_ways(db_session: Session):
|
||||
|
||||
|
||||
def test_category_has_multiple_budgets(db_session: Session):
|
||||
category = CategoryDb(name="Wohnen")
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import date
|
||||
import pytest
|
||||
|
||||
from src.models.category import Category, CategoryBudget
|
||||
from tests.factories import CategoryFactory
|
||||
|
||||
|
||||
class TestCategoryComputedFields:
|
||||
@@ -75,7 +76,7 @@ class TestCategoryBudgetValidation:
|
||||
|
||||
def test_valid_budget_no_dates(self):
|
||||
"""Budget with None dates is valid."""
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
@@ -86,7 +87,7 @@ class TestCategoryBudgetValidation:
|
||||
def test_valid_budget_with_dates(self):
|
||||
"""Budget with valid date range is accepted."""
|
||||
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
@@ -99,7 +100,7 @@ class TestCategoryBudgetValidation:
|
||||
def test_invalid_date_range_raises(self):
|
||||
"""Budget with valid_from > valid_to raises ValueError."""
|
||||
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CategoryBudget(
|
||||
@@ -116,7 +117,7 @@ class TestCategoryBudgetValidation:
|
||||
def test_valid_budget_exact_boundary(self):
|
||||
"""Budget with exact same from and to is valid (single day)."""
|
||||
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
@@ -129,7 +130,7 @@ class TestCategoryBudgetValidation:
|
||||
def test_validation_preserves_original_values(self):
|
||||
"""Validation doesn't change the model's original values."""
|
||||
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=150.75,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.
|
||||
|
||||
@@ -17,7 +18,7 @@ class TestSetCategoryBudgetNoOverlap:
|
||||
|
||||
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 = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -46,7 +47,7 @@ class TestSetCategoryBudgetNoOverlap:
|
||||
|
||||
def test_completely_before_no_overlap(self, db_session: Session):
|
||||
"""New budget completely before existing - should add both."""
|
||||
cat = Category(name="BeforeExisting")
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -79,7 +80,7 @@ class TestSetCategoryBudgetFullOverlap:
|
||||
|
||||
def test_full_overlap_replace(self, db_session: Session):
|
||||
"""When new budget fully overlaps existing, replace entirely."""
|
||||
cat = Category(name="FullOverlap")
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -114,7 +115,7 @@ class TestSetCategoryBudgetPartialOverlapStart:
|
||||
|
||||
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 = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -150,7 +151,7 @@ class TestSetCategoryBudgetPartialOverlapEnd:
|
||||
|
||||
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 = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -186,7 +187,7 @@ class TestSetCategoryBudgetMiddleOverlap:
|
||||
|
||||
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 = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -223,7 +224,7 @@ class TestSetCategoryBudgetUnbounded:
|
||||
|
||||
def test_unbounded_start(self, db_session: Session):
|
||||
"""Budget with None valid_from (unbounded start)."""
|
||||
cat = Category(name="UnboundedStart")
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -244,7 +245,7 @@ class TestSetCategoryBudgetUnbounded:
|
||||
|
||||
def test_unbounded_end(self, db_session: Session):
|
||||
"""Budget with None valid_to (unbounded end)."""
|
||||
cat = Category(name="UnboundedEnd")
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -265,7 +266,7 @@ class TestSetCategoryBudgetUnbounded:
|
||||
|
||||
def test_unbounded_both(self, db_session: Session):
|
||||
"""Budget with both dates unbounded (None)."""
|
||||
cat = Category(name="UnboundedBoth")
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -290,7 +291,7 @@ class TestSetCategoryBudgetWithExistingUnbounded:
|
||||
|
||||
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 = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -319,7 +320,7 @@ class TestSetCategoryBudgetWithExistingUnbounded:
|
||||
|
||||
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 = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -352,7 +353,7 @@ class TestSetCategoryBudgetMultipleWithSameAmount:
|
||||
|
||||
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 = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -386,7 +387,7 @@ class TestSetCategoryBudgetDateEdgeCases:
|
||||
|
||||
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 = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
@@ -497,7 +498,7 @@ class TestSetCategoryBudgetValidation:
|
||||
|
||||
def test_valid_budget_no_dates(self):
|
||||
"""Budget with None dates is valid."""
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
@@ -507,7 +508,7 @@ class TestSetCategoryBudgetValidation:
|
||||
|
||||
def test_valid_budget_with_dates(self):
|
||||
"""Budget with dates is valid."""
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
@@ -519,7 +520,7 @@ class TestSetCategoryBudgetValidation:
|
||||
|
||||
def test_invalid_date_range_raises(self):
|
||||
"""Budget with valid_from > valid_to should raise validation error."""
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CategoryBudget(
|
||||
@@ -533,7 +534,7 @@ class TestSetCategoryBudgetValidation:
|
||||
|
||||
def test_amount_must_be_positive(self):
|
||||
"""Amount must be greater than zero."""
|
||||
cat = Category.from_str("test")
|
||||
cat = CategoryFactory.build()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CategoryBudget(
|
||||
|
||||
@@ -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