diff --git a/leggen/services/sync_service.py b/leggen/services/sync_service.py index 0030374..e6ea8ae 100644 --- a/leggen/services/sync_service.py +++ b/leggen/services/sync_service.py @@ -67,6 +67,9 @@ class SyncService: self._sync_status.total_accounts = len(all_accounts) logs.append(f"Found {len(all_accounts)} accounts to sync") + # Check for expired or expiring requisitions + await self._check_requisition_expiry(requisitions.get("results", [])) + # Process each account for account_id in all_accounts: try: @@ -166,6 +169,22 @@ class SyncService: logger.error(error_msg) logs.append(error_msg) + # Send notification for account sync failure + try: + await self.notifications.send_sync_failure_notification( + { + "account_id": account_id, + "error": error_msg, + "type": "account_sync_failure", + "retry_count": 1, + "max_retries": 1, + } + ) + except Exception as notif_error: + logger.error( + f"Failed to send sync failure notification: {notif_error}" + ) + end_time = datetime.now() duration = (end_time - start_time).total_seconds() @@ -252,6 +271,30 @@ class SyncService: finally: self._sync_status.is_running = False + async def _check_requisition_expiry(self, requisitions: List[dict]) -> None: + """Check requisitions for expiry and send notifications""" + for req in requisitions: + requisition_id = req.get("id", "unknown") + institution_id = req.get("institution_id", "unknown") + status = req.get("status", "") + + # Check if requisition is expired + if status == "EX": + logger.warning( + f"Requisition {requisition_id} for {institution_id} has expired" + ) + try: + await self.notifications.send_expiry_notification( + { + "bank": institution_id, + "requisition_id": requisition_id, + "status": "expired", + "days_left": 0, + } + ) + except Exception as e: + logger.error(f"Failed to send expiry notification: {e}") + async def sync_specific_accounts( self, account_ids: List[str], force: bool = False, trigger_type: str = "manual" ) -> SyncResult: diff --git a/tests/unit/test_sync_notifications.py b/tests/unit/test_sync_notifications.py new file mode 100644 index 0000000..6dd0ff7 --- /dev/null +++ b/tests/unit/test_sync_notifications.py @@ -0,0 +1,252 @@ +"""Tests for sync service notification functionality.""" + +from unittest.mock import patch + +import pytest + +from leggen.services.sync_service import SyncService + + +@pytest.mark.unit +class TestSyncNotifications: + """Test sync service notification functionality.""" + + @pytest.mark.asyncio + async def test_sync_failure_sends_notification(self): + """Test that sync failures trigger notifications.""" + sync_service = SyncService() + + # Mock the dependencies + with ( + patch.object( + sync_service.gocardless, "get_requisitions" + ) as mock_get_requisitions, + patch.object( + sync_service.gocardless, "get_account_details" + ) as mock_get_details, + patch.object( + sync_service.notifications, "send_sync_failure_notification" + ) as mock_send_notification, + patch.object( + sync_service.database, "persist_sync_operation", return_value=1 + ), + ): + # Setup: One requisition with one account that will fail + mock_get_requisitions.return_value = { + "results": [ + { + "id": "req-123", + "institution_id": "TEST_BANK", + "status": "LN", + "accounts": ["account-1"], + } + ] + } + + # Make account details fail + mock_get_details.side_effect = Exception("API Error") + + # Execute: Run sync which should fail for the account + await sync_service.sync_all_accounts() + + # Assert: Notification should be sent for the failed account + mock_send_notification.assert_called_once() + call_args = mock_send_notification.call_args[0][0] + assert call_args["account_id"] == "account-1" + assert "API Error" in call_args["error"] + assert call_args["type"] == "account_sync_failure" + + @pytest.mark.asyncio + async def test_expired_requisition_sends_notification(self): + """Test that expired requisitions trigger expiry notifications.""" + sync_service = SyncService() + + # Mock the dependencies + with ( + patch.object( + sync_service.gocardless, "get_requisitions" + ) as mock_get_requisitions, + patch.object( + sync_service.notifications, "send_expiry_notification" + ) as mock_send_expiry, + patch.object( + sync_service.database, "persist_sync_operation", return_value=1 + ), + ): + # Setup: One expired requisition + mock_get_requisitions.return_value = { + "results": [ + { + "id": "req-expired", + "institution_id": "EXPIRED_BANK", + "status": "EX", + "accounts": [], + } + ] + } + + # Execute: Run sync + await sync_service.sync_all_accounts() + + # Assert: Expiry notification should be sent + mock_send_expiry.assert_called_once() + call_args = mock_send_expiry.call_args[0][0] + assert call_args["requisition_id"] == "req-expired" + assert call_args["bank"] == "EXPIRED_BANK" + assert call_args["status"] == "expired" + assert call_args["days_left"] == 0 + + @pytest.mark.asyncio + async def test_multiple_failures_send_multiple_notifications(self): + """Test that multiple account failures send multiple notifications.""" + sync_service = SyncService() + + # Mock the dependencies + with ( + patch.object( + sync_service.gocardless, "get_requisitions" + ) as mock_get_requisitions, + patch.object( + sync_service.gocardless, "get_account_details" + ) as mock_get_details, + patch.object( + sync_service.notifications, "send_sync_failure_notification" + ) as mock_send_notification, + patch.object( + sync_service.database, "persist_sync_operation", return_value=1 + ), + ): + # Setup: One requisition with two accounts that will fail + mock_get_requisitions.return_value = { + "results": [ + { + "id": "req-123", + "institution_id": "TEST_BANK", + "status": "LN", + "accounts": ["account-1", "account-2"], + } + ] + } + + # Make all account details fail + mock_get_details.side_effect = Exception("API Error") + + # Execute: Run sync + await sync_service.sync_all_accounts() + + # Assert: Two notifications should be sent + assert mock_send_notification.call_count == 2 + + @pytest.mark.asyncio + async def test_successful_sync_no_failure_notification(self): + """Test that successful syncs don't send failure notifications.""" + sync_service = SyncService() + + # Mock the dependencies + with ( + patch.object( + sync_service.gocardless, "get_requisitions" + ) as mock_get_requisitions, + patch.object( + sync_service.gocardless, "get_account_details" + ) as mock_get_details, + patch.object( + sync_service.gocardless, "get_account_balances" + ) as mock_get_balances, + patch.object( + sync_service.gocardless, "get_account_transactions" + ) as mock_get_transactions, + patch.object( + sync_service.notifications, "send_sync_failure_notification" + ) as mock_send_notification, + patch.object(sync_service.notifications, "send_transaction_notifications"), + patch.object(sync_service.database, "persist_account_details"), + patch.object(sync_service.database, "persist_balance"), + patch.object( + sync_service.database, "process_transactions", return_value=[] + ), + patch.object( + sync_service.database, "persist_transactions", return_value=[] + ), + patch.object( + sync_service.database, "persist_sync_operation", return_value=1 + ), + ): + # Setup: One requisition with one account that succeeds + mock_get_requisitions.return_value = { + "results": [ + { + "id": "req-123", + "institution_id": "TEST_BANK", + "status": "LN", + "accounts": ["account-1"], + } + ] + } + + mock_get_details.return_value = { + "id": "account-1", + "institution_id": "TEST_BANK", + "status": "READY", + "iban": "TEST123", + } + + mock_get_balances.return_value = { + "balances": [{"balanceAmount": {"amount": "100", "currency": "EUR"}}] + } + + mock_get_transactions.return_value = {"transactions": {"booked": []}} + + # Execute: Run sync + await sync_service.sync_all_accounts() + + # Assert: No failure notification should be sent + mock_send_notification.assert_not_called() + + @pytest.mark.asyncio + async def test_notification_failure_does_not_stop_sync(self): + """Test that notification failures don't stop the sync process.""" + sync_service = SyncService() + + # Mock the dependencies + with ( + patch.object( + sync_service.gocardless, "get_requisitions" + ) as mock_get_requisitions, + patch.object( + sync_service.gocardless, "get_account_details" + ) as mock_get_details, + patch.object( + sync_service.notifications, "send_sync_failure_notification" + ) as mock_send_notification, + patch.object( + sync_service.database, "persist_sync_operation", return_value=1 + ), + ): + # Setup: One requisition with one account that will fail + mock_get_requisitions.return_value = { + "results": [ + { + "id": "req-123", + "institution_id": "TEST_BANK", + "status": "LN", + "accounts": ["account-1"], + } + ] + } + + # Make account details fail + mock_get_details.side_effect = Exception("API Error") + + # Make notification sending fail + mock_send_notification.side_effect = Exception("Notification Error") + + # Execute: Run sync - should not raise exception from notification + try: + result = await sync_service.sync_all_accounts() + # The sync should complete with errors but not crash + assert result.success is False + assert len(result.errors) > 0 + except Exception as e: + # If exception is raised, it should not be the notification error + assert "Notification Error" not in str(e)