mypy black ruff
Tests / test (push) Successful in 11s
Tests / lint (push) Successful in 11s

This commit is contained in:
2026-10-16 22:51:11 +02:00
parent 553e1a4c83
commit 1fcaec8daf
10 changed files with 275 additions and 164 deletions
+15 -14
View File
@@ -1,6 +1,7 @@
import pytest
from datetime import date
import pytest
from src.models.category import Category, CategoryBudget
@@ -20,7 +21,7 @@ class TestCategoryComputedFields:
"""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
@@ -35,10 +36,10 @@ class TestCategoryComputedFields:
"""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):
@@ -84,7 +85,7 @@ class TestCategoryBudgetValidation:
def test_valid_budget_with_dates(self):
"""Budget with valid date range is accepted."""
from datetime import date
cat = Category.from_str("test")
budget = CategoryBudget(
category=cat,
@@ -97,9 +98,9 @@ class TestCategoryBudgetValidation:
def test_invalid_date_range_raises(self):
"""Budget with valid_from > valid_to raises ValueError."""
from datetime import date
cat = Category.from_str("test")
with pytest.raises(ValueError) as exc_info:
CategoryBudget(
category=cat,
@@ -107,14 +108,14 @@ class TestCategoryBudgetValidation:
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)."""
from datetime import date
cat = Category.from_str("test")
budget = CategoryBudget(
category=cat,
@@ -127,7 +128,7 @@ class TestCategoryBudgetValidation:
def test_validation_preserves_original_values(self):
"""Validation doesn't change the model's original values."""
from datetime import date
cat = Category.from_str("test")
budget = CategoryBudget(
category=cat,
@@ -135,10 +136,10 @@ class TestCategoryBudgetValidation:
valid_from=date(2026, 9, 1),
valid_to=date(2026, 9, 30),
)
# Create an invalid one and verify validation catches it
# Create an invalid one (valid_from > valid_to) and verify validation catches it
try:
bad_budget = CategoryBudget(
CategoryBudget(
category=cat,
amount_eur=200.00,
valid_from=date(2026, 12, 31),
@@ -146,6 +147,6 @@ class TestCategoryBudgetValidation:
)
except ValueError:
pass # Expected
# Valid one should still have original values
assert budget.amount_eur == 150.75
+98 -61
View File
@@ -5,9 +5,10 @@ The upsert function has two paths:
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 import select
from sqlalchemy.orm import Session
from src.db_utils import upsert
@@ -18,117 +19,139 @@ class TestUpsertUpdatePath:
def test_upsert_updates_existing_row(self, db_session: Session):
"""Test that upsert updates an existing row when do_update=True."""
from sqlalchemy.orm import declarative_base
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() # type: ignore
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.orm import declarative_base
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)
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.orm import declarative_base
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 = 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):
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.orm import declarative_base
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() # type: ignore
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
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"
@@ -139,31 +162,39 @@ class TestUpsertUpdateEdgeCases:
def test_upsert_partial_update(self, db_session: Session):
"""Test that only specified fields are updated."""
from sqlalchemy.orm import declarative_base
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")
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)
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__
@@ -174,28 +205,34 @@ class TestUpsertUpdateEmptyDict:
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.orm import declarative_base
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)
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"
+54 -24
View File
@@ -5,10 +5,12 @@ from datetime import date
import pytest
from sqlalchemy.orm import Session
from src.domain_utils import set_category_budget, find_category_db
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
# 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."""
@@ -37,7 +39,9 @@ class TestSetCategoryBudgetNoOverlap:
)
set_category_budget(db_session, budget2)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
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):
@@ -64,7 +68,9 @@ class TestSetCategoryBudgetNoOverlap:
)
set_category_budget(db_session, new_budget)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
budgets = list(
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
)
assert len(budgets) == 2
@@ -95,7 +101,9 @@ class TestSetCategoryBudgetFullOverlap:
)
set_category_budget(db_session, new_budget)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
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
@@ -131,7 +139,9 @@ class TestSetCategoryBudgetPartialOverlapStart:
# 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))
budgets = list(
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
)
assert len(budgets) == 2
@@ -165,7 +175,9 @@ class TestSetCategoryBudgetPartialOverlapEnd:
# 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))
budgets = list(
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
)
assert len(budgets) == 2
@@ -200,7 +212,9 @@ class TestSetCategoryBudgetMiddleOverlap:
# 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))
budgets = list(
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
)
assert len(budgets) == 3
@@ -221,7 +235,9 @@ class TestSetCategoryBudgetUnbounded:
)
set_category_budget(db_session, budget)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
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)
@@ -240,7 +256,9 @@ class TestSetCategoryBudgetUnbounded:
)
set_category_budget(db_session, budget)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
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
@@ -255,11 +273,13 @@ class TestSetCategoryBudgetUnbounded:
category=cat,
amount_eur=100.00,
valid_from=None, # Unbounded start
valid_to=None, # Unbounded end
valid_to=None, # Unbounded end
)
set_category_budget(db_session, budget)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
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
@@ -292,7 +312,9 @@ class TestSetCategoryBudgetWithExistingUnbounded:
)
set_category_budget(db_session, bounded_budget)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
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):
@@ -319,7 +341,9 @@ class TestSetCategoryBudgetWithExistingUnbounded:
)
set_category_budget(db_session, bounded_budget)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
budgets = list(
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
)
assert len(budgets) == 2
@@ -350,7 +374,9 @@ class TestSetCategoryBudgetMultipleWithSameAmount:
)
set_category_budget(db_session, budget2)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
budgets = list(
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
)
assert len(budgets) == 1
assert budgets[0].amount_eur == 200.00
@@ -382,7 +408,9 @@ class TestSetCategoryBudgetDateEdgeCases:
)
set_category_budget(db_session, budget2)
budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id))
budgets = list(
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
)
assert len(budgets) == 2
@@ -404,11 +432,11 @@ class TestFindCategoryDb:
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()
@@ -457,9 +485,11 @@ class TestSetCategoryBudgetErrorHandling:
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()
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:
@@ -490,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")
with pytest.raises(ValueError) as exc_info:
CategoryBudget(
category=cat,
@@ -498,13 +528,13 @@ class TestSetCategoryBudgetValidation:
valid_from=date(2026, 11, 30),
valid_to=date(2026, 10, 1),
)
assert "valid_from" in str(exc_info.value) or "valid_to" in str(exc_info.value)
def test_amount_must_be_positive(self):
"""Amount must be greater than zero."""
cat = Category.from_str("test")
with pytest.raises(ValueError) as exc_info:
CategoryBudget(
category=cat,
@@ -512,5 +542,5 @@ class TestSetCategoryBudgetValidation:
valid_from=date(2026, 10, 1),
valid_to=date(2026, 11, 30),
)
assert "greater than" in str(exc_info.value).lower()
+21 -19
View File
@@ -2,12 +2,7 @@
from datetime import date
import pytest
from src.domain_utils import set_category_budget, find_category_db
from src.models.common import Sign, date2str, ExportBaseModel
from src.models.category import Category, CategoryBudget, CategoryBudgetDb
from src.models.b4w import B4wExport
from src.models.common import ExportBaseModel, Sign, date2str
class TestSignEnum:
@@ -22,7 +17,7 @@ class TestSignEnum:
"""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"
@@ -68,64 +63,70 @@ class TestExportBaseModel:
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__ 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
@@ -136,8 +137,9 @@ class TestMyNameMethod:
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"
+13 -4
View File
@@ -1,6 +1,7 @@
"""Tests for src.models.__init__.py lazy loading and __dir__."""
import pytest
from src import models
@@ -10,50 +11,58 @@ class TestLazyLoading:
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:
models.NonExistentModel
# 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)
@@ -71,7 +80,7 @@ class TestDirMethod:
def test_dir_includes_all_exports(self):
"""Test that __dir__() includes all exported models."""
result = models.__dir__()
expected_exports = {
"B4wExport",
"Category",
@@ -84,7 +93,7 @@ class TestDirMethod:
"Transaction",
"TransactionDb",
}
# All exports should be in dir()
for export_name in expected_exports:
assert export_name in result