Complete implementation of multi-server Oracle database support: Backend: - Multi-pool Oracle with lazy loading per server - Email-to-server cache for automatic server discovery - JWT tokens include server_id claim - /auth/check-identity and /auth/check-email endpoints - /auth/my-servers endpoint for listing user's accessible servers - Server switch with password re-authentication Frontend: - New ServerSelector component for header dropdown - Multi-step login flow (identity → server → password) - Server switching from header with password modal - Mobile drawer menu with server selection - Dark mode support for all new components - URL bookmark support with ?server= query param Scripts: - Unified start.sh replacing start-prod.sh/start-test.sh - Unified ssh-tunnel.sh with multi-server support - Updated status.sh for new architecture Tests: - E2E tests for multi-server and single-server login flows - Backend unit tests for all new endpoints - Oracle multi-pool integration tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
325 lines
12 KiB
JavaScript
325 lines
12 KiB
JavaScript
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.sh test
|
|
* 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);
|
|
});
|
|
|
|
});
|