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>
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { test, expect } from '@chromatic-com/playwright';
|
|
import { CONFIG, navigateTo, assertNoDebugText } from './helpers';
|
|
|
|
// =============================================================================
|
|
// RESET PASSWORD — Suite de tests exhaustive pour /reset-password
|
|
// Couvre les 13 bugs identifiés + fonctionnalités
|
|
// =============================================================================
|
|
|
|
const FORM_SELECTOR = '[data-testid="reset-password-form"]';
|
|
const SUBMIT_SELECTOR = '[data-testid="reset-password-submit"]';
|
|
const FAKE_TOKEN = 'test-fake-token-e2e-123';
|
|
|
|
// Helper: clear auth state so PublicRoute doesn't redirect to /dashboard
|
|
async function clearAuthState(page: import('@playwright/test').Page) {
|
|
const context = page.context();
|
|
await context.clearCookies();
|
|
await page.evaluate(() => {
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
});
|
|
}
|
|
|
|
// Helper: navigate to reset-password with a token
|
|
async function goToResetPassword(page: import('@playwright/test').Page, token?: string) {
|
|
await clearAuthState(page);
|
|
const url = token
|
|
? `${CONFIG.baseURL}/reset-password?token=${token}`
|
|
: `${CONFIG.baseURL}/reset-password`;
|
|
await page.goto(url, { waitUntil: 'networkidle' });
|
|
await page.locator('main, [role="main"]').first().waitFor({
|
|
state: 'visible',
|
|
timeout: 15_000,
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// CHARGEMENT & RENDU
|
|
// =============================================================================
|
|
|
|
test.describe('RESET PASSWORD — Chargement & Rendu', () => {
|
|
test('01. Avec token : le formulaire s\'affiche', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
await expect(page.locator(FORM_SELECTOR)).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
|
await expect(page.locator('input[type="password"]').nth(1)).toBeVisible();
|
|
await expect(page.locator(SUBMIT_SELECTOR)).toBeVisible();
|
|
});
|
|
|
|
test('02. Sans token : le message d\'erreur s\'affiche', async ({ page }) => {
|
|
await goToResetPassword(page);
|
|
const alert = page.getByRole('alert');
|
|
await expect(alert).toBeVisible({ timeout: 10_000 });
|
|
// Form should NOT be visible
|
|
await expect(page.locator(FORM_SELECTOR)).not.toBeVisible();
|
|
});
|
|
|
|
test('03. Pas de texte debug visible', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
await assertNoDebugText(page);
|
|
});
|
|
|
|
test('04. Lien "Retour à la connexion" fonctionne', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const loginLink = page.locator('a[href="/login"]');
|
|
await expect(loginLink.first()).toBeVisible({ timeout: 5_000 });
|
|
await loginLink.first().click();
|
|
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// i18n
|
|
// =============================================================================
|
|
|
|
test.describe('RESET PASSWORD — i18n', () => {
|
|
test('05. [BUG #1/#2] Le titre correspond à une traduction connue', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const heading = (await page.locator('h1').textContent()) || '';
|
|
const validTitles = ['Reset Password', 'Réinitialiser le mot de passe', 'Restablecer contraseña'];
|
|
expect(validTitles.some((t) => heading.includes(t))).toBeTruthy();
|
|
});
|
|
|
|
test('06. [BUG #2] Pas de mélange de langues (labels et indicateur de force)', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const heading = (await page.locator('h1').textContent()) || '';
|
|
const isEnglish = /Reset Password/i.test(heading);
|
|
|
|
// Type a password to trigger the strength indicator
|
|
await page.locator('input[type="password"]').first().fill('Ab1!test');
|
|
await page.waitForTimeout(300);
|
|
const body = (await page.textContent('body')) || '';
|
|
|
|
if (isEnglish) {
|
|
// Labels should be English, not French
|
|
expect(body).not.toMatch(/Nouveau mot de passe/);
|
|
expect(body).not.toMatch(/Confirmer le mot de passe/);
|
|
}
|
|
});
|
|
|
|
test('07. [BUG #10] L\'état "sans token" est traduit', async ({ page }) => {
|
|
await goToResetPassword(page);
|
|
const heading = (await page.locator('h1').textContent()) || '';
|
|
const validTitles = ['Invalid reset link', 'Lien de réinitialisation invalide', 'Enlace de restablecimiento inválido'];
|
|
expect(validTitles.some((t) => heading.includes(t))).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// ÉTAT SANS TOKEN
|
|
// =============================================================================
|
|
|
|
test.describe('RESET PASSWORD — État sans token', () => {
|
|
test('08. Lien vers /forgot-password présent', async ({ page }) => {
|
|
await goToResetPassword(page);
|
|
const link = page.locator('a[href="/forgot-password"]');
|
|
await expect(link).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('09. Lien vers /login présent', async ({ page }) => {
|
|
await goToResetPassword(page);
|
|
const link = page.locator('a[href="/login"]');
|
|
await expect(link.first()).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// VALIDATION
|
|
// =============================================================================
|
|
|
|
test.describe('RESET PASSWORD — Validation', () => {
|
|
test('10. [BUG #4/#5] Password vide montre erreur au blur', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
await pw.focus();
|
|
await pw.blur();
|
|
await page.waitForTimeout(300);
|
|
const error = page.getByRole('alert');
|
|
await expect(error.first()).toBeVisible({ timeout: 3_000 });
|
|
});
|
|
|
|
test('11. [BUG #4] Password trop court montre erreur', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
await pw.fill('Ab1!');
|
|
await pw.blur();
|
|
await page.waitForTimeout(300);
|
|
const body = (await page.textContent('body')) || '';
|
|
expect(body).toMatch(/8 char|8 car|8 caract/i);
|
|
});
|
|
|
|
test('12. Mots de passe différents montre erreur', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
const confirm = page.locator('input[type="password"]').nth(1);
|
|
await pw.fill('ValidPass1!xx');
|
|
await confirm.fill('DifferentPass1!');
|
|
await confirm.blur();
|
|
await page.waitForTimeout(300);
|
|
const body = (await page.textContent('body')) || '';
|
|
expect(body).toMatch(/do not match|correspondent pas|no coinciden/i);
|
|
});
|
|
|
|
test('13. Erreur disparaît quand l\'utilisateur corrige', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
await pw.fill('Ab1!');
|
|
await pw.blur();
|
|
await page.waitForTimeout(300);
|
|
await expect(page.getByRole('alert').first()).toBeVisible({ timeout: 3_000 });
|
|
// Fix the password
|
|
await pw.fill('ValidPassword1!xx');
|
|
await page.waitForTimeout(500);
|
|
const validationErrors = page.locator('[role="alert"]').filter({
|
|
hasText: /required|requis|short|court|8 char|requerida/i,
|
|
});
|
|
await expect(validationErrors).not.toBeVisible({ timeout: 3_000 });
|
|
});
|
|
|
|
test('14. Soumission invalide ne fait pas d\'appel API', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const apiCalls: string[] = [];
|
|
page.on('request', (req) => {
|
|
if (req.url().includes('/auth/password/reset') && req.method() === 'POST') {
|
|
apiCalls.push(req.url());
|
|
}
|
|
});
|
|
// Click submit with empty fields
|
|
await page.locator(SUBMIT_SELECTOR).click();
|
|
await page.waitForTimeout(1_000);
|
|
expect(apiCalls.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// ACCESSIBILITÉ
|
|
// =============================================================================
|
|
|
|
test.describe('RESET PASSWORD — Accessibilité', () => {
|
|
test('15. #main-content existe pour le skip link', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const mainContent = page.locator('#main-content');
|
|
await expect(mainContent).toBeAttached();
|
|
});
|
|
|
|
test('16. Les inputs password ont des labels associés', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
// Both password inputs should have accessible labels
|
|
const inputs = page.locator('input[type="password"]');
|
|
const count = await inputs.count();
|
|
expect(count).toBe(2);
|
|
for (let i = 0; i < count; i++) {
|
|
const input = inputs.nth(i);
|
|
const id = await input.getAttribute('id');
|
|
expect(id).toBeTruthy();
|
|
const label = page.locator(`label[for="${id}"]`);
|
|
await expect(label).toBeAttached();
|
|
}
|
|
});
|
|
|
|
test('17. Le formulaire a un aria-label', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const form = page.locator(FORM_SELECTOR);
|
|
await expect(form).toBeVisible({ timeout: 10_000 });
|
|
const ariaLabel = await form.getAttribute('aria-label');
|
|
expect(ariaLabel).toBeTruthy();
|
|
expect(ariaLabel!.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('18. Navigation clavier : Tab traverse les champs correctement', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
// Focus first password
|
|
const pw = page.locator('input[type="password"]').first();
|
|
await pw.focus();
|
|
await expect(pw).toBeFocused();
|
|
// Tab to confirm password
|
|
await page.keyboard.press('Tab');
|
|
const focused1 = await page.evaluate(() => document.activeElement?.getAttribute('type'));
|
|
expect(focused1).toBe('password');
|
|
});
|
|
|
|
test('19. [BUG #3] Toggle show/hide password fonctionne', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
await pw.fill('TestPassword1!');
|
|
// Find the show password toggle button
|
|
const toggleBtn = page.getByRole('button', { name: /show|hide|afficher|masquer/i }).first();
|
|
await expect(toggleBtn).toBeVisible({ timeout: 3_000 });
|
|
await toggleBtn.click();
|
|
// Input type should change to text
|
|
const inputType = await pw.getAttribute('type');
|
|
expect(inputType).toBe('text');
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// SOUMISSION API
|
|
// =============================================================================
|
|
|
|
test.describe('RESET PASSWORD — Soumission API', () => {
|
|
test('20. [BUG #9] Token invalide affiche un message d\'erreur lisible', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
const confirm = page.locator('input[type="password"]').nth(1);
|
|
await pw.fill('MySecure1!Pass');
|
|
await confirm.fill('MySecure1!Pass');
|
|
await page.locator(SUBMIT_SELECTOR).click();
|
|
// Wait for error to appear
|
|
const alert = page.getByRole('alert');
|
|
await expect(alert.first()).toBeVisible({ timeout: 10_000 });
|
|
const alertText = (await alert.first().textContent()) || '';
|
|
// Should NOT show generic "Error authenticating" but a meaningful error
|
|
expect(alertText).not.toMatch(/Error authenticating/i);
|
|
expect(alertText.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// UX
|
|
// =============================================================================
|
|
|
|
test.describe('RESET PASSWORD — UX', () => {
|
|
test('21. [BUG #7] Le titre de la page est mis à jour', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const title = await page.title();
|
|
expect(title).toMatch(/reset|réinitialis|restablecer/i);
|
|
});
|
|
|
|
test('22. [BUG #8] Le bouton submit a aria-busy pendant le chargement', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
const confirm = page.locator('input[type="password"]').nth(1);
|
|
await pw.fill('MySecure1!Pass');
|
|
await confirm.fill('MySecure1!Pass');
|
|
await page.locator(SUBMIT_SELECTOR).click();
|
|
const btn = page.locator(SUBMIT_SELECTOR);
|
|
const ariaBusy = await btn.getAttribute('aria-busy').catch(() => null);
|
|
const ariaDisabled = await btn.getAttribute('aria-disabled').catch(() => null);
|
|
expect(ariaBusy === 'true' || ariaDisabled === 'true').toBeTruthy();
|
|
});
|
|
|
|
test('23. Soumission par touche Entrée fonctionne', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
const confirm = page.locator('input[type="password"]').nth(1);
|
|
await pw.fill('MySecure1!Pass');
|
|
await confirm.fill('MySecure1!Pass');
|
|
await confirm.press('Enter');
|
|
const alert = page.getByRole('alert');
|
|
await expect(alert.first()).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('24. Password strength indicator s\'affiche quand on tape', async ({ page }) => {
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
const pw = page.locator('input[type="password"]').first();
|
|
await pw.fill('Ab1!test');
|
|
await page.waitForTimeout(300);
|
|
// Should see strength indicator (Weak/Fair/Good/Strong)
|
|
const body = (await page.textContent('body')) || '';
|
|
expect(body).toMatch(/Weak|Fair|Good|Strong|Faible|Moyen|Bon|Fort/i);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// RESPONSIVE
|
|
// =============================================================================
|
|
|
|
test.describe('RESET PASSWORD — Responsive', () => {
|
|
test('25. Mobile (375px) — formulaire visible sans overflow', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
await expect(page.locator(FORM_SELECTOR)).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.locator(SUBMIT_SELECTOR)).toBeVisible();
|
|
const hasOverflow = await page.evaluate(() =>
|
|
document.documentElement.scrollWidth > document.documentElement.clientWidth
|
|
);
|
|
expect(hasOverflow).toBe(false);
|
|
});
|
|
|
|
test('26. Tablet (768px) — page affichée correctement', async ({ page }) => {
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
await goToResetPassword(page, FAKE_TOKEN);
|
|
await expect(page.locator(FORM_SELECTOR)).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.locator(SUBMIT_SELECTOR)).toBeVisible();
|
|
});
|
|
});
|