""" Tests for ReceiptAutoCreateService (US-009). US-009 Acceptance Criteria: - Clasa ReceiptAutoCreateService cu metoda create_from_ocr_result(job_id, ocr_result, user, batch_id) - Mapare completă OCR fields → Receipt fields (vezi ExtractionData schema) - Creare attachment cu fișierul original din job.file_path - Generare accounting entries via AccountingService.generate_entries() existent - Validare minimă: suma > 0, dată validă (nu în viitor) - Return ReceiptCreateResult cu receipt_id sau error_message - Actualizează batch_jobs cu receipt_id după creare - pytest tests pass """ import pytest from datetime import date, timedelta from decimal import Decimal from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import tempfile import os from backend.modules.data_entry.services.receipt_auto_create import ( ReceiptAutoCreateService, ReceiptCreateResult, ) from backend.modules.data_entry.schemas.ocr import ExtractionData, TvaEntry, PaymentMethod from backend.modules.data_entry.db.models.receipt import ReceiptType, ReceiptDirection # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def valid_ocr_result(): """Create a valid OCR extraction result.""" return ExtractionData( receipt_type="bon_fiscal", receipt_number="123456", receipt_series="ABC", receipt_date=date.today() - timedelta(days=1), # Yesterday amount=Decimal("125.50"), partner_name="MEGA IMAGE SRL", cui="12345678", description="Produse alimentare", tva_entries=[ TvaEntry(code="A", percent=19, amount=Decimal("20.00")), TvaEntry(code="B", percent=9, amount=Decimal("5.50")), ], tva_total=Decimal("25.50"), address="Str. Test Nr. 1, Bucuresti", items_count=5, payment_methods=[ PaymentMethod(method="CARD", amount=Decimal("125.50")), ], suggested_payment_mode="banca", confidence_amount=0.95, confidence_date=0.90, confidence_vendor=0.85, overall_confidence=0.90, raw_text="BON FISCAL\nMEGA IMAGE SRL\nTotal: 125.50 LEI", ) @pytest.fixture def minimal_ocr_result(): """Create minimal valid OCR result with only required fields.""" return ExtractionData( receipt_date=date.today(), amount=Decimal("50.00"), ) @pytest.fixture def mock_session(): """Create a mock async session.""" session = AsyncMock() session.add = MagicMock() session.flush = AsyncMock() session.commit = AsyncMock() session.rollback = AsyncMock() session.execute = AsyncMock() return session @pytest.fixture def temp_file(): """Create a temporary file for attachment testing.""" with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: f.write(b"fake pdf content for testing") temp_path = f.name yield temp_path # Cleanup if os.path.exists(temp_path): os.remove(temp_path) # ============================================================================ # Unit Tests - Validation # ============================================================================ class TestValidation: """Test OCR result validation.""" def test_validate_valid_result(self, valid_ocr_result): """Test validation passes for valid OCR result.""" is_valid, error = ReceiptAutoCreateService._validate_ocr_result(valid_ocr_result) assert is_valid is True assert error == "" def test_validate_missing_amount(self): """Test validation fails when amount is missing.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=None, ) is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result) assert is_valid is False assert "amount" in error.lower() def test_validate_zero_amount(self): """Test validation fails when amount is zero.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=Decimal("0"), ) is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result) assert is_valid is False assert "amount" in error.lower() def test_validate_negative_amount(self): """Test validation fails when amount is negative.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=Decimal("-10.00"), ) is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result) assert is_valid is False assert "amount" in error.lower() def test_validate_missing_date(self): """Test validation fails when date is missing.""" ocr_result = ExtractionData( receipt_date=None, amount=Decimal("100.00"), ) is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result) assert is_valid is False assert "date" in error.lower() def test_validate_future_date(self): """Test validation fails when date is in the future.""" ocr_result = ExtractionData( receipt_date=date.today() + timedelta(days=1), # Tomorrow amount=Decimal("100.00"), ) is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result) assert is_valid is False assert "future" in error.lower() def test_validate_today_date_passes(self): """Test validation passes when date is today.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=Decimal("100.00"), ) is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result) assert is_valid is True assert error == "" # ============================================================================ # Unit Tests - Field Mapping # ============================================================================ class TestFieldMapping: """Test OCR to Receipt field mapping.""" def test_map_basic_fields(self, valid_ocr_result): """Test basic field mapping from OCR to Receipt.""" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert receipt_data.receipt_number == "123456" assert receipt_data.receipt_series == "ABC" assert receipt_data.receipt_date == valid_ocr_result.receipt_date assert receipt_data.amount == Decimal("125.50") assert receipt_data.partner_name == "MEGA IMAGE SRL" assert receipt_data.cui == "12345678" assert receipt_data.description == "Produse alimentare" assert receipt_data.company_id == 1 def test_map_receipt_type_bon_fiscal(self, valid_ocr_result): """Test mapping bon_fiscal type correctly.""" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert receipt_data.receipt_type == ReceiptType.BON_FISCAL def test_map_receipt_type_chitanta(self, valid_ocr_result): """Test mapping chitanta type correctly.""" valid_ocr_result.receipt_type = "chitanta" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert receipt_data.receipt_type == ReceiptType.CHITANTA def test_map_default_direction_is_expense(self, valid_ocr_result): """Test default direction is expense (cheltuiala).""" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert receipt_data.direction == ReceiptDirection.CHELTUIALA def test_map_tva_breakdown(self, valid_ocr_result): """Test TVA breakdown mapping.""" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert receipt_data.tva_breakdown is not None assert len(receipt_data.tva_breakdown) == 2 assert receipt_data.tva_breakdown[0].code == "A" assert receipt_data.tva_breakdown[0].percent == 19 assert receipt_data.tva_breakdown[0].amount == Decimal("20.00") assert receipt_data.tva_total == Decimal("25.50") def test_map_payment_methods(self, valid_ocr_result): """Test payment methods mapping.""" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert receipt_data.payment_methods is not None assert len(receipt_data.payment_methods) == 1 assert receipt_data.payment_methods[0].method == "CARD" assert receipt_data.payment_methods[0].amount == Decimal("125.50") assert receipt_data.payment_mode == "banca" def test_map_vendor_address(self, valid_ocr_result): """Test vendor address mapping.""" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert receipt_data.vendor_address == "Str. Test Nr. 1, Bucuresti" def test_map_items_count(self, valid_ocr_result): """Test items count mapping.""" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert receipt_data.items_count == 5 def test_map_raw_text_truncated(self, valid_ocr_result): """Test raw text is truncated to 5000 chars.""" valid_ocr_result.raw_text = "X" * 10000 receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=valid_ocr_result, company_id=1, ) assert len(receipt_data.ocr_raw_text) == 5000 def test_map_minimal_result(self, minimal_ocr_result): """Test mapping works with minimal required fields.""" receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=minimal_ocr_result, company_id=1, ) assert receipt_data.amount == Decimal("50.00") assert receipt_data.receipt_date == date.today() assert receipt_data.company_id == 1 assert receipt_data.receipt_number is None assert receipt_data.partner_name is None assert receipt_data.tva_breakdown is None # ============================================================================ # Unit Tests - ReceiptCreateResult # ============================================================================ class TestReceiptCreateResult: """Test ReceiptCreateResult dataclass.""" def test_success_result(self): """Test successful result has receipt_id.""" result = ReceiptCreateResult(success=True, receipt_id=42) assert result.success is True assert result.receipt_id == 42 assert result.error_message is None def test_failure_result(self): """Test failure result has error_message.""" result = ReceiptCreateResult(success=False, error_message="Validation failed") assert result.success is False assert result.receipt_id is None assert result.error_message == "Validation failed" # ============================================================================ # Integration Tests with Mocked Dependencies # ============================================================================ class TestCreateFromOcrResult: """Test create_from_ocr_result method.""" @pytest.mark.asyncio async def test_successful_creation(self, mock_session, valid_ocr_result, temp_file): """Test successful receipt creation from OCR result.""" # Mock ReceiptCRUD.create to return a mock receipt mock_receipt = MagicMock() mock_receipt.id = 1 mock_receipt.amount = valid_ocr_result.amount mock_receipt.partner_name = valid_ocr_result.partner_name with patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD" ) as mock_crud, patch( "backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD" ) as mock_entry_crud, patch( "backend.modules.data_entry.services.receipt_auto_create.settings" ) as mock_settings: mock_crud.create = AsyncMock(return_value=mock_receipt) mock_entry_crud.create_bulk = AsyncMock() # Mock settings for attachment path mock_settings.data_entry_upload_path_resolved = Path(tempfile.gettempdir()) result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="test-job-123", ocr_result=valid_ocr_result, username="test_user", batch_id=1, company_id=1, file_path=temp_file, original_filename="bon.pdf", ) assert result.success is True assert result.receipt_id == 1 assert result.error_message is None mock_crud.create.assert_called_once() mock_session.commit.assert_called_once() @pytest.mark.asyncio async def test_validation_failure_returns_error(self, mock_session): """Test that validation failure returns error result.""" invalid_ocr = ExtractionData( receipt_date=date.today() + timedelta(days=5), # Future date amount=Decimal("100.00"), ) result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="test-job-123", ocr_result=invalid_ocr, username="test_user", batch_id=1, company_id=1, ) assert result.success is False assert result.error_message is not None assert "future" in result.error_message.lower() mock_session.commit.assert_not_called() @pytest.mark.asyncio async def test_zero_amount_returns_error(self, mock_session): """Test that zero amount returns error result.""" zero_amount_ocr = ExtractionData( receipt_date=date.today(), amount=Decimal("0"), ) result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="test-job-123", ocr_result=zero_amount_ocr, username="test_user", batch_id=1, company_id=1, ) assert result.success is False assert "amount" in result.error_message.lower() @pytest.mark.asyncio async def test_batch_job_updated_with_receipt_id(self, mock_session, valid_ocr_result): """Test that batch_jobs table is updated with receipt_id.""" mock_receipt = MagicMock() mock_receipt.id = 42 with patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD" ) as mock_crud, patch( "backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD" ) as mock_entry_crud: mock_crud.create = AsyncMock(return_value=mock_receipt) mock_entry_crud.create_bulk = AsyncMock() result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="test-job-456", ocr_result=valid_ocr_result, username="test_user", batch_id=1, company_id=1, ) assert result.success is True # Verify execute was called (for update batch_jobs) mock_session.execute.assert_called() @pytest.mark.asyncio async def test_accounting_entries_generated(self, mock_session, valid_ocr_result): """Test that accounting entries are generated for the receipt.""" mock_receipt = MagicMock() mock_receipt.id = 1 mock_receipt.amount = valid_ocr_result.amount mock_receipt.direction = ReceiptDirection.CHELTUIALA mock_receipt.expense_type_code = None mock_receipt.payment_mode = "banca" mock_receipt.cash_register_account = None with patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD" ) as mock_crud, patch( "backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD" ) as mock_entry_crud, patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptService" ) as mock_service: mock_crud.create = AsyncMock(return_value=mock_receipt) mock_entry_crud.create_bulk = AsyncMock() mock_service.generate_accounting_entries = MagicMock(return_value=[ MagicMock(), # Mock entries ]) result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="test-job-789", ocr_result=valid_ocr_result, username="test_user", batch_id=1, company_id=1, ) assert result.success is True mock_service.generate_accounting_entries.assert_called_once_with(mock_receipt) mock_entry_crud.create_bulk.assert_called_once() @pytest.mark.asyncio async def test_exception_triggers_rollback(self, mock_session, valid_ocr_result): """Test that exception during creation triggers rollback.""" with patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD" ) as mock_crud: mock_crud.create = AsyncMock(side_effect=Exception("Database error")) result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="test-job-err", ocr_result=valid_ocr_result, username="test_user", batch_id=1, company_id=1, ) assert result.success is False assert "Database error" in result.error_message mock_session.rollback.assert_called_once() @pytest.mark.asyncio async def test_attachment_creation_failure_doesnt_fail_receipt( self, mock_session, valid_ocr_result ): """Test that attachment creation failure doesn't fail receipt creation.""" mock_receipt = MagicMock() mock_receipt.id = 1 with patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD" ) as mock_crud, patch( "backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD" ) as mock_entry_crud: mock_crud.create = AsyncMock(return_value=mock_receipt) mock_entry_crud.create_bulk = AsyncMock() # Pass a non-existent file path result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="test-job-attach", ocr_result=valid_ocr_result, username="test_user", batch_id=1, company_id=1, file_path="/nonexistent/path/file.pdf", ) # Receipt should still be created successfully assert result.success is True assert result.receipt_id == 1 @pytest.mark.asyncio async def test_entry_generation_failure_doesnt_fail_receipt( self, mock_session, valid_ocr_result ): """Test that entry generation failure doesn't fail receipt creation.""" mock_receipt = MagicMock() mock_receipt.id = 1 with patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD" ) as mock_crud, patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptService" ) as mock_service: mock_crud.create = AsyncMock(return_value=mock_receipt) mock_service.generate_accounting_entries = MagicMock( side_effect=Exception("Entry generation failed") ) result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="test-job-entry-err", ocr_result=valid_ocr_result, username="test_user", batch_id=1, company_id=1, ) # Receipt should still be created successfully assert result.success is True assert result.receipt_id == 1 # ============================================================================ # Edge Cases # ============================================================================ class TestEdgeCases: """Test edge cases and boundary conditions.""" @pytest.mark.asyncio async def test_minimal_valid_ocr_creates_receipt(self, mock_session, minimal_ocr_result): """Test that minimal valid OCR result creates receipt.""" mock_receipt = MagicMock() mock_receipt.id = 1 with patch( "backend.modules.data_entry.services.receipt_auto_create.ReceiptCRUD" ) as mock_crud, patch( "backend.modules.data_entry.services.receipt_auto_create.AccountingEntryCRUD" ): mock_crud.create = AsyncMock(return_value=mock_receipt) result = await ReceiptAutoCreateService.create_from_ocr_result( session=mock_session, job_id="minimal-job", ocr_result=minimal_ocr_result, username="test_user", batch_id=1, company_id=1, ) assert result.success is True def test_empty_tva_entries_maps_to_none(self): """Test that empty TVA entries list maps to None.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=Decimal("100.00"), tva_entries=[], ) receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=ocr_result, company_id=1, ) assert receipt_data.tva_breakdown is None def test_empty_payment_methods_maps_to_none(self): """Test that empty payment methods list maps to None.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=Decimal("100.00"), payment_methods=[], ) receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=ocr_result, company_id=1, ) assert receipt_data.payment_methods is None def test_none_raw_text_handled(self): """Test that None raw text is handled correctly.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=Decimal("100.00"), raw_text="", ) receipt_data = ReceiptAutoCreateService._map_ocr_to_receipt( ocr_result=ocr_result, company_id=1, ) assert receipt_data.ocr_raw_text is None def test_very_small_amount_valid(self): """Test that very small positive amount is valid.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=Decimal("0.01"), ) is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result) assert is_valid is True def test_very_large_amount_valid(self): """Test that very large amount is valid.""" ocr_result = ExtractionData( receipt_date=date.today(), amount=Decimal("999999999.99"), ) is_valid, error = ReceiptAutoCreateService._validate_ocr_result(ocr_result) assert is_valid is True if __name__ == "__main__": pytest.main([__file__, "-v"])