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:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test package
|
||||
1
tests/backend/__init__.py
Normal file
1
tests/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Backend tests
|
||||
1205
tests/backend/test_bulk_upload.py
Normal file
1205
tests/backend/test_bulk_upload.py
Normal file
File diff suppressed because it is too large
Load Diff
388
tests/backend/test_cleanup_service.py
Normal file
388
tests/backend/test_cleanup_service.py
Normal 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"])
|
||||
642
tests/backend/test_receipt_auto_create.py
Normal file
642
tests/backend/test_receipt_auto_create.py
Normal 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"])
|
||||
545
tests/backend/test_receipt_list_batch_info.py
Normal file
545
tests/backend/test_receipt_list_batch_info.py
Normal 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
12
tests/conftest.py
Normal 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))
|
||||
Reference in New Issue
Block a user