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>
This commit is contained in:
Claude Agent
2026-01-12 08:33:17 +00:00
parent b4a226409c
commit 7b3541403f
53 changed files with 15810 additions and 196 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Test package

View File

@@ -0,0 +1 @@
# Backend tests

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,388 @@
"""
Tests for cleanup service (US-008: Auto-Cleanup Erori După 7 Zile).
US-008 Acceptance Criteria:
- Funcție cleanup_expired_failed_receipts() în services/
- Background job/task rulează la startup și apoi zilnic
- Găsește receipts cu processing_status='failed' și processing_completed_at < now() - 7 days
- Șterge receipts-urile găsite și fișierele atașate din storage
- Loghează numărul de receipts șterse
- Salvează count în cache/memory pentru notificare la login
- pytest tests pass pentru cleanup logic
"""
import pytest
import asyncio
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import tempfile
import os
from backend.modules.data_entry.services.cleanup_service import (
cleanup_expired_failed_receipts,
get_last_cleanup_stats,
start_cleanup_task,
stop_cleanup_task,
is_cleanup_task_running,
CLEANUP_RETENTION_DAYS,
)
from backend.modules.data_entry.db.models.receipt import (
Receipt, ReceiptAttachment, ReceiptStatus, ReceiptDirection, ReceiptType
)
class MockAttachment:
"""Mock ReceiptAttachment for testing."""
def __init__(self, file_path: str):
self.id = 1
self.receipt_id = 1
self.filename = "test.jpg"
self.stored_filename = "uuid-test.jpg"
self.file_path = file_path
self.file_size = 1024
self.mime_type = "image/jpeg"
self.uploaded_at = datetime.utcnow()
class MockReceipt:
"""Mock Receipt for testing."""
def __init__(
self,
receipt_id: int,
processing_status: str = "failed",
processing_completed_at: datetime = None,
attachments: list = None
):
self.id = receipt_id
self.receipt_type = ReceiptType.BON_FISCAL
self.direction = ReceiptDirection.CHELTUIALA
self.receipt_number = f"NR-{receipt_id}"
self.receipt_series = "ABC"
self.receipt_date = datetime(2026, 1, 10).date()
self.amount = Decimal("100.50")
self.description = f"Test receipt {receipt_id}"
self.expense_type_code = "CONSUMABILE"
self.company_id = 1
self.partner_name = "Test Vendor"
self.cui = "12345678"
self.payment_mode = "casa"
self.status = ReceiptStatus.DRAFT
self.created_by = "test_user"
self.created_at = datetime.utcnow()
self.updated_at = datetime.utcnow()
self.batch_id = "test-batch-123"
self.processing_status = processing_status
self.processing_error = "OCR failed" if processing_status == "failed" else None
self.file_hash = "abc123hash"
self.processing_started_at = datetime.utcnow() - timedelta(days=10)
self.processing_completed_at = processing_completed_at
self.attachments = attachments or []
self.entries = []
# ============================================================================
# Unit Tests for cleanup_expired_failed_receipts
# ============================================================================
class TestCleanupExpiredFailedReceipts:
"""Test cleanup_expired_failed_receipts function."""
@pytest.mark.asyncio
async def test_no_expired_receipts(self):
"""Test cleanup returns 0 when no expired receipts exist."""
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
count = await cleanup_expired_failed_receipts(mock_session)
assert count == 0
mock_session.commit.assert_not_called()
@pytest.mark.asyncio
async def test_finds_expired_failed_receipts(self):
"""Test cleanup finds receipts older than 7 days with failed status."""
# Create receipt with processing_completed_at 10 days ago
old_date = datetime.utcnow() - timedelta(days=10)
mock_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=old_date)
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_receipt]
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
count = await cleanup_expired_failed_receipts(mock_session)
assert count == 1
mock_session.delete.assert_called_once_with(mock_receipt)
mock_session.commit.assert_called_once()
@pytest.mark.asyncio
async def test_does_not_delete_recent_failed_receipts(self):
"""Test cleanup does NOT delete receipts completed less than 7 days ago."""
# Create receipt with processing_completed_at 3 days ago (should NOT be deleted)
recent_date = datetime.utcnow() - timedelta(days=3)
mock_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=recent_date)
mock_session = AsyncMock()
mock_result = MagicMock()
# Simulate that the query returns empty (no expired receipts found)
mock_result.scalars.return_value.all.return_value = []
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
count = await cleanup_expired_failed_receipts(mock_session)
assert count == 0
mock_session.delete.assert_not_called()
@pytest.mark.asyncio
async def test_deletes_attachment_files_from_disk(self):
"""Test cleanup deletes attachment files from storage."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create test file
test_subdir = Path(temp_dir) / "receipts" / "1"
test_subdir.mkdir(parents=True)
test_file = test_subdir / "test.jpg"
test_file.write_bytes(b"fake image content")
# Create mock receipt with attachment pointing to the test file
old_date = datetime.utcnow() - timedelta(days=10)
attachment = MockAttachment(str(Path("receipts/1/test.jpg")))
mock_receipt = MockReceipt(
1,
processing_status="failed",
processing_completed_at=old_date,
attachments=[attachment]
)
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_receipt]
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path(temp_dir)
count = await cleanup_expired_failed_receipts(mock_session)
assert count == 1
# Verify file was deleted
assert not test_file.exists()
@pytest.mark.asyncio
async def test_handles_missing_attachment_files_gracefully(self):
"""Test cleanup handles missing files without crashing."""
old_date = datetime.utcnow() - timedelta(days=10)
attachment = MockAttachment("nonexistent/path/file.jpg")
mock_receipt = MockReceipt(
1,
processing_status="failed",
processing_completed_at=old_date,
attachments=[attachment]
)
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_receipt]
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
# Should not raise exception
count = await cleanup_expired_failed_receipts(mock_session)
assert count == 1
mock_session.delete.assert_called_once()
mock_session.commit.assert_called_once()
@pytest.mark.asyncio
async def test_updates_cleanup_stats(self):
"""Test cleanup updates stats for notification."""
old_date = datetime.utcnow() - timedelta(days=10)
mock_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=old_date)
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_receipt]
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
await cleanup_expired_failed_receipts(mock_session)
stats = get_last_cleanup_stats()
assert stats["count"] == 1
assert stats["timestamp"] is not None
@pytest.mark.asyncio
async def test_deletes_multiple_receipts(self):
"""Test cleanup deletes multiple expired receipts."""
old_date = datetime.utcnow() - timedelta(days=10)
mock_receipts = [
MockReceipt(1, processing_status="failed", processing_completed_at=old_date),
MockReceipt(2, processing_status="failed", processing_completed_at=old_date),
MockReceipt(3, processing_status="failed", processing_completed_at=old_date),
]
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = mock_receipts
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
count = await cleanup_expired_failed_receipts(mock_session)
assert count == 3
assert mock_session.delete.call_count == 3
# ============================================================================
# Tests for cleanup stats
# ============================================================================
class TestCleanupStats:
"""Test cleanup stats functionality."""
def test_get_last_cleanup_stats_returns_copy(self):
"""Test that get_last_cleanup_stats returns a copy, not the original dict."""
stats1 = get_last_cleanup_stats()
stats2 = get_last_cleanup_stats()
stats1["modified"] = True
assert "modified" not in stats2
# ============================================================================
# Tests for background task management
# ============================================================================
class TestCleanupTaskManagement:
"""Test background task start/stop functionality."""
@pytest.mark.asyncio
async def test_start_cleanup_task_returns_true(self):
"""Test start_cleanup_task returns True on success."""
async def mock_get_session():
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session.execute.return_value = mock_result
yield mock_session
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
success = await start_cleanup_task(mock_get_session)
assert success is True
assert is_cleanup_task_running() is True
# Cleanup
await stop_cleanup_task()
@pytest.mark.asyncio
async def test_stop_cleanup_task(self):
"""Test stop_cleanup_task stops the running task."""
async def mock_get_session():
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session.execute.return_value = mock_result
yield mock_session
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
await start_cleanup_task(mock_get_session)
assert is_cleanup_task_running() is True
await stop_cleanup_task()
# Give time for task to be cancelled
await asyncio.sleep(0.1)
assert is_cleanup_task_running() is False
# ============================================================================
# Tests for retention days configuration
# ============================================================================
class TestCleanupConfiguration:
"""Test cleanup configuration constants."""
def test_retention_days_is_7(self):
"""Verify retention period is 7 days as per US-008 requirements."""
assert CLEANUP_RETENTION_DAYS == 7
# ============================================================================
# Integration-style tests
# ============================================================================
class TestCleanupQueryLogic:
"""Test the query logic for finding expired receipts."""
@pytest.mark.asyncio
async def test_only_failed_status_is_deleted(self):
"""Test that only receipts with processing_status='failed' are deleted."""
# The query should filter by processing_status='failed'
# We verify this by checking that receipts with other statuses are not returned
old_date = datetime.utcnow() - timedelta(days=10)
# Create receipts with different statuses
failed_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=old_date)
completed_receipt = MockReceipt(2, processing_status="completed", processing_completed_at=old_date)
pending_receipt = MockReceipt(3, processing_status="pending", processing_completed_at=old_date)
# Only the failed receipt should be in the query results
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [failed_receipt] # Only failed
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
count = await cleanup_expired_failed_receipts(mock_session)
assert count == 1
mock_session.delete.assert_called_once_with(failed_receipt)
@pytest.mark.asyncio
async def test_receipts_without_processing_completed_at_are_not_deleted(self):
"""Test that receipts with NULL processing_completed_at are not deleted."""
# Receipts that never finished processing should not be deleted
mock_receipt = MockReceipt(1, processing_status="failed", processing_completed_at=None)
mock_session = AsyncMock()
mock_result = MagicMock()
# Query should return empty because processing_completed_at is NULL
mock_result.scalars.return_value.all.return_value = []
mock_session.execute.return_value = mock_result
with patch('backend.modules.data_entry.services.cleanup_service.settings') as mock_settings:
mock_settings.upload_path_resolved = Path("/tmp/uploads")
count = await cleanup_expired_failed_receipts(mock_session)
assert count == 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,642 @@
"""
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"])

View File

@@ -0,0 +1,545 @@
"""
Tests for receipt list endpoint with batch info (US-012).
US-012 Acceptance Criteria:
- Response includes new fields for each receipt: batch_id, processing_status,
processing_error, file_hash, processing_started_at, processing_completed_at
- Filtering on processing_status works (?processing_status=failed)
- Filtering on batch_id works (?batch_id=uuid)
- Sorting on processing_started_at works
- Response includes processing_stats with counts: {pending_count, processing_count,
completed_count, failed_count}
- pytest tests pass for all new filters
"""
import pytest
from datetime import datetime
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
from backend.modules.data_entry.routers.receipts import router
from backend.modules.data_entry.db.models.receipt import (
Receipt, ReceiptStatus, ReceiptDirection, ReceiptType
)
from backend.modules.data_entry.schemas.receipt import (
ReceiptResponse, ReceiptListResponse, ProcessingStats, ReceiptFilter
)
from shared.auth.dependencies import get_current_user
from backend.modules.data_entry.db.database import get_session
# Mock user for authentication
class MockCurrentUser:
username = "test_user"
user_id = 1
companies = ["1"]
permissions = ["data_entry"]
def create_mock_receipt(
receipt_id: int,
batch_id: str = None,
processing_status: str = None,
processing_error: str = None,
file_hash: str = None,
processing_started_at: datetime = None,
processing_completed_at: datetime = None,
) -> MagicMock:
"""Create a mock Receipt object with batch fields."""
receipt = MagicMock(spec=Receipt)
receipt.id = receipt_id
receipt.receipt_type = ReceiptType.BON_FISCAL
receipt.direction = ReceiptDirection.CHELTUIALA
receipt.receipt_number = f"NR-{receipt_id}"
receipt.receipt_series = "ABC"
receipt.receipt_date = datetime(2026, 1, 10).date()
receipt.amount = Decimal("100.50")
receipt.description = f"Test receipt {receipt_id}"
receipt.tva_breakdown = None
receipt.tva_total = None
receipt.items_count = None
receipt.vendor_address = None
receipt.expense_type_code = "CONSUMABILE"
receipt.company_id = 1
receipt.partner_name = "Test Vendor"
receipt.cui = "12345678"
receipt.ocr_raw_text = None
receipt.payment_methods = None
receipt.cash_register_id = None
receipt.cash_register_name = None
receipt.cash_register_account = None
receipt.payment_mode = "casa"
receipt.status = ReceiptStatus.DRAFT
receipt.created_by = "test_user"
receipt.created_at = datetime.utcnow()
receipt.updated_at = datetime.utcnow()
receipt.submitted_at = None
receipt.reviewed_by = None
receipt.reviewed_at = None
receipt.rejection_reason = None
receipt.oracle_synced_at = None
receipt.oracle_act_id = None
receipt.oracle_error = None
# Batch fields (US-012)
receipt.batch_id = batch_id
receipt.processing_status = processing_status
receipt.processing_error = processing_error
receipt.file_hash = file_hash
receipt.processing_started_at = processing_started_at
receipt.processing_completed_at = processing_completed_at
# Relationships
receipt.attachments = []
receipt.entries = []
return receipt
def create_test_app(mock_session=None, mock_user=None):
"""Create a test FastAPI app with the receipts router and dependency overrides."""
app = FastAPI()
app.include_router(router, prefix="/api/data-entry/receipts")
if mock_user is not None:
app.dependency_overrides[get_current_user] = lambda: mock_user
if mock_session is not None:
async def override_session():
yield mock_session
app.dependency_overrides[get_session] = override_session
return app
@pytest.fixture
def mock_current_user():
return MockCurrentUser()
@pytest.fixture
def mock_session():
"""Create a mock async session."""
session = AsyncMock()
return session
# ============================================================================
# Tests for Response Schema with Batch Fields
# ============================================================================
class TestReceiptResponseBatchFields:
"""Test that ReceiptResponse includes batch fields."""
def test_response_includes_batch_id(self):
"""Verify ReceiptResponse includes batch_id field."""
receipt = create_mock_receipt(1, batch_id="test-batch-uuid")
response = ReceiptResponse.model_validate(receipt)
assert response.batch_id == "test-batch-uuid"
def test_response_includes_processing_status(self):
"""Verify ReceiptResponse includes processing_status field."""
receipt = create_mock_receipt(1, processing_status="completed")
response = ReceiptResponse.model_validate(receipt)
assert response.processing_status == "completed"
def test_response_includes_processing_error(self):
"""Verify ReceiptResponse includes processing_error field."""
receipt = create_mock_receipt(1, processing_error="OCR failed: invalid image")
response = ReceiptResponse.model_validate(receipt)
assert response.processing_error == "OCR failed: invalid image"
def test_response_includes_file_hash(self):
"""Verify ReceiptResponse includes file_hash field."""
receipt = create_mock_receipt(1, file_hash="abc123hash")
response = ReceiptResponse.model_validate(receipt)
assert response.file_hash == "abc123hash"
def test_response_includes_processing_started_at(self):
"""Verify ReceiptResponse includes processing_started_at field."""
started_at = datetime(2026, 1, 10, 10, 30, 0)
receipt = create_mock_receipt(1, processing_started_at=started_at)
response = ReceiptResponse.model_validate(receipt)
assert response.processing_started_at == started_at
def test_response_includes_processing_completed_at(self):
"""Verify ReceiptResponse includes processing_completed_at field."""
completed_at = datetime(2026, 1, 10, 10, 35, 0)
receipt = create_mock_receipt(1, processing_completed_at=completed_at)
response = ReceiptResponse.model_validate(receipt)
assert response.processing_completed_at == completed_at
def test_response_batch_fields_are_optional(self):
"""Verify batch fields can be None."""
receipt = create_mock_receipt(1)
response = ReceiptResponse.model_validate(receipt)
assert response.batch_id is None
assert response.processing_status is None
assert response.processing_error is None
assert response.file_hash is None
assert response.processing_started_at is None
assert response.processing_completed_at is None
# ============================================================================
# Tests for ProcessingStats Schema
# ============================================================================
class TestProcessingStatsSchema:
"""Test ProcessingStats schema."""
def test_processing_stats_structure(self):
"""Verify ProcessingStats has correct structure."""
stats = ProcessingStats(
pending_count=5,
processing_count=2,
completed_count=10,
failed_count=1
)
assert stats.pending_count == 5
assert stats.processing_count == 2
assert stats.completed_count == 10
assert stats.failed_count == 1
def test_processing_stats_defaults_to_zero(self):
"""Verify ProcessingStats defaults all counts to 0."""
stats = ProcessingStats()
assert stats.pending_count == 0
assert stats.processing_count == 0
assert stats.completed_count == 0
assert stats.failed_count == 0
# ============================================================================
# Tests for ReceiptListResponse with processing_stats
# ============================================================================
class TestReceiptListResponseWithStats:
"""Test ReceiptListResponse includes processing_stats."""
def test_list_response_includes_processing_stats(self):
"""Verify ReceiptListResponse includes processing_stats field."""
receipt = create_mock_receipt(1)
stats = ProcessingStats(pending_count=1, processing_count=0, completed_count=5, failed_count=2)
response = ReceiptListResponse(
items=[ReceiptResponse.model_validate(receipt)],
total=1,
page=1,
page_size=20,
pages=1,
processing_stats=stats
)
assert response.processing_stats is not None
assert response.processing_stats.pending_count == 1
assert response.processing_stats.completed_count == 5
assert response.processing_stats.failed_count == 2
def test_list_response_processing_stats_is_optional(self):
"""Verify processing_stats can be None."""
receipt = create_mock_receipt(1)
response = ReceiptListResponse(
items=[ReceiptResponse.model_validate(receipt)],
total=1,
page=1,
page_size=20,
pages=1,
)
assert response.processing_stats is None
# ============================================================================
# Tests for ReceiptFilter with new fields
# ============================================================================
class TestReceiptFilterWithBatchFields:
"""Test ReceiptFilter includes batch filtering fields."""
def test_filter_includes_processing_status(self):
"""Verify ReceiptFilter includes processing_status field."""
filter_obj = ReceiptFilter(processing_status="failed")
assert filter_obj.processing_status == "failed"
def test_filter_includes_batch_id(self):
"""Verify ReceiptFilter includes batch_id field."""
filter_obj = ReceiptFilter(batch_id="test-batch-uuid")
assert filter_obj.batch_id == "test-batch-uuid"
def test_filter_includes_sort_by(self):
"""Verify ReceiptFilter includes sort_by field."""
filter_obj = ReceiptFilter(sort_by="processing_started_at")
assert filter_obj.sort_by == "processing_started_at"
def test_filter_new_fields_are_optional(self):
"""Verify new filter fields default to None."""
filter_obj = ReceiptFilter()
assert filter_obj.processing_status is None
assert filter_obj.batch_id is None
assert filter_obj.sort_by is None
# ============================================================================
# Integration Tests for List Endpoint with Filters
# ============================================================================
class TestListReceiptsEndpointWithFilters:
"""Test GET /api/data-entry/receipts endpoint with new filters."""
@pytest.mark.asyncio
async def test_list_receipts_returns_batch_fields(self, mock_session, mock_current_user):
"""Test that list endpoint returns batch fields in response."""
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
receipt = create_mock_receipt(
1,
batch_id="batch-123",
processing_status="completed",
file_hash="hash123",
processing_started_at=datetime(2026, 1, 10, 10, 0, 0),
processing_completed_at=datetime(2026, 1, 10, 10, 5, 0)
)
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
mock_crud.get_processing_stats = AsyncMock(return_value={
"pending_count": 0,
"processing_count": 0,
"completed_count": 1,
"failed_count": 0
})
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/data-entry/receipts/")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
item = data["items"][0]
assert item["batch_id"] == "batch-123"
assert item["processing_status"] == "completed"
assert item["file_hash"] == "hash123"
assert "processing_started_at" in item
assert "processing_completed_at" in item
@pytest.mark.asyncio
async def test_list_receipts_includes_processing_stats(self, mock_session, mock_current_user):
"""Test that list endpoint returns processing_stats in response."""
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
receipt = create_mock_receipt(1)
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
mock_crud.get_processing_stats = AsyncMock(return_value={
"pending_count": 2,
"processing_count": 1,
"completed_count": 5,
"failed_count": 1
})
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/data-entry/receipts/")
assert response.status_code == 200
data = response.json()
assert "processing_stats" in data
stats = data["processing_stats"]
assert stats["pending_count"] == 2
assert stats["processing_count"] == 1
assert stats["completed_count"] == 5
assert stats["failed_count"] == 1
@pytest.mark.asyncio
async def test_filter_by_processing_status(self, mock_session, mock_current_user):
"""Test filtering by processing_status query param."""
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
receipt = create_mock_receipt(1, processing_status="failed")
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
mock_crud.get_processing_stats = AsyncMock(return_value={
"pending_count": 0,
"processing_count": 0,
"completed_count": 0,
"failed_count": 1
})
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/data-entry/receipts/?processing_status=failed")
assert response.status_code == 200
# Verify filter was passed to CRUD
call_args = mock_crud.get_list.call_args
filters = call_args[0][1] # Second positional argument is filters
assert filters.processing_status == "failed"
@pytest.mark.asyncio
async def test_filter_by_batch_id(self, mock_session, mock_current_user):
"""Test filtering by batch_id query param."""
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
batch_uuid = "test-batch-uuid-123"
receipt = create_mock_receipt(1, batch_id=batch_uuid)
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
mock_crud.get_processing_stats = AsyncMock(return_value={
"pending_count": 0,
"processing_count": 0,
"completed_count": 1,
"failed_count": 0
})
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(f"/api/data-entry/receipts/?batch_id={batch_uuid}")
assert response.status_code == 200
# Verify filter was passed to CRUD
call_args = mock_crud.get_list.call_args
filters = call_args[0][1]
assert filters.batch_id == batch_uuid
@pytest.mark.asyncio
async def test_sort_by_processing_started_at(self, mock_session, mock_current_user):
"""Test sorting by processing_started_at query param."""
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
receipt = create_mock_receipt(1)
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
mock_crud.get_processing_stats = AsyncMock(return_value={
"pending_count": 0,
"processing_count": 0,
"completed_count": 0,
"failed_count": 0
})
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/data-entry/receipts/?sort_by=processing_started_at")
assert response.status_code == 200
# Verify sort_by was passed to CRUD
call_args = mock_crud.get_list.call_args
filters = call_args[0][1]
assert filters.sort_by == "processing_started_at"
@pytest.mark.asyncio
async def test_combined_filters(self, mock_session, mock_current_user):
"""Test combining batch_id, processing_status, and sort_by filters."""
app = create_test_app(mock_session=mock_session, mock_user=mock_current_user)
batch_uuid = "batch-abc-123"
receipt = create_mock_receipt(1, batch_id=batch_uuid, processing_status="completed")
with patch("backend.modules.data_entry.services.receipt_service.ReceiptCRUD") as mock_crud:
mock_crud.get_list = AsyncMock(return_value=([receipt], 1))
mock_crud.get_processing_stats = AsyncMock(return_value={
"pending_count": 0,
"processing_count": 0,
"completed_count": 1,
"failed_count": 0
})
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/api/data-entry/receipts/"
f"?batch_id={batch_uuid}"
f"&processing_status=completed"
f"&sort_by=processing_started_at"
)
assert response.status_code == 200
# Verify all filters were passed to CRUD
call_args = mock_crud.get_list.call_args
filters = call_args[0][1]
assert filters.batch_id == batch_uuid
assert filters.processing_status == "completed"
assert filters.sort_by == "processing_started_at"
# ============================================================================
# Tests for CRUD get_processing_stats
# ============================================================================
class TestCRUDGetProcessingStats:
"""Test ReceiptCRUD.get_processing_stats method."""
@pytest.mark.asyncio
async def test_get_processing_stats_returns_counts(self):
"""Test that get_processing_stats returns proper count structure."""
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
mock_session = AsyncMock()
# Mock query result
mock_result = MagicMock()
mock_result.all = MagicMock(return_value=[
MagicMock(processing_status="pending", count=2),
MagicMock(processing_status="processing", count=1),
MagicMock(processing_status="completed", count=5),
MagicMock(processing_status="failed", count=1),
])
mock_session.execute = AsyncMock(return_value=mock_result)
stats = await ReceiptCRUD.get_processing_stats(mock_session)
assert stats["pending_count"] == 2
assert stats["processing_count"] == 1
assert stats["completed_count"] == 5
assert stats["failed_count"] == 1
@pytest.mark.asyncio
async def test_get_processing_stats_with_company_filter(self):
"""Test that get_processing_stats respects company_id filter."""
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.all = MagicMock(return_value=[
MagicMock(processing_status="completed", count=3),
])
mock_session.execute = AsyncMock(return_value=mock_result)
stats = await ReceiptCRUD.get_processing_stats(mock_session, company_id=1)
assert stats["completed_count"] == 3
assert stats["pending_count"] == 0
assert stats["processing_count"] == 0
assert stats["failed_count"] == 0
@pytest.mark.asyncio
async def test_get_processing_stats_with_batch_filter(self):
"""Test that get_processing_stats respects batch_id filter."""
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.all = MagicMock(return_value=[
MagicMock(processing_status="pending", count=2),
MagicMock(processing_status="failed", count=1),
])
mock_session.execute = AsyncMock(return_value=mock_result)
stats = await ReceiptCRUD.get_processing_stats(mock_session, batch_id="batch-123")
assert stats["pending_count"] == 2
assert stats["failed_count"] == 1
assert stats["completed_count"] == 0
assert stats["processing_count"] == 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])

12
tests/conftest.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Pytest configuration for ROA2WEB tests.
This file ensures proper Python path setup for imports.
"""
import sys
from pathlib import Path
# Add project root to Python path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))