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

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

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

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

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

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

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

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

546 lines
22 KiB
Python

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