diff --git a/src/moneywiz_api/model/transaction.py b/src/moneywiz_api/model/transaction.py index c73553e..8670f76 100644 --- a/src/moneywiz_api/model/transaction.py +++ b/src/moneywiz_api/model/transaction.py @@ -10,7 +10,11 @@ from moneywiz_api.model.record import Record from moneywiz_api.schema_profile import SchemaProfile from moneywiz_api.types import ID -from moneywiz_api.validation import require_integer_identity, require_valid +from moneywiz_api.validation import ( + require_integer_identity, + require_text, + require_valid, +) ABS_TOLERANCE = 0.001 @@ -24,7 +28,7 @@ class Transaction(Record, ABC): reconciled: bool amount: Decimal - description: str + description: Optional[str] datetime: datetime notes: Optional[str] @@ -50,8 +54,10 @@ def __init__(self, row): # Validate require_valid(self.reconciled is not None, "transaction state is required") require_valid(self.amount is not None, "transaction amount is required") - require_valid( - self.description is not None, "transaction description is required" + require_text( + self.description, + "transaction description must be an uncoerced string or null", + optional=True, ) require_valid(self.datetime is not None, "transaction date is required") # self.notes can be None diff --git a/tests/unit/test_api_snapshot_generation.py b/tests/unit/test_api_snapshot_generation.py index c778d9e..9116625 100644 --- a/tests/unit/test_api_snapshot_generation.py +++ b/tests/unit/test_api_snapshot_generation.py @@ -17,6 +17,7 @@ def create_read_schema(path) -> None: (8, "SyncObject", 0, 0), (9, "Account", 8, 0), (10, "CashAccount", 9, 0), + (35, "Tag", 8, 0), (36, "Transaction", 8, 0), (37, "DepositTransaction", 36, 0), ], @@ -31,6 +32,9 @@ def create_read_schema(path) -> None: "ZORIGINALCURRENCY TEXT, ZORIGINALAMOUNT FLOAT, " "ZORIGINALEXCHANGERATE FLOAT)" ) + connection.execute( + "CREATE TABLE Z_36TAGS (Z_36TRANSACTIONS INTEGER, Z_35TAGS INTEGER)" + ) connection.execute( "INSERT INTO ZSYNCOBJECT " "(Z_PK, Z_ENT, ZOBJECTCREATIONDATE, ZGID, ZDISPLAYORDER, ZGROUPID, " @@ -56,6 +60,20 @@ def mutate_between_loads(path) -> None: connection.close() +def add_nullable_description_transaction(path) -> None: + connection = sqlite3.connect(path) + connection.execute( + "INSERT INTO ZSYNCOBJECT " + "(Z_PK, Z_ENT, ZOBJECTCREATIONDATE, ZGID, ZRECONCILED, ZAMOUNT1, " + "ZDESC2, ZDATE1, ZNOTES1, ZACCOUNT2, ZPAYEE2, ZORIGINALCURRENCY, " + "ZORIGINALAMOUNT, ZORIGINALEXCHANGERATE) " + "VALUES (2, 37, 0, 'transaction-2', 0, 5, NULL, 0, NULL, 1, " + "NULL, 'EUR', 5, 1)" + ) + connection.commit() + connection.close() + + @pytest.mark.parametrize( ("initial_managers", "expected_managers"), [ @@ -81,3 +99,20 @@ def test_scoped_reload_refreshes_loaded_union_atomically( assert api.transaction_manager.get(2).description == "Income" assert tuple(snapshot["completeness"]["managers"]) == expected_managers assert snapshot["records"]["accounts"][0]["name"] == "After" + + +def test_nullable_transaction_description_preserves_complete_api_read(tmp_path) -> None: + path = tmp_path / "nullable-description.sqlite" + create_read_schema(path) + add_nullable_description_transaction(path) + + with MoneywizApi(path, managers=("transactions",)) as api: + completeness = api.completeness() + snapshot = api.snapshot().as_dict() + + assert completeness.complete + assert api.transaction_manager.load_report.source_ids == (2,) + assert api.transaction_manager.load_report.parsed_ids == (2,) + assert api.transaction_manager.load_report.skipped == () + assert api.transaction_manager.get(2).description is None + assert snapshot["records"]["transactions"][0]["description"] is None diff --git a/tests/unit/test_transaction_compatibility.py b/tests/unit/test_transaction_compatibility.py index aa7a0e6..56f9423 100644 --- a/tests/unit/test_transaction_compatibility.py +++ b/tests/unit/test_transaction_compatibility.py @@ -58,6 +58,30 @@ def test_transfer_withdraw_preserves_stored_amount_with_rounded_rate() -> None: assert transaction.original_amount == Decimal("-3.333") +def test_transaction_description_allows_null() -> None: + transaction = TransferWithdrawTransaction( + transfer_withdraw_row(ZDESC2=None, ZORIGINALEXCHANGERATE=1.0) + ) + + assert transaction.description is None + assert transaction.as_dict()["description"] is None + + +class _DescriptionSubclass(str): + pass + + +@pytest.mark.parametrize("description", [b"Transfer", 1, _DescriptionSubclass("x")]) +def test_transaction_description_rejects_non_exact_text(description) -> None: + with pytest.raises( + AssertionError, + match="transaction description must be an uncoerced string or null", + ): + TransferWithdrawTransaction( + transfer_withdraw_row(ZDESC2=description, ZORIGINALEXCHANGERATE=1.0) + ) + + class TransferWithdrawManager(RecordManager): @property def ents(self):