From be6c95c49772e62de33a27e7ff7b95af1254b991 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Fri, 3 Jul 2026 08:04:14 +0530 Subject: [PATCH] feat: Add resilience hardening for csv parser --- app/utils/csv_parser.py | 16 +++++++-- tests/services/test_csv_parser.py | 12 +++++++ tests/services/test_llm_classification.py | 44 +++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 tests/services/test_llm_classification.py diff --git a/app/utils/csv_parser.py b/app/utils/csv_parser.py index 02099c6..4e2fc19 100644 --- a/app/utils/csv_parser.py +++ b/app/utils/csv_parser.py @@ -75,14 +75,24 @@ def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]: else None ) - if not all([txn_id, date_str, amount_str, merchant]): + if not all([date_str, amount_str, merchant]): raise InvalidCSVException( - f"Row {line_num}: Empty values are not allowed in required fields" + f"Row {line_num}: Empty values are not allowed in required fields (date, amount, merchant)" ) + # Auto-generate txn_id if missing or empty + if not txn_id or not txn_id.strip(): + import uuid + txn_id = f"GEN_{uuid.uuid4().hex[:8].upper()}" + else: + txn_id = txn_id.strip() + # Validate amount + cleaned_amount_str = ( + amount_str.strip().replace("$", "").replace("₹", "").replace(",", "") + ) try: - amount = float(amount_str.strip()) + amount = float(cleaned_amount_str) except ValueError as e: raise InvalidCSVException( f"Row {line_num}: Invalid amount '{amount_str}'. Must be a number." diff --git a/tests/services/test_csv_parser.py b/tests/services/test_csv_parser.py index a714b24..e0769de 100644 --- a/tests/services/test_csv_parser.py +++ b/tests/services/test_csv_parser.py @@ -52,3 +52,15 @@ def test_parse_invalid_date(): with pytest.raises(InvalidCSVException) as exc_info: parse_and_validate_csv(invalid_csv.encode("utf-8")) assert "Invalid date format" in str(exc_info.value) + + +def test_parse_amount_with_symbols(): + csv_data = ( + "txn_id,date,amount,merchant\n" + "TX1001,2026-06-25,$120.50,Weekly grocery shop\n" + 'TX1002,2026-06-26,"₹1,500.00",Weekly grocery shop\n' + ) + result = parse_and_validate_csv(csv_data.encode("utf-8")) + assert len(result) == 2 + assert result[0]["amount"] == 120.50 + assert result[1]["amount"] == 1500.00 diff --git a/tests/services/test_llm_classification.py b/tests/services/test_llm_classification.py new file mode 100644 index 0000000..e550e46 --- /dev/null +++ b/tests/services/test_llm_classification.py @@ -0,0 +1,44 @@ +import pytest +from app.worker import classify_transactions_batch +from google import genai +from unittest.mock import MagicMock, patch + +pytestmark = pytest.mark.asyncio + + +async def test_classify_transactions_batch_retry_success(): + # Mock GenAI client and interaction creation + mock_client = MagicMock(spec=genai.Client) + mock_interaction = MagicMock() + mock_interaction.output_text = '{"classifications": [{"txn_id": "TX1", "category": "Food"}, {"txn_id": "TX2", "category": "Shopping"}]}' + mock_client.interactions.create.return_value = mock_interaction + + batch_txns = [ + {"txn_id": "TX1", "merchant": "Swiggy", "amount": 150.0}, + {"txn_id": "TX2", "merchant": "Amazon", "amount": 1200.0}, + ] + + results = await classify_transactions_batch(mock_client, batch_txns) + assert len(results) == 2 + assert results["TX1"]["category"] == "Food" + assert results["TX1"]["failed"] is False + assert results["TX2"]["category"] == "Shopping" + assert results["TX2"]["failed"] is False + + +async def test_classify_transactions_batch_retry_failure(): + mock_client = MagicMock(spec=genai.Client) + # Simulate API call failure (e.g. invalid API key or connection error) + mock_client.interactions.create.side_effect = Exception("API Key Error") + + batch_txns = [ + {"txn_id": "TX1", "merchant": "Swiggy", "amount": 150.0}, + ] + + # Patch sleep to make retry test run instantly + with patch("asyncio.sleep", return_value=None): + results = await classify_transactions_batch(mock_client, batch_txns) + + assert len(results) == 1 + assert results["TX1"]["category"] == "Other" + assert results["TX1"]["failed"] is True