Files
leggen/tests/unit/test_database_service.py
Elisiário Couto 5f87991076 refactor(api): Split DatabaseService into repository pattern.
Split the monolithic DatabaseService (1,492 lines) into focused repository
modules using the repository pattern for better maintainability and
separation of concerns.

Changes:
- Create new repositories/ directory with 5 focused repositories:
  - TransactionRepository: transaction data operations (264 lines)
  - AccountRepository: account data operations (128 lines)
  - BalanceRepository: balance data operations (107 lines)
  - MigrationRepository: all database migrations (629 lines)
  - SyncRepository: sync operation tracking (132 lines)
  - BaseRepository: shared database connection logic (28 lines)

- Refactor DatabaseService into a clean facade (287 lines):
  - Delegates data access to repositories
  - Maintains public API (no breaking changes)
  - Keeps data processors in service layer
  - Preserves require_sqlite decorator

- Update tests to mock repository methods instead of private methods
- Fix test references to internal methods (_persist_*, _get_*)

Benefits:
- Clear separation of concerns (one repository per domain)
- Easier maintenance (changes isolated to specific repositories)
- Better testability (repositories can be mocked individually)
- Improved code organization (from 1 file to 7 focused files)

All 114 tests passing.
2025-12-08 23:21:55 +00:00

490 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.transactions, "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.transactions, "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.transactions, "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.transactions, "get_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",
date_from=None,
date_to=None,
min_amount=None,
max_amount=None,
search=None,
)
async def test_get_transaction_count_from_db_with_filters(self, database_service):
"""Test getting transaction count with filters."""
with patch.object(database_service.transactions, "get_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",
date_to=None,
min_amount=-100.0,
max_amount=None,
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.transactions, "get_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.balances, "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.balances, "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.transactions, "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.transactions, "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.object(database_service.balances, "persist") as mock_persist,
patch.object(
database_service.balance_transformer, "transform_to_database_format"
) as mock_transform,
):
mock_transform.return_value = [
(
"test-account-123",
"REVOLUT_REVOLT21",
"active",
"LT313250081177977789",
1000.0,
"EUR",
"interimAvailable",
"2025-09-01T10:00:00",
)
]
await database_service.persist_balance("test-account-123", balance_data)
# Verify transformation and persistence were called
mock_transform.assert_called_once_with("test-account-123", balance_data)
mock_persist.assert_called_once()
async def test_persist_balance_sqlite_error(self, database_service):
"""Test handling error during balance persistence."""
balance_data = {"balances": []}
with (
patch.object(database_service.balances, "persist") as mock_persist,
patch.object(
database_service.balance_transformer, "transform_to_database_format"
) as mock_transform,
):
mock_persist.side_effect = Exception("Database error")
mock_transform.return_value = []
with pytest.raises(Exception, match="Database error"):
await database_service.persist_balance("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.object(database_service.transactions, "persist") as mock_persist:
mock_persist.return_value = sample_transactions_db_format
result = await database_service.persist_transactions(
"test-account-123", sample_transactions_db_format
)
# Should return the new transactions
assert len(result) == 2
mock_persist.assert_called_once_with(
"test-account-123", sample_transactions_db_format
)
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.object(database_service.transactions, "persist") as mock_persist:
# Return empty list indicating all were duplicates
mock_persist.return_value = []
result = await database_service.persist_transactions(
"test-account-123", sample_transactions_db_format
)
# Should return empty list since all transactions already exist
assert len(result) == 0
mock_persist.assert_called_once()
async def test_persist_transactions_sqlite_error(self, database_service):
"""Test handling error during transaction persistence."""
with patch.object(database_service.transactions, "persist") as mock_persist:
mock_persist.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"):
await database_service.persist_transactions("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"