Files
roa2web-service-auto/tests/backend/test_cleanup_service.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

389 lines
15 KiB
Python

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