From ea7854473fd588921f820b4cefcb88486c215e37 Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Sun, 18 Oct 2026 09:28:19 +0200 Subject: [PATCH] Add category-list factory and seed categories in B4W smoke test --- tests/conftest.py | 10 ++++++- tests/factories.py | 30 ++++++++++++++++++- tests/test_category_budget.py | 9 +++--- tests/test_category_edge_cases.py | 11 +++---- tests/test_domain_utils_budget_overlap.py | 35 ++++++++++++----------- tests/test_smoke_b4w.py | 11 +++++++ tests/test_transactions.py | 4 +-- 7 files changed, 80 insertions(+), 30 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4dff4af..127e207 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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)) diff --git a/tests/factories.py b/tests/factories.py index 91718f9..be16434 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -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): diff --git a/tests/test_category_budget.py b/tests/test_category_budget.py index ff738a1..db20fb1 100644 --- a/tests/test_category_budget.py +++ b/tests/test_category_budget.py @@ -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() diff --git a/tests/test_category_edge_cases.py b/tests/test_category_edge_cases.py index 2aab705..1ac6c45 100644 --- a/tests/test_category_edge_cases.py +++ b/tests/test_category_edge_cases.py @@ -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, diff --git a/tests/test_domain_utils_budget_overlap.py b/tests/test_domain_utils_budget_overlap.py index 727364f..3164887 100644 --- a/tests/test_domain_utils_budget_overlap.py +++ b/tests/test_domain_utils_budget_overlap.py @@ -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( diff --git a/tests/test_smoke_b4w.py b/tests/test_smoke_b4w.py index ae4e230..450cf88 100644 --- a/tests/test_smoke_b4w.py +++ b/tests/test_smoke_b4w.py @@ -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()] diff --git a/tests/test_transactions.py b/tests/test_transactions.py index e77b132..a1b67fa 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -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)