import { test, expect } from '@playwright/test'; import { TEST_CONFIG, loginAsUser, setupErrorCapture, waitForToast, fillField, forceSubmitForm, } from './utils/test-helpers'; /** * Error Handling E2E Test Suite * * Tests error handling throughout the application: * - Network errors (offline, timeout, 500) * - Validation errors (form validation) * - API errors (400, 401, 403, 404, 500) * - Error boundaries (React error boundaries) * - User-friendly error messages * - Error recovery */ test.describe('Error Handling', () => { test.beforeEach(async ({ page }) => { setupErrorCapture(page); }); test.describe('Network Errors', () => { test.beforeEach(async ({ page }) => { await loginAsUser(page); }); test('should handle offline mode gracefully', async ({ page }) => { // Go offline await page.context().setOffline(true); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('domcontentloaded'); // Should show offline message or cached content const offlineIndicator = page.locator('text=offline, text=No internet, text=Connection lost').first(); const cachedContent = page.locator('[data-testid="tracks-list"], [data-testid="library"]').first(); const hasOfflineMessage = await offlineIndicator.isVisible({ timeout: 3000 }).catch(() => false); const hasCachedContent = await cachedContent.isVisible({ timeout: 3000 }).catch(() => false); expect(hasOfflineMessage || hasCachedContent).toBeTruthy(); // Go back online await page.context().setOffline(false); }); test('should handle API timeout errors', async ({ page }) => { // Intercept API calls and delay them to simulate timeout await page.route('**/api/v1/tracks**', async (route) => { await new Promise(resolve => setTimeout(resolve, 10000)); // 10 second delay route.abort('timedout'); }); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); // Should show timeout error or loading state const timeoutError = await waitForToast(page, 'error', 15000).catch(() => null); const loadingState = page.locator('text=Loading, [data-testid="loading"]').first(); expect(timeoutError !== null || await loadingState.isVisible({ timeout: 2000 }).catch(() => false)).toBeTruthy(); }); test('should handle 500 server errors', async ({ page }) => { // Intercept API calls and return 500 await page.route('**/api/v1/tracks**', (route) => { route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Internal Server Error' }), }); }); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); // Should show error message const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); expect(errorToast).toBeTruthy(); }); test('should handle 503 service unavailable', async ({ page }) => { await page.route('**/api/v1/tracks**', (route) => { route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'Service Unavailable' }), }); }); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); expect(errorToast).toBeTruthy(); }); }); test.describe('Authentication Errors', () => { test('should handle 401 unauthorized errors', async ({ page }) => { // Start unauthenticated test.use({ storageState: { cookies: [], origins: [] } }); // Try to access protected route await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); // Should redirect to login await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/(login|auth/login)`)); }); test('should handle invalid login credentials', async ({ page }) => { test.use({ storageState: { cookies: [], origins: [] } }); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/login`); await page.waitForLoadState('networkidle'); // Fill form with invalid credentials await fillField(page, 'input[type="email"]', 'invalid@example.com'); await fillField(page, 'input[type="password"]', 'wrongpassword'); const loginForm = page.locator('form').first(); await forceSubmitForm(page, loginForm); // Should show error message const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); const errorMessage = page.locator('text=Invalid, text=incorrect, text=wrong').first(); expect(errorToast !== null || await errorMessage.isVisible({ timeout: 3000 }).catch(() => false)).toBeTruthy(); }); test('should handle expired token gracefully', async ({ page }) => { await loginAsUser(page); // Simulate expired token by clearing it await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); // Try to access protected route await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); // Should redirect to login or show error const currentUrl = page.url(); const redirectedToLogin = currentUrl.includes('/login'); const errorShown = await waitForToast(page, 'error', 3000).catch(() => null); expect(redirectedToLogin || errorShown !== null).toBeTruthy(); }); }); test.describe('Validation Errors', () => { test.beforeEach(async ({ page }) => { await loginAsUser(page); }); test('should show validation errors for empty required fields', async ({ page }) => { await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); await page.waitForLoadState('networkidle'); // Try to submit empty form const registerForm = page.locator('form').first(); if (await registerForm.isVisible({ timeout: 2000 }).catch(() => false)) { await forceSubmitForm(page, registerForm); // Should show validation errors const emailError = page.locator('text=required, text=email').first(); const passwordError = page.locator('text=required, text=password').first(); const hasEmailError = await emailError.isVisible({ timeout: 2000 }).catch(() => false); const hasPasswordError = await passwordError.isVisible({ timeout: 2000 }).catch(() => false); expect(hasEmailError || hasPasswordError).toBeTruthy(); } }); test('should show validation error for invalid email format', async ({ page }) => { await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); await page.waitForLoadState('networkidle'); const emailInput = page.locator('input[type="email"]').first(); if (await emailInput.isVisible({ timeout: 2000 }).catch(() => false)) { await fillField(page, 'input[type="email"]', 'invalid-email'); // Blur to trigger validation await emailInput.blur(); // Should show validation error const emailError = page.locator('text=invalid, text=email format').first(); const hasError = await emailError.isVisible({ timeout: 2000 }).catch(() => false); // HTML5 validation might also show browser tooltip const isValid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valid); expect(hasError || !isValid).toBeTruthy(); } }); test('should show validation error for password mismatch', async ({ page }) => { await page.goto(`${TEST_CONFIG.FRONTEND_URL}/register`); await page.waitForLoadState('networkidle'); const passwordInput = page.locator('input[type="password"]').first(); const confirmPasswordInput = page.locator('input[name*="confirm"], input[name*="passwordConfirm"]').first(); if (await passwordInput.isVisible({ timeout: 2000 }).catch(() => false) && await confirmPasswordInput.isVisible({ timeout: 2000 }).catch(() => false)) { await fillField(page, 'input[type="password"]', 'password123'); await fillField(page, 'input[name*="confirm"], input[name*="passwordConfirm"]', 'different123'); // Blur to trigger validation await confirmPasswordInput.blur(); // Should show validation error const passwordError = page.locator('text=match, text=password, text=do not match').first(); const hasError = await passwordError.isVisible({ timeout: 2000 }).catch(() => false); expect(hasError).toBeTruthy(); } }); }); test.describe('API Error Responses', () => { test.beforeEach(async ({ page }) => { await loginAsUser(page); }); test('should handle 400 bad request errors', async ({ page }) => { await page.route('**/api/v1/tracks**', (route) => { route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ success: false, error: { code: 'VALIDATION_ERROR', message: 'Invalid request data' } }), }); }); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); expect(errorToast).toBeTruthy(); }); test('should handle 403 forbidden errors', async ({ page }) => { await page.route('**/api/v1/tracks/*/delete**', (route) => { route.fulfill({ status: 403, contentType: 'application/json', body: JSON.stringify({ success: false, error: { code: 'FORBIDDEN', message: 'You do not have permission to perform this action' } }), }); }); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); // Try to delete a track (if delete button exists) const deleteButton = page.locator('button[aria-label*="delete"], button[title*="delete"]').first(); if (await deleteButton.isVisible({ timeout: 2000 }).catch(() => false)) { await deleteButton.click(); const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); expect(errorToast).toBeTruthy(); } }); test('should handle 404 not found errors', async ({ page }) => { await page.route('**/api/v1/tracks/non-existent-id**', (route) => { route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ success: false, error: { code: 'NOT_FOUND', message: 'Track not found' } }), }); }); // Try to access non-existent track await page.goto(`${TEST_CONFIG.FRONTEND_URL}/tracks/non-existent-id`); await page.waitForLoadState('networkidle'); // Should show 404 message or redirect const notFoundMessage = page.locator('text=404, text=Not Found, text=not found').first(); const errorToast = await waitForToast(page, 'error', 3000).catch(() => null); expect(await notFoundMessage.isVisible({ timeout: 2000 }).catch(() => false) || errorToast !== null).toBeTruthy(); }); }); test.describe('Error Recovery', () => { test.beforeEach(async ({ page }) => { await loginAsUser(page); }); test('should allow retry after network error', async ({ page }) => { let requestCount = 0; await page.route('**/api/v1/tracks**', (route) => { requestCount++; if (requestCount === 1) { // First request fails route.abort('failed'); } else { // Subsequent requests succeed route.continue(); } }); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); // Should show error const errorToast = await waitForToast(page, 'error', 5000).catch(() => null); // Look for retry button const retryButton = page.locator('button:has-text("Retry"), button:has-text("Try again")').first(); if (await retryButton.isVisible({ timeout: 2000 }).catch(() => false)) { await retryButton.click(); // Should retry and succeed await page.waitForTimeout(2000); expect(requestCount).toBeGreaterThan(1); } else { // Retry might be automatic or not implemented expect(errorToast !== null || requestCount > 1).toBeTruthy(); } }); test('should clear errors when navigating away', async ({ page }) => { // Trigger an error await page.route('**/api/v1/tracks**', (route) => { route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Server Error' }), }); }); await page.goto(`${TEST_CONFIG.FRONTEND_URL}/library`); await page.waitForLoadState('networkidle'); // Error should be shown await waitForToast(page, 'error', 5000).catch(() => null); // Navigate away await page.goto(`${TEST_CONFIG.FRONTEND_URL}/dashboard`); await page.waitForLoadState('networkidle'); // Error toast should be gone (or dismissed) await page.waitForTimeout(1000); // This is hard to test directly, but navigation should work await expect(page).toHaveURL(new RegExp(`${TEST_CONFIG.FRONTEND_URL}/(dashboard)?`)); }); }); });