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:
324
e2e/bulk-upload.spec.js
Normal file
324
e2e/bulk-upload.spec.js
Normal file
@@ -0,0 +1,324 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* E2E Tests for Bulk Receipt Upload (US-005)
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. Start the test environment: ./start-test.sh
|
||||
* 2. Ensure backend is running on port 8000
|
||||
* 3. Ensure frontend is running on port 3000
|
||||
*
|
||||
* Run:
|
||||
* npm run test:e2e
|
||||
* npm run test:e2e:headed # With visible browser
|
||||
*
|
||||
* Test PDFs are located in: docs/data-entry/
|
||||
*/
|
||||
|
||||
// Test configuration
|
||||
const TEST_USER = {
|
||||
username: 'MARIUS M',
|
||||
password: '123',
|
||||
company: 'MARIUSM AUTO'
|
||||
};
|
||||
|
||||
// Sample test PDFs from docs/data-entry/
|
||||
const TEST_PDFS = [
|
||||
'benzina 13 iulie.pdf',
|
||||
'brick igiena 604.pdf',
|
||||
'electrobering igiena iulie 604.pdf'
|
||||
];
|
||||
|
||||
// Corrupted file for error handling test
|
||||
const CORRUPTED_FILE = 'test-corrupted.txt';
|
||||
|
||||
test.describe('Bulk Receipt Upload E2E Tests', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to login page
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for login page to load
|
||||
await page.waitForSelector('input[placeholder*="Utilizator"]', { timeout: 10000 });
|
||||
|
||||
// Fill login credentials
|
||||
await page.fill('input[placeholder*="Utilizator"]', TEST_USER.username);
|
||||
await page.fill('input[type="password"]', TEST_USER.password);
|
||||
|
||||
// Click login button
|
||||
await page.click('button:has-text("Autentificare")');
|
||||
|
||||
// Wait for redirect after login
|
||||
await page.waitForURL(/\/(reports|data-entry)/, { timeout: 15000 });
|
||||
|
||||
// Select company if modal appears
|
||||
const companyModal = page.locator('.company-select-modal');
|
||||
if (await companyModal.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await page.click(`text="${TEST_USER.company}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('should navigate to bulk upload page', async ({ page }) => {
|
||||
// Navigate to data entry module
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
|
||||
// Verify page title
|
||||
await expect(page.locator('h1')).toContainText('Upload Bonuri în Lot');
|
||||
|
||||
// Verify upload zone is visible
|
||||
await expect(page.locator('.bulk-upload-zone')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should upload 3 real PDF receipts and process them', async ({ page }) => {
|
||||
// Navigate to bulk upload page
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
// Get file paths
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.map(f => path.join(testFilesPath, f));
|
||||
|
||||
// Upload files via file input
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
// Verify files appear in the list
|
||||
await expect(page.locator('.bulk-file-list')).toBeVisible();
|
||||
await expect(page.locator('.file-item')).toHaveCount(3);
|
||||
|
||||
// Verify file names are shown
|
||||
for (const pdfName of TEST_PDFS) {
|
||||
await expect(page.locator(`.file-item:has-text("${pdfName}")`)).toBeVisible();
|
||||
}
|
||||
|
||||
// Verify total count message
|
||||
await expect(page.locator('text=/3 fișier/')).toBeVisible();
|
||||
|
||||
// Click process button
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Wait for upload to complete and processing to start
|
||||
await expect(page.locator('.bulk-progress-bar')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for all files to be processed (up to 3 minutes for OCR)
|
||||
await page.waitForFunction(() => {
|
||||
const progress = document.querySelector('.bulk-progress-bar');
|
||||
if (!progress) return false;
|
||||
const text = progress.textContent || '';
|
||||
// Check if all 3 are processed (3/3)
|
||||
return text.includes('3/3') || text.includes('100%');
|
||||
}, { timeout: 180000 });
|
||||
|
||||
// Verify summary modal appears
|
||||
await expect(page.locator('.bulk-summary-modal, .p-dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify success count
|
||||
await expect(page.locator('.success-count, .stat-success .stat-value')).toBeVisible();
|
||||
|
||||
// Get the number of successful receipts
|
||||
const successCount = await page.locator('.success-count, .stat-success .stat-value').textContent();
|
||||
console.log(`Successfully processed: ${successCount} receipts`);
|
||||
|
||||
// Verify total amount is displayed
|
||||
await expect(page.locator('.total-amount')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show receipts in list after processing', async ({ page }) => {
|
||||
// First upload and process files (abbreviated version)
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.slice(0, 1).map(f => path.join(testFilesPath, f));
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Wait for processing to complete
|
||||
await page.waitForFunction(() => {
|
||||
const modal = document.querySelector('.bulk-summary-modal, .p-dialog');
|
||||
return modal && window.getComputedStyle(modal).display !== 'none';
|
||||
}, { timeout: 120000 });
|
||||
|
||||
// Click "Vezi bonurile create" link
|
||||
await page.click('button:has-text("Vezi bonurile create")');
|
||||
|
||||
// Verify navigation to receipts list
|
||||
await expect(page).toHaveURL(/\/data-entry/);
|
||||
|
||||
// Verify receipts are listed
|
||||
await expect(page.locator('.receipts-table, .p-datatable')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should handle file removal before upload', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.map(f => path.join(testFilesPath, f));
|
||||
|
||||
// Upload files
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
// Verify 3 files are listed
|
||||
await expect(page.locator('.file-item')).toHaveCount(3);
|
||||
|
||||
// Click remove button on first file
|
||||
await page.locator('.file-item').first().locator('button.btn-remove, .pi-times').click();
|
||||
|
||||
// Verify only 2 files remain
|
||||
await expect(page.locator('.file-item')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('should clear all files with "Golește lista" button', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.map(f => path.join(testFilesPath, f));
|
||||
|
||||
// Upload files
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
// Verify files are listed
|
||||
await expect(page.locator('.file-item')).toHaveCount(3);
|
||||
|
||||
// Click clear button
|
||||
await page.click('button:has-text("Golește lista")');
|
||||
|
||||
// Verify list is empty
|
||||
await expect(page.locator('.file-item')).toHaveCount(0);
|
||||
await expect(page.locator('.bulk-file-list')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should show progress during processing', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = TEST_PDFS.slice(0, 2).map(f => path.join(testFilesPath, f));
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Verify progress bar appears
|
||||
await expect(page.locator('.bulk-progress-bar')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify progress list shows items
|
||||
await expect(page.locator('.bulk-progress-list')).toBeVisible();
|
||||
|
||||
// Verify status badges are shown
|
||||
await expect(page.locator('.status-badge, .p-tag')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should disable upload zone during processing', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = [path.join(testFilesPath, TEST_PDFS[0])];
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Wait for processing to start
|
||||
await expect(page.locator('.bulk-progress-bar')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify upload zone is disabled
|
||||
const uploadZone = page.locator('.bulk-upload-zone');
|
||||
await expect(uploadZone).toHaveClass(/disabled/);
|
||||
|
||||
// Verify process button is disabled
|
||||
await expect(page.locator('button:has-text("Procesează")')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should allow loading new batch after completion', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = [path.join(testFilesPath, TEST_PDFS[0])];
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Wait for summary modal
|
||||
await page.waitForFunction(() => {
|
||||
const modal = document.querySelector('.bulk-summary-modal, .p-dialog');
|
||||
return modal && window.getComputedStyle(modal).display !== 'none';
|
||||
}, { timeout: 120000 });
|
||||
|
||||
// Click "Încarcă alt batch" button
|
||||
await page.click('button:has-text("Încarcă alt batch")');
|
||||
|
||||
// Verify modal closes and upload zone is enabled
|
||||
await expect(page.locator('.bulk-summary-modal, .p-dialog')).not.toBeVisible();
|
||||
await expect(page.locator('.bulk-upload-zone')).toBeVisible();
|
||||
await expect(page.locator('.bulk-upload-zone')).not.toHaveClass(/disabled/);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
test.describe('Bulk Upload Error Handling', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('input[placeholder*="Utilizator"]', { timeout: 10000 });
|
||||
await page.fill('input[placeholder*="Utilizator"]', TEST_USER.username);
|
||||
await page.fill('input[type="password"]', TEST_USER.password);
|
||||
await page.click('button:has-text("Autentificare")');
|
||||
await page.waitForURL(/\/(reports|data-entry)/, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test('should reject files with invalid MIME type', async ({ page }) => {
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
// Create a text file (invalid type)
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
|
||||
// Try to set a text file - should be rejected by client-side validation
|
||||
// Note: This tests the accept attribute on the file input
|
||||
const acceptAttr = await fileInput.getAttribute('accept');
|
||||
expect(acceptAttr).toContain('pdf');
|
||||
expect(acceptAttr).toContain('image');
|
||||
});
|
||||
|
||||
test('should show error message when upload fails', async ({ page }) => {
|
||||
// Navigate to bulk upload with backend not running on a specific endpoint
|
||||
await page.goto('/data-entry/bulk-upload');
|
||||
await page.waitForSelector('.bulk-upload-zone', { timeout: 10000 });
|
||||
|
||||
const testFilesPath = path.join(process.cwd(), 'docs', 'data-entry');
|
||||
const filePaths = [path.join(testFilesPath, TEST_PDFS[0])];
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(filePaths);
|
||||
|
||||
// Intercept the upload request and make it fail
|
||||
await page.route('**/api/data-entry/bulk/upload', route => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ detail: 'Internal server error' })
|
||||
});
|
||||
});
|
||||
|
||||
await page.click('button:has-text("Procesează")');
|
||||
|
||||
// Verify error message appears
|
||||
await expect(page.locator('.error-message')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.error-message')).toContainText(/eroare|error/i);
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user