mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 19:32:25 +00:00
The notification system was incorrectly sending notifications for existing transactions that were being updated during sync operations. This change modifies the transaction persistence logic to only return genuinely new transactions, preventing duplicate notifications while maintaining data integrity through INSERT OR REPLACE. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
473 lines
18 KiB
Python
473 lines
18 KiB
Python
"""Tests for database service."""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from leggen.services.database_service import DatabaseService
|
|
|
|
|
|
@pytest.fixture
|
|
def database_service():
|
|
"""Create a database service instance for testing."""
|
|
return DatabaseService()
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_transactions_db_format():
|
|
"""Sample transactions in database format."""
|
|
return [
|
|
{
|
|
"accountId": "test-account-123",
|
|
"transactionId": "txn-001",
|
|
"internalTransactionId": "txn-001",
|
|
"institutionId": "REVOLUT_REVOLT21",
|
|
"iban": "LT313250081177977789",
|
|
"transactionDate": datetime(2025, 9, 1, 9, 30),
|
|
"description": "Coffee Shop Payment",
|
|
"transactionValue": -10.50,
|
|
"transactionCurrency": "EUR",
|
|
"transactionStatus": "booked",
|
|
"rawTransaction": {"transactionId": "txn-001", "some": "data"},
|
|
},
|
|
{
|
|
"accountId": "test-account-123",
|
|
"transactionId": "txn-002",
|
|
"internalTransactionId": "txn-002",
|
|
"institutionId": "REVOLUT_REVOLT21",
|
|
"iban": "LT313250081177977789",
|
|
"transactionDate": datetime(2025, 9, 2, 14, 15),
|
|
"description": "Grocery Store",
|
|
"transactionValue": -45.30,
|
|
"transactionCurrency": "EUR",
|
|
"transactionStatus": "booked",
|
|
"rawTransaction": {"transactionId": "txn-002", "other": "data"},
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_balances_db_format():
|
|
"""Sample balances in database format."""
|
|
return [
|
|
{
|
|
"id": 1,
|
|
"account_id": "test-account-123",
|
|
"bank": "REVOLUT_REVOLT21",
|
|
"status": "active",
|
|
"iban": "LT313250081177977789",
|
|
"amount": 1000.00,
|
|
"currency": "EUR",
|
|
"type": "interimAvailable",
|
|
"timestamp": datetime(2025, 9, 1, 10, 0),
|
|
},
|
|
{
|
|
"id": 2,
|
|
"account_id": "test-account-123",
|
|
"bank": "REVOLUT_REVOLT21",
|
|
"status": "active",
|
|
"iban": "LT313250081177977789",
|
|
"amount": 950.00,
|
|
"currency": "EUR",
|
|
"type": "expected",
|
|
"timestamp": datetime(2025, 9, 1, 10, 0),
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestDatabaseService:
|
|
"""Test database service operations."""
|
|
|
|
async def test_get_transactions_from_db_success(
|
|
self, database_service, sample_transactions_db_format
|
|
):
|
|
"""Test successful retrieval of transactions from database."""
|
|
with patch.object(
|
|
database_service, "_get_transactions"
|
|
) as mock_get_transactions:
|
|
mock_get_transactions.return_value = sample_transactions_db_format
|
|
|
|
result = await database_service.get_transactions_from_db(
|
|
account_id="test-account-123", limit=10
|
|
)
|
|
|
|
assert len(result) == 2
|
|
assert result[0]["internalTransactionId"] == "txn-001"
|
|
mock_get_transactions.assert_called_once_with(
|
|
account_id="test-account-123",
|
|
limit=10,
|
|
offset=0,
|
|
date_from=None,
|
|
date_to=None,
|
|
min_amount=None,
|
|
max_amount=None,
|
|
search=None,
|
|
)
|
|
|
|
async def test_get_transactions_from_db_with_filters(
|
|
self, database_service, sample_transactions_db_format
|
|
):
|
|
"""Test retrieving transactions with filters."""
|
|
with patch.object(
|
|
database_service, "_get_transactions"
|
|
) as mock_get_transactions:
|
|
mock_get_transactions.return_value = sample_transactions_db_format
|
|
|
|
result = await database_service.get_transactions_from_db(
|
|
account_id="test-account-123",
|
|
limit=5,
|
|
offset=10,
|
|
date_from="2025-09-01",
|
|
date_to="2025-09-02",
|
|
min_amount=-50.0,
|
|
max_amount=0.0,
|
|
search="Coffee",
|
|
)
|
|
|
|
assert len(result) == 2
|
|
mock_get_transactions.assert_called_once_with(
|
|
account_id="test-account-123",
|
|
limit=5,
|
|
offset=10,
|
|
date_from="2025-09-01",
|
|
date_to="2025-09-02",
|
|
min_amount=-50.0,
|
|
max_amount=0.0,
|
|
search="Coffee",
|
|
)
|
|
|
|
async def test_get_transactions_from_db_sqlite_disabled(self, database_service):
|
|
"""Test getting transactions when SQLite is disabled."""
|
|
database_service.sqlite_enabled = False
|
|
|
|
result = await database_service.get_transactions_from_db()
|
|
|
|
assert result == []
|
|
|
|
async def test_get_transactions_from_db_error(self, database_service):
|
|
"""Test handling error when getting transactions."""
|
|
with patch.object(
|
|
database_service, "_get_transactions"
|
|
) as mock_get_transactions:
|
|
mock_get_transactions.side_effect = Exception("Database error")
|
|
|
|
result = await database_service.get_transactions_from_db()
|
|
|
|
assert result == []
|
|
|
|
async def test_get_transaction_count_from_db_success(self, database_service):
|
|
"""Test successful retrieval of transaction count."""
|
|
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
|
|
mock_get_count.return_value = 42
|
|
|
|
result = await database_service.get_transaction_count_from_db(
|
|
account_id="test-account-123"
|
|
)
|
|
|
|
assert result == 42
|
|
mock_get_count.assert_called_once_with(account_id="test-account-123")
|
|
|
|
async def test_get_transaction_count_from_db_with_filters(self, database_service):
|
|
"""Test getting transaction count with filters."""
|
|
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
|
|
mock_get_count.return_value = 15
|
|
|
|
result = await database_service.get_transaction_count_from_db(
|
|
account_id="test-account-123",
|
|
date_from="2025-09-01",
|
|
min_amount=-100.0,
|
|
search="Coffee",
|
|
)
|
|
|
|
assert result == 15
|
|
mock_get_count.assert_called_once_with(
|
|
account_id="test-account-123",
|
|
date_from="2025-09-01",
|
|
min_amount=-100.0,
|
|
search="Coffee",
|
|
)
|
|
|
|
async def test_get_transaction_count_from_db_sqlite_disabled(
|
|
self, database_service
|
|
):
|
|
"""Test getting count when SQLite is disabled."""
|
|
database_service.sqlite_enabled = False
|
|
|
|
result = await database_service.get_transaction_count_from_db()
|
|
|
|
assert result == 0
|
|
|
|
async def test_get_transaction_count_from_db_error(self, database_service):
|
|
"""Test handling error when getting count."""
|
|
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
|
|
mock_get_count.side_effect = Exception("Database error")
|
|
|
|
result = await database_service.get_transaction_count_from_db()
|
|
|
|
assert result == 0
|
|
|
|
async def test_get_balances_from_db_success(
|
|
self, database_service, sample_balances_db_format
|
|
):
|
|
"""Test successful retrieval of balances from database."""
|
|
with patch.object(database_service, "_get_balances") as mock_get_balances:
|
|
mock_get_balances.return_value = sample_balances_db_format
|
|
|
|
result = await database_service.get_balances_from_db(
|
|
account_id="test-account-123"
|
|
)
|
|
|
|
assert len(result) == 2
|
|
assert result[0]["account_id"] == "test-account-123"
|
|
assert result[0]["amount"] == 1000.00
|
|
mock_get_balances.assert_called_once_with(account_id="test-account-123")
|
|
|
|
async def test_get_balances_from_db_sqlite_disabled(self, database_service):
|
|
"""Test getting balances when SQLite is disabled."""
|
|
database_service.sqlite_enabled = False
|
|
|
|
result = await database_service.get_balances_from_db()
|
|
|
|
assert result == []
|
|
|
|
async def test_get_balances_from_db_error(self, database_service):
|
|
"""Test handling error when getting balances."""
|
|
with patch.object(database_service, "_get_balances") as mock_get_balances:
|
|
mock_get_balances.side_effect = Exception("Database error")
|
|
|
|
result = await database_service.get_balances_from_db()
|
|
|
|
assert result == []
|
|
|
|
async def test_get_account_summary_from_db_success(self, database_service):
|
|
"""Test successful retrieval of account summary."""
|
|
mock_summary = {
|
|
"accountId": "test-account-123",
|
|
"institutionId": "REVOLUT_REVOLT21",
|
|
"iban": "LT313250081177977789",
|
|
}
|
|
|
|
with patch.object(database_service, "_get_account_summary") as mock_get_summary:
|
|
mock_get_summary.return_value = mock_summary
|
|
|
|
result = await database_service.get_account_summary_from_db(
|
|
"test-account-123"
|
|
)
|
|
|
|
assert result == mock_summary
|
|
mock_get_summary.assert_called_once_with("test-account-123")
|
|
|
|
async def test_get_account_summary_from_db_sqlite_disabled(self, database_service):
|
|
"""Test getting summary when SQLite is disabled."""
|
|
database_service.sqlite_enabled = False
|
|
|
|
result = await database_service.get_account_summary_from_db("test-account-123")
|
|
|
|
assert result is None
|
|
|
|
async def test_get_account_summary_from_db_error(self, database_service):
|
|
"""Test handling error when getting summary."""
|
|
with patch.object(database_service, "_get_account_summary") as mock_get_summary:
|
|
mock_get_summary.side_effect = Exception("Database error")
|
|
|
|
result = await database_service.get_account_summary_from_db(
|
|
"test-account-123"
|
|
)
|
|
|
|
assert result is None
|
|
|
|
async def test_persist_balance_sqlite_success(self, database_service):
|
|
"""Test successful balance persistence."""
|
|
balance_data = {
|
|
"institution_id": "REVOLUT_REVOLT21",
|
|
"iban": "LT313250081177977789",
|
|
"balances": [
|
|
{
|
|
"balanceAmount": {"amount": "1000.00", "currency": "EUR"},
|
|
"balanceType": "interimAvailable",
|
|
}
|
|
],
|
|
}
|
|
|
|
with patch("sqlite3.connect") as mock_connect:
|
|
mock_conn = mock_connect.return_value
|
|
mock_cursor = mock_conn.cursor.return_value
|
|
|
|
await database_service._persist_balance_sqlite(
|
|
"test-account-123", balance_data
|
|
)
|
|
|
|
# Verify database operations
|
|
mock_connect.assert_called()
|
|
mock_cursor.execute.assert_called() # Table creation and insert
|
|
mock_conn.commit.assert_called_once()
|
|
mock_conn.close.assert_called_once()
|
|
|
|
async def test_persist_balance_sqlite_error(self, database_service):
|
|
"""Test handling error during balance persistence."""
|
|
balance_data = {"balances": []}
|
|
|
|
with patch("sqlite3.connect") as mock_connect:
|
|
mock_connect.side_effect = Exception("Database error")
|
|
|
|
with pytest.raises(Exception, match="Database error"):
|
|
await database_service._persist_balance_sqlite(
|
|
"test-account-123", balance_data
|
|
)
|
|
|
|
async def test_persist_transactions_sqlite_success(
|
|
self, database_service, sample_transactions_db_format
|
|
):
|
|
"""Test successful transaction persistence."""
|
|
with patch("sqlite3.connect") as mock_connect:
|
|
mock_conn = mock_connect.return_value
|
|
mock_cursor = mock_conn.cursor.return_value
|
|
# Mock fetchone to return (0,) indicating transaction doesn't exist yet
|
|
mock_cursor.fetchone.return_value = (0,)
|
|
|
|
result = await database_service._persist_transactions_sqlite(
|
|
"test-account-123", sample_transactions_db_format
|
|
)
|
|
|
|
# Should return the transactions (assuming no duplicates)
|
|
assert len(result) >= 0 # Could be empty if all are duplicates
|
|
|
|
# Verify database operations
|
|
mock_connect.assert_called()
|
|
mock_cursor.execute.assert_called()
|
|
mock_conn.commit.assert_called_once()
|
|
mock_conn.close.assert_called_once()
|
|
|
|
async def test_persist_transactions_sqlite_duplicate_detection(
|
|
self, database_service, sample_transactions_db_format
|
|
):
|
|
"""Test that existing transactions are not returned as new."""
|
|
with patch("sqlite3.connect") as mock_connect:
|
|
mock_conn = mock_connect.return_value
|
|
mock_cursor = mock_conn.cursor.return_value
|
|
# Mock fetchone to return (1,) indicating transaction already exists
|
|
mock_cursor.fetchone.return_value = (1,)
|
|
|
|
result = await database_service._persist_transactions_sqlite(
|
|
"test-account-123", sample_transactions_db_format
|
|
)
|
|
|
|
# Should return empty list since all transactions already exist
|
|
assert len(result) == 0
|
|
|
|
# Verify database operations still happened (INSERT OR REPLACE executed)
|
|
mock_connect.assert_called()
|
|
mock_cursor.execute.assert_called()
|
|
mock_conn.commit.assert_called_once()
|
|
mock_conn.close.assert_called_once()
|
|
|
|
async def test_persist_transactions_sqlite_error(self, database_service):
|
|
"""Test handling error during transaction persistence."""
|
|
with patch("sqlite3.connect") as mock_connect:
|
|
mock_connect.side_effect = Exception("Database error")
|
|
|
|
with pytest.raises(Exception, match="Database error"):
|
|
await database_service._persist_transactions_sqlite(
|
|
"test-account-123", []
|
|
)
|
|
|
|
async def test_process_transactions_booked_and_pending(self, database_service):
|
|
"""Test processing transactions with both booked and pending."""
|
|
account_info = {
|
|
"institution_id": "REVOLUT_REVOLT21",
|
|
"iban": "LT313250081177977789",
|
|
}
|
|
|
|
transaction_data = {
|
|
"transactions": {
|
|
"booked": [
|
|
{
|
|
"internalTransactionId": "txn-001",
|
|
"transactionId": "txn-001",
|
|
"bookingDate": "2025-09-01",
|
|
"transactionAmount": {"amount": "-10.50", "currency": "EUR"},
|
|
"remittanceInformationUnstructured": "Coffee Shop",
|
|
}
|
|
],
|
|
"pending": [
|
|
{
|
|
"internalTransactionId": "txn-002",
|
|
"transactionId": "txn-002",
|
|
"bookingDate": "2025-09-02",
|
|
"transactionAmount": {"amount": "-25.00", "currency": "EUR"},
|
|
"remittanceInformationUnstructured": "Gas Station",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
result = database_service.process_transactions(
|
|
"test-account-123", account_info, transaction_data
|
|
)
|
|
|
|
assert len(result) == 2
|
|
|
|
# Check booked transaction
|
|
booked_txn = next(t for t in result if t["transactionStatus"] == "booked")
|
|
assert booked_txn["transactionId"] == "txn-001"
|
|
assert booked_txn["internalTransactionId"] == "txn-001"
|
|
assert booked_txn["transactionValue"] == -10.50
|
|
assert booked_txn["description"] == "Coffee Shop"
|
|
|
|
# Check pending transaction
|
|
pending_txn = next(t for t in result if t["transactionStatus"] == "pending")
|
|
assert pending_txn["transactionId"] == "txn-002"
|
|
assert pending_txn["internalTransactionId"] == "txn-002"
|
|
assert pending_txn["transactionValue"] == -25.00
|
|
assert pending_txn["description"] == "Gas Station"
|
|
|
|
async def test_process_transactions_missing_date_error(self, database_service):
|
|
"""Test processing transaction with missing date raises error."""
|
|
account_info = {"institution_id": "TEST_BANK"}
|
|
|
|
transaction_data = {
|
|
"transactions": {
|
|
"booked": [
|
|
{
|
|
"internalTransactionId": "txn-001",
|
|
# Missing both bookingDate and valueDate
|
|
"transactionAmount": {"amount": "-10.50", "currency": "EUR"},
|
|
}
|
|
],
|
|
"pending": [],
|
|
}
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="No valid date found in transaction"):
|
|
database_service.process_transactions(
|
|
"test-account-123", account_info, transaction_data
|
|
)
|
|
|
|
async def test_process_transactions_remittance_array(self, database_service):
|
|
"""Test processing transaction with remittance array."""
|
|
account_info = {"institution_id": "TEST_BANK"}
|
|
|
|
transaction_data = {
|
|
"transactions": {
|
|
"booked": [
|
|
{
|
|
"internalTransactionId": "txn-001",
|
|
"transactionId": "txn-001",
|
|
"bookingDate": "2025-09-01",
|
|
"transactionAmount": {"amount": "-10.50", "currency": "EUR"},
|
|
"remittanceInformationUnstructuredArray": ["Line 1", "Line 2"],
|
|
}
|
|
],
|
|
"pending": [],
|
|
}
|
|
}
|
|
|
|
result = database_service.process_transactions(
|
|
"test-account-123", account_info, transaction_data
|
|
)
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["description"] == "Line 1,Line 2"
|