veza/tests/e2e/14-edge-cases.spec.ts
senke 6fad0ad68d fix: stabilize frontend — 98 TS errors to 0, align API endpoints, optimize bundle
- Fix 98 TypeScript errors across 37 files:
  - Service layer double-unwrapping (subscriptionService, distributionService, gearService)
  - Self-referencing variables in SearchPageResults
  - FeedView/ExploreView .posts→.items alignment
  - useQueueSync Zustand subscribe API
  - AdminAuditLogsView missing interface fields
  - Toast proxy type, interceptor type narrowing
  - 22 unused imports/variables removed
  - 5 storybook mock data fixes

- Align frontend API calls with backend endpoints:
  - Analytics: useAnalyticsView now calls /creator/analytics/dashboard (was /analytics)
  - Chat: chatService uses /conversations (was mock data), WS URL from backend token
  - Dashboard StatsSection: uses real /dashboard API data (was hardcoded zeros)
  - Settings: suppress 2FA toast error when endpoint unavailable

- Fix marketplace products: seed uses 'active' status (was 'published')
- Enrich seed: admin follows all creators (feed has content)

- Optimize bundle: vendor catch-all 793KB→318KB gzip (-60%)
  Split into vendor-charts, vendor-emoji, vendor-swagger, vendor-media, etc.

- Clean repo: remove ~100 orphaned screenshots, audit reports, logs from root

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:18:49 +01:00

567 lines
22 KiB
TypeScript

import { test, expect } from '@chromatic-com/playwright';
import { loginViaAPI,
CONFIG,
navigateTo,
assertNotBroken,
assertNoDebugText,
collectNetworkErrors,
playFirstTrack,
SELECTORS,
} from './helpers';
// =============================================================================
// EDGE CASES — Formulaires vides
// =============================================================================
test.describe('EDGE CASES — Formulaires vides', () => {
test('01. Submit empty login form shows validation errors', async ({ page }) => {
await navigateTo(page, '/login');
// Click submit without filling anything
const submitBtn = page.getByRole('button', { name: /sign in|se connecter/i });
await submitBtn.click();
// Should stay on login page
await expect(page).toHaveURL(/login/);
// Should show validation error(s) or HTML5 validation prevents submission
const body = await page.textContent('body') || '';
const hasValidation = /required|obligatoire|email|invalid|invalide/i.test(body);
const emailInput = page.locator('input[type="email"]').first();
const validationMessage = await emailInput.evaluate(
(el: HTMLInputElement) => el.validationMessage,
).catch(() => '');
expect(hasValidation || validationMessage.length > 0).toBeTruthy();
console.log(` Empty login form: validation shown (${validationMessage || 'custom error'})`);
});
test('02. Submit empty register form shows validation errors', async ({ page }) => {
await navigateTo(page, '/register');
// Click submit without filling anything
const submitBtn = page.getByRole('button', { name: /s'inscrire|create account/i });
await submitBtn.click();
// Should stay on register page
await expect(page).toHaveURL(/register/);
// Check for validation errors
const body = await page.textContent('body') || '';
const hasValidation = /required|obligatoire|invalid|invalide|trop court|too short/i.test(body);
const usernameInput = page.locator('#register-username');
const validationMessage = await usernameInput.evaluate(
(el: HTMLInputElement) => el.validationMessage,
).catch(() => '');
expect(hasValidation || validationMessage.length > 0).toBeTruthy();
console.log(' Empty register form: validation shown');
});
test('03. Submit empty search does not crash', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await navigateTo(page, '/search');
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (await searchInput.first().isVisible().catch(() => false)) {
// Clear the input and press Enter
await searchInput.first().fill('');
await searchInput.first().press('Enter');
await page.waitForTimeout(1_000);
await assertNotBroken(page);
console.log(' Empty search: no crash');
}
});
test('04. Login with only email filled shows password error', async ({ page }) => {
await navigateTo(page, '/login');
await page.getByLabel(/^email$/i).or(page.locator('input[type="email"]')).first().fill('test@example.com');
await page.getByRole('button', { name: /sign in|se connecter/i }).click();
// Should stay on login
await expect(page).toHaveURL(/login/);
// Password field should show validation
const passwordInput = page.locator('input[type="password"]').first();
const validationMessage = await passwordInput.evaluate(
(el: HTMLInputElement) => el.validationMessage,
).catch(() => '');
const body = await page.textContent('body') || '';
const hasError = validationMessage.length > 0 || /required|password|mot de passe/i.test(body);
console.log(` Partial login form: ${hasError ? 'validation shown' : 'no explicit error'}`);
});
});
// =============================================================================
// EDGE CASES — Caracteres speciaux et injection
// =============================================================================
test.describe('EDGE CASES — Caracteres speciaux', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('05. XSS attempt in search does not execute @critical', async ({ page }) => {
await navigateTo(page, '/search');
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (!(await searchInput.first().isVisible().catch(() => false))) return;
const xssPayload = '<script>alert("xss")</script>';
await searchInput.first().fill(xssPayload);
await page.waitForTimeout(1_500);
// Verify no alert dialog appeared
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError/i);
// The script tag should be sanitized — not rendered as HTML
const scriptElements = await page.locator('script:has-text("xss")').count();
expect(scriptElements).toBe(0);
console.log(' XSS payload sanitized');
});
test('06. SQL injection attempt in search does not crash @critical', async ({ page }) => {
await navigateTo(page, '/search');
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (!(await searchInput.first().isVisible().catch(() => false))) return;
const sqlPayload = "'; DROP TABLE users; --";
await searchInput.first().fill(sqlPayload);
await page.waitForTimeout(1_500);
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError|syntax error|SQL/i);
console.log(' SQL injection: no crash');
});
test('07. Very long string in search does not crash', async ({ page }) => {
await navigateTo(page, '/search');
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (!(await searchInput.first().isVisible().catch(() => false))) return;
const longString = 'a'.repeat(600);
await searchInput.first().fill(longString);
await page.waitForTimeout(1_500);
await assertNotBroken(page);
console.log(' Long string (600 chars): no crash');
});
test('08. Emoji search works without crash', async ({ page }) => {
await navigateTo(page, '/search');
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (!(await searchInput.first().isVisible().catch(() => false))) return;
await searchInput.first().fill('music vibes');
await page.waitForTimeout(1_500);
await assertNotBroken(page);
console.log(' Emoji search: no crash');
});
test('09. Unicode and special characters in search', async ({ page }) => {
await navigateTo(page, '/search');
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (!(await searchInput.first().isVisible().catch(() => false))) return;
const specialChars = 'cafe\u0301 mu\u0308sik \u00e9l\u00e8ve \u00f1';
await searchInput.first().fill(specialChars);
await page.waitForTimeout(1_500);
await assertNotBroken(page);
console.log(' Unicode search: no crash');
});
test('10. HTML entities in login email field', async ({ page }) => {
// This test needs to be on the login page, so clear the authenticated state
// (beforeEach logged in via API — we need to undo that for this test)
await page.evaluate(() => localStorage.removeItem('auth-storage'));
await page.context().clearCookies();
await navigateTo(page, '/login');
// Wait for the login form to be fully visible before interacting
await page.getByTestId('login-form').waitFor({ state: 'visible', timeout: 15_000 });
const emailInput = page.locator('input[type="email"]');
await emailInput.first().waitFor({ state: 'visible', timeout: 10_000 });
await emailInput.first().fill('test&amp;<b>bold</b>@example.com');
await page.getByLabel(/^password$|^mot de passe$/i).or(page.locator('input[type="password"]')).first().fill('Password123!');
await page.getByRole('button', { name: /sign in|se connecter/i }).click();
// Should show error (invalid email format), not crash
await page.waitForTimeout(1_500);
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError/i);
console.log(' HTML in email field: no crash');
});
});
// =============================================================================
// EDGE CASES — Erreurs reseau
// =============================================================================
test.describe('EDGE CASES — Erreurs reseau', () => {
test('11. Simulated 500 error on API shows error message, no crash @critical', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
// Intercept a common API route and return 500
await page.route('**/api/v1/tracks**', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: 'Simulated server error' } }),
});
});
await navigateTo(page, '/library');
await page.waitForTimeout(2_000);
// Page should not crash — should show an error state or empty state
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError|Cannot read/i);
expect(body.length).toBeGreaterThan(50);
console.log(' 500 error handled gracefully');
});
test('12. Simulated network timeout shows loading or error state', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
// Intercept API and simulate a timeout (abort after delay)
await page.route('**/api/v1/search**', async (route) => {
// Delay then abort to simulate timeout
await new Promise((resolve) => setTimeout(resolve, 5_000));
route.abort('timedout');
});
await navigateTo(page, '/search');
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (await searchInput.first().isVisible().catch(() => false)) {
await searchInput.first().fill('timeout test');
// Wait a moment - should show loading indicator or remain stable
await page.waitForTimeout(2_000);
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError|Cannot read/i);
console.log(' Network timeout: no crash');
}
});
test('13. API returning malformed JSON does not crash page', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await page.route('**/api/v1/tracks**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: '{ invalid json !!!',
});
});
await navigateTo(page, '/library');
await page.waitForTimeout(2_000);
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError|Unexpected token/i);
expect(body.length).toBeGreaterThan(50);
console.log(' Malformed JSON: no crash');
});
});
// =============================================================================
// EDGE CASES — Ressources inexistantes
// =============================================================================
test.describe('EDGE CASES — Ressources inexistantes', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
});
test('14. /tracks/nonexistent-id shows 404 or error page @critical', async ({ page }) => {
await navigateTo(page, '/tracks/nonexistent-id-99999');
const body = await page.textContent('body') || '';
// Should show a 404 page, error message, or redirect — not crash
const handled = /not found|introuvable|404|error|does not exist|n'existe pas/i.test(body) ||
page.url().includes('/404') ||
page.url().includes('/dashboard');
expect(body).not.toMatch(/crash|TypeError|Cannot read/i);
console.log(` /tracks/nonexistent: ${handled ? 'handled' : 'page loaded (check behavior)'}`);
});
test('15. /playlists/nonexistent-id shows 404 or error page', async ({ page }) => {
await navigateTo(page, '/playlists/nonexistent-id-99999');
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError|Cannot read/i);
const handled = /not found|introuvable|404|error/i.test(body) ||
page.url().includes('/404');
console.log(` /playlists/nonexistent: ${handled ? 'handled' : 'page loaded'}`);
});
test('16. /u/nonexistent-user shows 404 or error page', async ({ page }) => {
await navigateTo(page, '/u/this-user-does-not-exist-at-all');
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError|Cannot read/i);
const handled = /not found|introuvable|404|error|n'existe pas/i.test(body) ||
page.url().includes('/404');
console.log(` /u/nonexistent: ${handled ? 'handled' : 'page loaded'}`);
});
test('17. Completely unknown route shows 404 page', async ({ page }) => {
await navigateTo(page, '/this-route-definitely-does-not-exist');
// Wait a bit for redirects to settle
await page.waitForTimeout(2_000);
const body = await page.textContent('body') || '';
// Should show 404 page or redirect, not blank or crash
expect(body).not.toMatch(/crash|TypeError/i);
// Body should have some content (at least a heading or navigation)
expect(body.trim().length).toBeGreaterThan(10);
const is404 = /404|not found|introuvable|page not found/i.test(body) ||
page.url().includes('/404');
console.log(` Unknown route: ${is404 ? '404 shown' : 'redirected or fallback'}`);
});
test('18. /marketplace/products/nonexistent-id handles gracefully', async ({ page }) => {
await navigateTo(page, '/marketplace/products/nonexistent-product-id');
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError|Cannot read/i);
console.log(' /marketplace/products/nonexistent: no crash');
});
});
// =============================================================================
// EDGE CASES — Double actions
// =============================================================================
test.describe('EDGE CASES — Double actions', () => {
test('19. Double-click on login submit does not cause duplicate requests @critical', async ({ page }) => {
await navigateTo(page, '/login');
await page.getByLabel(/^email$/i).or(page.locator('input[type="email"]')).first().fill(CONFIG.users.listener.email);
await page.getByLabel(/^password$|^mot de passe$/i).or(page.locator('input[type="password"]')).first().fill(CONFIG.users.listener.password);
// Track API calls
const loginRequests: string[] = [];
page.on('request', (req) => {
if (req.url().includes('/auth/login') && req.method() === 'POST') {
loginRequests.push(req.url());
}
});
const submitBtn = page.getByRole('button', { name: /sign in|se connecter/i });
// Double-click rapidly
await submitBtn.dblclick();
// Wait for response
await page.waitForTimeout(3_000);
// Should have sent at most 2 requests (double-click), ideally 1 if debounced
console.log(` Login requests sent: ${loginRequests.length}`);
// The page should not crash regardless
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError/i);
});
test('20. Rapid page navigation does not crash the app', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
// Click through pages rapidly without waiting
const pages = ['/dashboard', '/library', '/discover', '/search', '/playlists', '/profile', '/settings'];
for (const route of pages) {
page.goto(route, { waitUntil: 'commit' }).catch(() => {});
// Minimal delay to trigger navigation
await page.waitForTimeout(200);
}
// Wait for final page to settle
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(3_000);
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError|Cannot read/i);
// During rapid navigation, body may be minimal — just ensure no crash
expect(body.trim().length).toBeGreaterThan(10);
console.log(' Rapid navigation: no crash');
});
test('21. Double-click on like button toggles correctly', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await navigateTo(page, '/discover');
// Find a like button
const likeBtn = page.getByRole('button', { name: /ajouter aux favoris|add to favorites/i }).first();
if (!(await likeBtn.isVisible().catch(() => false))) {
console.log(' No like button visible (skipping)');
return;
}
// Double-click to toggle like twice
await likeBtn.dblclick();
await page.waitForTimeout(1_000);
// Should not crash — state may or may not have changed
await assertNotBroken(page);
console.log(' Double-click like: no crash');
});
});
// =============================================================================
// EDGE CASES — Etat du navigateur
// =============================================================================
test.describe('EDGE CASES — Etat du navigateur', () => {
test('22. Clearing localStorage forces re-login', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await page.waitForTimeout(2_000);
// Clear auth storage
await page.evaluate(() => {
localStorage.removeItem('auth-storage');
localStorage.clear();
});
// Navigate to a protected page
await navigateTo(page, '/dashboard');
await page.waitForTimeout(2_000);
// Should redirect to login or show unauthenticated state
const url = page.url();
const isLoggedOut = url.includes('/login') || url.includes('/register');
console.log(` After clearing storage: ${isLoggedOut ? 'redirected to login' : 'still on ' + url}`);
});
test('23. Accessing app with expired/invalid token shows login', async ({ page }) => {
// Set an invalid auth state
await page.goto('/login', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
localStorage.setItem('auth-storage', JSON.stringify({
state: { isAuthenticated: true, isLoading: false, error: null },
version: 1,
}));
});
// Try to access protected page with fake auth
await navigateTo(page, '/dashboard');
await page.waitForTimeout(3_000);
// The API should reject the invalid session and redirect to login
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError/i);
console.log(` Invalid token: ended up at ${page.url()}`);
});
test('24. Page loads correctly with JavaScript-disabled cookies notice', async ({ page }) => {
// Verify the page loads and doesn't depend on cookies being pre-set
await page.context().clearCookies();
await navigateTo(page, '/login');
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/crash|TypeError/i);
expect(body.length).toBeGreaterThan(50);
console.log(' Clean cookie state: login page loads');
});
});
// =============================================================================
// EDGE CASES — Concurrent interactions
// =============================================================================
test.describe('EDGE CASES — Interactions concurrentes', () => {
test('25. Multiple search queries in quick succession', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await navigateTo(page, '/search');
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (!(await searchInput.first().isVisible().catch(() => false))) return;
// Type multiple queries rapidly to test debounce handling
const queries = ['rock', 'jazz', 'electronic', 'hip hop', 'classical'];
for (const query of queries) {
await searchInput.first().fill(query);
await page.waitForTimeout(100); // Very short delay between queries
}
// Wait for the final debounced search to resolve
await page.waitForTimeout(2_000);
await assertNotBroken(page);
console.log(' Rapid search queries: no crash');
});
test('26. Opening search while player is active does not break either', async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
// Start playing a track
await navigateTo(page, '/discover');
// Play first track — hover on card then click play button
const trackCard = page.locator('[role="article"]').first();
if (await trackCard.isVisible().catch(() => false)) {
await trackCard.hover();
await page.waitForTimeout(300);
}
const playBtn = page.getByRole('button', { name: /^lire |^play /i }).first()
.or(page.locator('[aria-label*="Lire"]').first())
.or(page.locator('[aria-label*="Play"]').first());
if (await playBtn.isVisible().catch(() => false)) {
await playBtn.click();
await page.waitForTimeout(CONFIG.timeouts.animation);
}
// Navigate to search while track might be playing
await navigateTo(page, '/search');
await assertNotBroken(page);
// Player should still be visible if it was active
const player = page.getByTestId('global-player');
const playerStillThere = await player.isVisible().catch(() => false);
console.log(` Player after search nav: ${playerStillThere ? 'still visible' : 'not visible (no track was playing)'}`);
// Search should work
const searchInput = page.locator('input[role="combobox"][aria-label="Search"]')
.or(page.getByPlaceholder(/search for tracks/i));
if (await searchInput.first().isVisible().catch(() => false)) {
await searchInput.first().fill('test');
await page.waitForTimeout(1_500);
await assertNotBroken(page);
}
console.log(' Search + player coexist: no crash');
});
});