Files
roa2web-service-auto/tests/backend/test_receipt_auto_create.py
Claude Agent 7b3541403f feat(data-entry): Bulk Receipt Upload cu Mobile UX Android Nativ
## Funcționalități Principale

### Bulk Upload & Processing
- Drag & drop pentru upload bonuri multiple oriunde pe pagină
- Batch processing cu job queue și worker pool
- Real-time updates via SSE (Server-Sent Events) cu fallback polling
- Duplicate detection via SHA-256 file hash
- Auto-retry pentru job-uri failed
- Cancel individual jobs sau batch complet

### Mobile UX - Android Native Style
- Top bar fixă cu hamburger, titlu centrat, acțiuni (search/filter)
- Bottom navigation cu 4 tab-uri (Bonuri, Upload, Rapoarte, Setări)
- FAB (Floating Action Button) cu hide/show on scroll
- Filter chips orizontal scrollabile
- Selecție multiplă prin long-press (500ms)
- Select All + Bulk Delete cu confirmare
- Layout Android pentru Create/Edit/View bon (Gmail compose style)

### Bug Fixes
- Refresh individual via SSE în loc de refresh total pagină
- Bonurile cu eroare OCR rămân vizibile pentru editare manuală
- Afișare nume fișier original pentru toate bonurile
- Upload stabil pe mobil (fix race condition File API)
- Păstrare ordine bonuri la refresh (nu se reordonează)

### Backend
- SSE endpoint pentru status updates real-time
- Bulk delete endpoint cu partial success
- Auto-cleanup bonuri failed după 7 zile
- Batch model cu tracking complet

### Testing
- E2E tests cu Playwright
- Unit tests pentru bulk upload, auto-create, cleanup

## Commits Squashed: 43 user stories (US-001 → US-043)
## Branch: ralph/bulk-receipt-upload
## Timp dezvoltare: ~3 zile (Ralph autonomous)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:33:17 +00:00

643 lines
24 KiB
Python

"""
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"])