379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
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)?`));
|
|
});
|
|
});
|
|
});
|
|
|