const { test, expect } = require('@playwright/test'); const { HVACTestBase } = require('./page-objects/HVACTestBase'); const path = require('path'); const fs = require('fs'); /** * Featured Image Upload Test Suite * * Tests the drag-and-drop image upload system including: * - File validation (type, size, format) * - Drag and drop functionality * - Image preview generation * - File replacement and removal * - Security validation * - Error handling and recovery */ test.describe('Featured Image Upload System', () => { let hvacTest; const testImagesDir = path.join(__dirname, 'fixtures', 'images'); test.beforeAll(async () => { // Create test images directory if it doesn't exist if (!fs.existsSync(testImagesDir)) { fs.mkdirSync(testImagesDir, { recursive: true }); } // Create test images await createTestImages(testImagesDir); }); test.beforeEach(async ({ page }) => { hvacTest = new HVACTestBase(page); await hvacTest.loginAsTrainer(); await hvacTest.navigateToCreateEvent(); // Wait for upload component to be ready await expect(page.locator('.featured-image-upload')).toBeVisible(); }); test.describe('Basic Upload Functionality', () => { test('should initialize upload component correctly', async ({ page }) => { // Verify all upload elements are present await expect(page.locator('#featured-image-input')).toBeVisible(); await expect(page.locator('.upload-area')).toBeVisible(); await expect(page.locator('.upload-placeholder')).toBeVisible(); // Verify initial state const hasPreview = await page.locator('#image-preview').isVisible(); expect(hasPreview).toBe(false); // Verify drag and drop indicators await expect(page.locator('.drag-drop-text')).toContainText('Drag & drop'); }); test('should upload valid image file', async ({ page }) => { const imagePath = path.join(testImagesDir, 'valid-image.jpg'); await page.setInputFiles('#featured-image-input', imagePath); // Should show preview await expect(page.locator('#image-preview')).toBeVisible(); await expect(page.locator('#image-preview img')).toBeVisible(); // Should show file info await expect(page.locator('.image-info .filename')).toContainText('valid-image.jpg'); // Should hide upload placeholder await expect(page.locator('.upload-placeholder')).not.toBeVisible(); // Should show remove button await expect(page.locator('.remove-image-btn')).toBeVisible(); }); test('should display image preview with correct dimensions', async ({ page }) => { const imagePath = path.join(testImagesDir, 'large-image.jpg'); await page.setInputFiles('#featured-image-input', imagePath); await expect(page.locator('#image-preview img')).toBeVisible(); // Check preview dimensions are constrained const img = page.locator('#image-preview img'); const { width, height } = await img.boundingBox(); expect(width).toBeLessThanOrEqual(400); // Max preview width expect(height).toBeLessThanOrEqual(300); // Max preview height }); test('should show loading state during upload', async ({ page }) => { // Slow down the FileReader to test loading state await page.addInitScript(() => { const originalFileReader = window.FileReader; window.FileReader = function() { const reader = new originalFileReader(); const originalReadAsDataURL = reader.readAsDataURL; reader.readAsDataURL = function(file) { setTimeout(() => { originalReadAsDataURL.call(this, file); }, 1000); // 1 second delay }; return reader; }; }); const imagePath = path.join(testImagesDir, 'valid-image.jpg'); await page.setInputFiles('#featured-image-input', imagePath); // Should show loading indicator await expect(page.locator('.upload-loading')).toBeVisible(); // Wait for upload to complete await expect(page.locator('#image-preview img')).toBeVisible(); await expect(page.locator('.upload-loading')).not.toBeVisible(); }); }); test.describe('File Validation', () => { test('should accept valid image formats', async ({ page }) => { const validFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp']; for (const format of validFormats) { const imagePath = path.join(testImagesDir, `test-image.${format}`); await page.setInputFiles('#featured-image-input', imagePath); // Should show preview for valid formats await expect(page.locator('#image-preview img')).toBeVisible(); // Clear for next test await page.click('.remove-image-btn'); await expect(page.locator('#image-preview')).not.toBeVisible(); } }); test('should reject invalid file types', async ({ page }) => { const invalidFiles = [ 'document.pdf', 'script.js', 'text-file.txt', 'video.mp4', 'audio.mp3' ]; for (const filename of invalidFiles) { const filePath = path.join(testImagesDir, filename); await page.setInputFiles('#featured-image-input', filePath); // Should show error message await expect(page.locator('.upload-error')).toBeVisible(); await expect(page.locator('.upload-error')).toContainText('Invalid file type'); // Should not show preview await expect(page.locator('#image-preview img')).not.toBeVisible(); } }); test('should enforce file size limits', async ({ page }) => { const oversizedImagePath = path.join(testImagesDir, 'oversized-image.jpg'); await page.setInputFiles('#featured-image-input', oversizedImagePath); await expect(page.locator('.upload-error')).toBeVisible(); await expect(page.locator('.upload-error')).toContainText('File size exceeds 5MB limit'); }); test('should validate actual image content vs extension', async ({ page }) => { // File with .jpg extension but actually a text file const fakeImagePath = path.join(testImagesDir, 'fake-image.jpg'); await page.setInputFiles('#featured-image-input', fakeImagePath); await expect(page.locator('.upload-error')).toContainText('File content does not match extension'); }); test('should handle corrupted image files', async ({ page }) => { const corruptedImagePath = path.join(testImagesDir, 'corrupted-image.jpg'); await page.setInputFiles('#featured-image-input', corruptedImagePath); // Should show error for corrupted file await expect(page.locator('.upload-error')).toContainText('Unable to process image file'); }); }); test.describe('Drag and Drop Functionality', () => { test('should show visual feedback during drag over', async ({ page }) => { const imagePath = path.join(testImagesDir, 'valid-image.jpg'); const file = fs.readFileSync(imagePath); // Create drag data const dataTransfer = await page.evaluateHandle((fileData) => { const dt = new DataTransfer(); const file = new File([new Uint8Array(fileData)], 'valid-image.jpg', { type: 'image/jpeg' }); dt.items.add(file); return dt; }, Array.from(file)); const uploadArea = page.locator('.upload-area'); // Trigger dragenter event await uploadArea.dispatchEvent('dragenter', { dataTransfer }); // Should show drag-over state await expect(uploadArea).toHaveClass(/drag-over/); await expect(page.locator('.drag-indicator')).toBeVisible(); // Trigger dragleave event await uploadArea.dispatchEvent('dragleave', { dataTransfer }); // Should remove drag-over state await expect(uploadArea).not.toHaveClass(/drag-over/); }); test('should handle drop event with valid image', async ({ page }) => { const imagePath = path.join(testImagesDir, 'valid-image.jpg'); const file = fs.readFileSync(imagePath); await page.locator('.upload-area').dispatchEvent('drop', { dataTransfer: await page.evaluateHandle((fileData) => { const dt = new DataTransfer(); const file = new File([new Uint8Array(fileData)], 'valid-image.jpg', { type: 'image/jpeg' }); dt.items.add(file); return dt; }, Array.from(file)) }); // Should process dropped file await expect(page.locator('#image-preview img')).toBeVisible(); await expect(page.locator('.image-info .filename')).toContainText('valid-image.jpg'); }); test('should handle multiple files dropped (take first valid)', async ({ page }) => { const imagePath1 = path.join(testImagesDir, 'image1.jpg'); const imagePath2 = path.join(testImagesDir, 'image2.jpg'); const file1 = fs.readFileSync(imagePath1); const file2 = fs.readFileSync(imagePath2); await page.locator('.upload-area').dispatchEvent('drop', { dataTransfer: await page.evaluateHandle((fileData) => { const dt = new DataTransfer(); const file1 = new File([new Uint8Array(fileData.file1)], 'image1.jpg', { type: 'image/jpeg' }); const file2 = new File([new Uint8Array(fileData.file2)], 'image2.jpg', { type: 'image/jpeg' }); dt.items.add(file1); dt.items.add(file2); return dt; }, { file1: Array.from(file1), file2: Array.from(file2) }) }); // Should process only the first file await expect(page.locator('#image-preview img')).toBeVisible(); await expect(page.locator('.image-info .filename')).toContainText('image1.jpg'); // Should show warning about multiple files await expect(page.locator('.upload-warning')).toContainText('Only the first image was selected'); }); test('should prevent default browser behavior on drag events', async ({ page }) => { // Monitor for any navigation attempts let navigationAttempted = false; page.on('framenavigated', () => { navigationAttempted = true; }); const uploadArea = page.locator('.upload-area'); // Simulate drag events that might cause navigation await uploadArea.dispatchEvent('dragover'); await uploadArea.dispatchEvent('dragenter'); await uploadArea.dispatchEvent('drop'); await page.waitForTimeout(500); // Should not have attempted navigation expect(navigationAttempted).toBe(false); }); }); test.describe('Image Management', () => { test('should allow image replacement', async ({ page }) => { const firstImage = path.join(testImagesDir, 'image1.jpg'); const secondImage = path.join(testImagesDir, 'image2.jpg'); // Upload first image await page.setInputFiles('#featured-image-input', firstImage); await expect(page.locator('.image-info .filename')).toContainText('image1.jpg'); // Upload second image (should replace first) await page.setInputFiles('#featured-image-input', secondImage); await expect(page.locator('.image-info .filename')).toContainText('image2.jpg'); // Should only have one preview const previewCount = await page.locator('#image-preview img').count(); expect(previewCount).toBe(1); }); test('should allow image removal', async ({ page }) => { const imagePath = path.join(testImagesDir, 'valid-image.jpg'); await page.setInputFiles('#featured-image-input', imagePath); await expect(page.locator('#image-preview img')).toBeVisible(); // Click remove button await page.click('.remove-image-btn'); // Should hide preview and restore upload area await expect(page.locator('#image-preview')).not.toBeVisible(); await expect(page.locator('.upload-placeholder')).toBeVisible(); // Should clear file input const inputValue = await page.locator('#featured-image-input').inputValue(); expect(inputValue).toBe(''); }); test('should show image metadata', async ({ page }) => { const imagePath = path.join(testImagesDir, 'detailed-image.jpg'); await page.setInputFiles('#featured-image-input', imagePath); // Should show file information await expect(page.locator('.image-info .filename')).toBeVisible(); await expect(page.locator('.image-info .filesize')).toBeVisible(); await expect(page.locator('.image-info .dimensions')).toBeVisible(); // Verify information format const filesize = await page.locator('.image-info .filesize').textContent(); expect(filesize).toMatch(/\d+(\.\d+)?\s?(KB|MB)/); const dimensions = await page.locator('.image-info .dimensions').textContent(); expect(dimensions).toMatch(/\d+\s?×\s?\d+/); }); test('should handle image orientation correctly', async ({ page }) => { const portraitPath = path.join(testImagesDir, 'portrait-image.jpg'); const landscapePath = path.join(testImagesDir, 'landscape-image.jpg'); // Test portrait image await page.setInputFiles('#featured-image-input', portraitPath); await expect(page.locator('#image-preview img')).toBeVisible(); let imgBox = await page.locator('#image-preview img').boundingBox(); expect(imgBox.height).toBeGreaterThan(imgBox.width); await page.click('.remove-image-btn'); // Test landscape image await page.setInputFiles('#featured-image-input', landscapePath); await expect(page.locator('#image-preview img')).toBeVisible(); imgBox = await page.locator('#image-preview img').boundingBox(); expect(imgBox.width).toBeGreaterThan(imgBox.height); }); }); test.describe('Error Handling', () => { test('should handle FileReader errors gracefully', async ({ page }) => { // Mock FileReader to throw an error await page.addInitScript(() => { const originalFileReader = window.FileReader; window.FileReader = function() { const reader = new originalFileReader(); const originalReadAsDataURL = reader.readAsDataURL; reader.readAsDataURL = function(file) { setTimeout(() => { if (this.onerror) { this.onerror(new Error('FileReader error')); } }, 100); }; return reader; }; }); const imagePath = path.join(testImagesDir, 'valid-image.jpg'); await page.setInputFiles('#featured-image-input', imagePath); await expect(page.locator('.upload-error')).toContainText('Error processing image file'); }); test('should recover from upload errors', async ({ page }) => { const invalidPath = path.join(testImagesDir, 'invalid-file.txt'); // Try invalid file first await page.setInputFiles('#featured-image-input', invalidPath); await expect(page.locator('.upload-error')).toBeVisible(); // Should be able to upload valid file after error const validPath = path.join(testImagesDir, 'valid-image.jpg'); await page.setInputFiles('#featured-image-input', validPath); await expect(page.locator('.upload-error')).not.toBeVisible(); await expect(page.locator('#image-preview img')).toBeVisible(); }); test('should handle network errors during upload', async ({ page }) => { // Mock network failure for any upload requests await page.route('**/*upload*', route => route.abort()); const imagePath = path.join(testImagesDir, 'valid-image.jpg'); await page.setInputFiles('#featured-image-input', imagePath); // Should handle network error gracefully await expect(page.locator('.upload-error')).toContainText('Upload failed'); }); test('should validate against MIME type spoofing', async ({ page }) => { // File with image extension but text content const spoofedPath = path.join(testImagesDir, 'spoofed-image.jpg'); await page.setInputFiles('#featured-image-input', spoofedPath); await expect(page.locator('.upload-error')).toContainText('File content validation failed'); }); }); test.describe('Accessibility', () => { test('should support keyboard navigation', async ({ page }) => { // Focus should move to file input await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); // Navigate to upload area const focused = await page.evaluate(() => document.activeElement.classList.contains('upload-area') || document.activeElement.id === 'featured-image-input' ); expect(focused).toBe(true); // Enter key should trigger file dialog await page.keyboard.press('Enter'); // File dialog behavior varies by browser - just ensure no errors }); test('should have proper ARIA labels', async ({ page }) => { const uploadArea = page.locator('.upload-area'); const fileInput = page.locator('#featured-image-input'); // Check for accessibility attributes await expect(uploadArea).toHaveAttribute('role', 'button'); await expect(uploadArea).toHaveAttribute('aria-label'); await expect(fileInput).toHaveAttribute('aria-describedby'); }); test('should announce upload status to screen readers', async ({ page }) => { const imagePath = path.join(testImagesDir, 'valid-image.jpg'); // Monitor for aria-live announcements const announcements = []; page.on('console', msg => { if (msg.text().includes('aria-live')) { announcements.push(msg.text()); } }); await page.setInputFiles('#featured-image-input', imagePath); // Should announce successful upload await expect(page.locator('[aria-live="polite"]')).toContainText('Image uploaded successfully'); }); }); }); /** * Helper function to create test image files */ async function createTestImages(dir) { // Create minimal valid JPEG data const validJpeg = Buffer.from([ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48, 0x00, 0x48, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, // ... truncated for brevity - minimal JPEG data 0xFF, 0xD9 ]); const testFiles = { 'valid-image.jpg': validJpeg, 'test-image.jpeg': validJpeg, 'test-image.png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), // PNG header 'test-image.gif': Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // GIF header 'test-image.webp': Buffer.from([0x52, 0x49, 0x46, 0x46]), // WEBP header 'large-image.jpg': Buffer.concat([validJpeg, Buffer.alloc(1024 * 1024)]), // ~1MB 'oversized-image.jpg': Buffer.alloc(6 * 1024 * 1024), // 6MB 'fake-image.jpg': Buffer.from('This is not an image file'), 'corrupted-image.jpg': Buffer.from([0xFF, 0xD8, 0x00, 0x00]), // Invalid JPEG 'document.pdf': Buffer.from('%PDF-1.4'), 'script.js': Buffer.from('alert("test");'), 'text-file.txt': Buffer.from('Plain text content'), 'video.mp4': Buffer.from('ftypmp42'), 'audio.mp3': Buffer.from('ID3'), 'image1.jpg': validJpeg, 'image2.jpg': validJpeg, 'detailed-image.jpg': validJpeg, 'portrait-image.jpg': validJpeg, 'landscape-image.jpg': validJpeg, 'spoofed-image.jpg': Buffer.from('') }; for (const [filename, data] of Object.entries(testFiles)) { fs.writeFileSync(path.join(dir, filename), data); } }