feat(web): update all features, stories, e2e tests, and auth interceptor
Update auth, playlists, tracks, search, profile, dashboard, player,
settings, and social features. Add e2e audit specs for all major pages.
Update ESLint config, vitest config, and route configuration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:16:36 +00:00
|
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
|
import { CONFIG, loginViaAPI } from './helpers';
|
|
|
|
|
|
|
|
|
|
const BASE = CONFIG.baseURL;
|
|
|
|
|
const VALID_USERNAME = CONFIG.users.admin.username; // admin_veza
|
2026-04-02 17:42:03 +00:00
|
|
|
const LISTENER_USERNAME = CONFIG.users.listener.username; // music_fan
|
feat(web): update all features, stories, e2e tests, and auth interceptor
Update auth, playlists, tracks, search, profile, dashboard, player,
settings, and social features. Add e2e audit specs for all major pages.
Update ESLint config, vitest config, and route configuration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:16:36 +00:00
|
|
|
|
|
|
|
|
test.describe('Profil public utilisateur (/u/:username)', () => {
|
|
|
|
|
test.describe('Chargement & Rendu', () => {
|
|
|
|
|
test('la page se charge sans erreur pour un visiteur non connecté', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// Should NOT redirect to /login (BUG #1 regression)
|
|
|
|
|
expect(page.url()).toContain(`/u/${VALID_USERNAME}`);
|
|
|
|
|
|
|
|
|
|
// Profile name should be visible
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la page se charge pour un utilisateur connecté', async ({ page }) => {
|
|
|
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
expect(page.url()).toContain(`/u/${VALID_USERNAME}`);
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('le titre de page contient le nom du profil', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// BUG #9 regression: title should include username
|
|
|
|
|
await expect(page).toHaveTitle(new RegExp(VALID_USERNAME));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('le skeleton de chargement est affiché', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'commit' });
|
|
|
|
|
// The skeleton should appear with role="status" and aria-label
|
|
|
|
|
const skeleton = page.locator('[role="status"]');
|
|
|
|
|
// It may or may not still be visible depending on load speed
|
|
|
|
|
// Just verify it was rendered at some point (not a hard requirement)
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Fonctionnalités', () => {
|
|
|
|
|
test('affiche les infos publiques (avatar, bio, stats)', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// Username heading
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// @ handle
|
|
|
|
|
await expect(page.getByText(`@`)).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Member since date
|
|
|
|
|
await expect(page.getByText(/Joined|Membre depuis/)).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Stats section - should have tracks, playlists, followers, following
|
|
|
|
|
await expect(page.getByText(/Tracks|Morceaux|Pistas/)).toBeVisible();
|
|
|
|
|
await expect(page.getByText(/Followers|Abonnés|Seguidores/)).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('les onglets affichent du contenu (Tracks, Playlists, Reposts, Feed)', async ({ page }) => {
|
|
|
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// BUG #2 regression: tab content should render
|
|
|
|
|
// Tracks tab (default) should show tabpanel
|
|
|
|
|
const tracksPanel = page.getByRole('tabpanel');
|
|
|
|
|
await expect(tracksPanel).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Click Playlists tab
|
|
|
|
|
await page.getByRole('tab', { name: /Playlists/ }).click();
|
|
|
|
|
await expect(page.getByRole('tabpanel')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Click Reposts tab
|
|
|
|
|
await page.getByRole('tab', { name: /Reposts/ }).click();
|
|
|
|
|
await expect(page.getByRole('tabpanel')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Click Feed tab
|
|
|
|
|
await page.getByRole('tab', { name: /Feed|Fil/ }).click();
|
|
|
|
|
await expect(page.getByRole('tabpanel')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('page 404 avec username inexistant', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/nonexistent_user_xyz_test`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// BUG #3 regression: should show "User Not Found" not "Something went wrong"
|
|
|
|
|
await expect(page.getByRole('heading', { level: 2 })).toContainText(/Not Found|introuvable|no encontrado/);
|
|
|
|
|
|
|
|
|
|
// Should NOT have "Try again" button for 404
|
|
|
|
|
await expect(page.getByRole('button', { name: /Try again|Réessayer|Reintentar/ })).not.toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Should have "Return to Base" link
|
|
|
|
|
await expect(page.getByRole('link', { name: /Return to Base|Retour|Volver/ })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('bouton follow visible pour un autre utilisateur connecté', async ({ page }) => {
|
|
|
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// Follow button should be visible (viewing another user's profile)
|
|
|
|
|
await expect(page.getByRole('button', { name: /Follow|Suivre|Seguir/ })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('bouton follow masqué sur son propre profil', async ({ page }) => {
|
|
|
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
|
|
|
await page.goto(`${BASE}/u/${LISTENER_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// Follow button should NOT be visible on own profile
|
|
|
|
|
await expect(page.getByRole('button', { name: /Follow|Suivre|Seguir/ })).not.toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('bouton follow masqué pour visiteur non connecté', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// Follow button should NOT be visible when not logged in
|
|
|
|
|
await expect(page.getByRole('button', { name: /Follow|Suivre|Seguir/ })).not.toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Sécurité', () => {
|
|
|
|
|
test('XSS dans le paramètre username est neutralisé', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/%3Cscript%3Ealert(1)%3C%2Fscript%3E`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// Should show error page, not execute script
|
|
|
|
|
expect(page.url()).not.toContain('login'); // Should not redirect
|
|
|
|
|
// No alert dialog should have appeared (React escapes by default)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pas de fuite de token dans l\'URL ou le DOM', async ({ page }) => {
|
|
|
|
|
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// URL should not contain tokens
|
|
|
|
|
expect(page.url()).not.toMatch(/token|jwt|bearer/i);
|
|
|
|
|
|
|
|
|
|
// DOM should not expose tokens
|
|
|
|
|
const html = await page.content();
|
|
|
|
|
expect(html).not.toMatch(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); // JWT pattern
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pas de fuite d\'email dans le profil public', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
const html = await page.content();
|
|
|
|
|
expect(html).not.toContain('admin@veza');
|
|
|
|
|
expect(html).not.toContain('@veza.fr');
|
|
|
|
|
expect(html).not.toContain('@veza.music');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Accessibilité', () => {
|
|
|
|
|
test('le lien Skip to content fonctionne', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// BUG #6 regression: #main-content should exist
|
|
|
|
|
const mainContent = page.locator('#main-content');
|
|
|
|
|
await expect(mainContent).toBeAttached();
|
|
|
|
|
|
|
|
|
|
// Skip link should point to #main-content
|
|
|
|
|
const skipLink = page.getByRole('link', { name: /Skip to content/ });
|
|
|
|
|
await expect(skipLink).toHaveAttribute('href', '#main-content');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('les onglets ont les rôles ARIA corrects', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// tablist should exist
|
|
|
|
|
await expect(page.getByRole('tablist')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// tabs should exist
|
|
|
|
|
const tabs = page.getByRole('tab');
|
|
|
|
|
await expect(tabs).toHaveCount(4);
|
|
|
|
|
|
|
|
|
|
// Active tab should have tabpanel
|
|
|
|
|
await expect(page.getByRole('tabpanel')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('les headings sont structurés correctement (h1, h2)', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// h1 = username/display name
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
|
|
|
|
|
|
|
|
|
// h2 = About section
|
|
|
|
|
const h2 = page.getByRole('heading', { level: 2 });
|
|
|
|
|
await expect(h2.first()).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('i18n', () => {
|
|
|
|
|
test('pas de clés i18n brutes affichées', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
const text = await page.textContent('body');
|
|
|
|
|
// Should not contain raw i18n keys
|
|
|
|
|
expect(text).not.toMatch(/profilePublic\./);
|
|
|
|
|
expect(text).not.toMatch(/common\./);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pas de mélange de langues EN/FR', async ({ page }) => {
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
const text = await page.textContent('body') ?? '';
|
|
|
|
|
|
|
|
|
|
// BUG #5 regression: should not mix French and English
|
|
|
|
|
// If page is in English, should not have French-only words
|
|
|
|
|
if (text.includes('About')) {
|
|
|
|
|
expect(text).not.toMatch(/\bSuivre\b/);
|
|
|
|
|
expect(text).not.toMatch(/\bAbonné\b/);
|
|
|
|
|
expect(text).not.toMatch(/\bDésabonnement\b/);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Responsive', () => {
|
|
|
|
|
test('mobile 375px - stats ne débordent pas', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// BUG #7 regression: all 4 stat labels should be visible
|
|
|
|
|
await expect(page.getByText(/Following|Abonnements|Siguiendo/)).toBeVisible();
|
|
|
|
|
await expect(page.getByText(/Followers|Abonnés|Seguidores/)).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tablet 768px - layout correct', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// Page should render without crash
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Réseau & API', () => {
|
|
|
|
|
test('pas d\'erreur 404 pour /roles', async ({ page }) => {
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
page.on('response', (response) => {
|
|
|
|
|
if (response.url().includes('/roles') && response.status() === 404) {
|
|
|
|
|
errors.push(response.url());
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await page.goto(`${BASE}/u/${VALID_USERNAME}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
|
await page.waitForTimeout(3000);
|
|
|
|
|
|
|
|
|
|
// BUG #4 regression: /roles endpoint should not be called
|
|
|
|
|
expect(errors).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|