## 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>
389 lines
15 KiB
Python
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"])
|