veza/tests/e2e/36-forgot-password.spec.ts

333 lines
14 KiB
TypeScript
Raw Normal View History

import { test, expect } from '@chromatic-com/playwright';
import { CONFIG, navigateTo, assertNoDebugText } from './helpers';
// =============================================================================
// FORGOT PASSWORD — Suite de tests exhaustive pour /forgot-password
// Couvre les 12 bugs identifiés + fonctionnalités
// =============================================================================
const FORM_SELECTOR = '[data-testid="forgot-password-form"]';
const SUBMIT_SELECTOR = '[data-testid="forgot-password-submit"]';
// 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 forgot-password with clean session
async function goToForgotPassword(page: import('@playwright/test').Page) {
await clearAuthState(page);
await page.goto(`${CONFIG.baseURL}/forgot-password`, { waitUntil: 'networkidle' });
// Wait for the form to be visible (app may show splash screen first)
await page.locator(`${FORM_SELECTOR}, [role="status"]`).first().waitFor({
state: 'visible',
timeout: 15_000,
});
}
// =============================================================================
// CHARGEMENT & RENDU
// =============================================================================
test.describe('FORGOT PASSWORD — Chargement & Rendu', () => {
test('01. La page /forgot-password se charge avec le formulaire visible', async ({ page }) => {
await goToForgotPassword(page);
await expect(page.locator(FORM_SELECTOR)).toBeVisible({ timeout: 10_000 });
await expect(page.locator('input[type="email"]')).toBeVisible();
await expect(page.locator(SUBMIT_SELECTOR)).toBeVisible();
});
test('02. Pas de texte debug visible', async ({ page }) => {
await goToForgotPassword(page);
await assertNoDebugText(page);
});
test('03. Lien footer vers /login fonctionne', async ({ page }) => {
await goToForgotPassword(page);
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 });
});
test('04. Le logo VEZA est visible', async ({ page }) => {
await goToForgotPassword(page);
await expect(page.getByText('VEZA')).toBeVisible({ timeout: 5_000 });
});
test('05. Le titre H1 et le sous-titre sont visibles et non vides', async ({ page }) => {
await goToForgotPassword(page);
const heading = page.locator('h1');
await expect(heading).toBeVisible({ timeout: 5_000 });
const text = await heading.textContent();
expect(text).toBeTruthy();
expect(text!.length).toBeGreaterThan(0);
});
});
// =============================================================================
// i18n
// =============================================================================
test.describe('FORGOT PASSWORD — i18n', () => {
test('06. [BUG #1/#2] Le titre correspond à une traduction connue (pas de texte hardcodé)', async ({ page }) => {
await goToForgotPassword(page);
const headingText = (await page.locator('h1').textContent()) || '';
// The title must be one of the known i18n translations
const validTitles = ['Forgot Password', 'Mot de passe oublié', 'Contraseña olvidada'];
expect(validTitles.some((t) => headingText.includes(t))).toBeTruthy();
});
test('07. [BUG #2] La langue est cohérente avec la page /login', async ({ page }) => {
// Visit login to determine the current language
await clearAuthState(page);
await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'networkidle' });
await page.locator('h1').waitFor({ state: 'visible', timeout: 10_000 });
const loginHeading = (await page.locator('h1').textContent()) || '';
// Visit forgot-password
await goToForgotPassword(page);
const fpHeading = (await page.locator('h1').textContent()) || '';
// Both should be in the same language
const loginIsEnglish = /Login|Sign in/i.test(loginHeading);
const loginIsFrench = /Connexion/i.test(loginHeading);
const fpIsEnglish = /Forgot Password/i.test(fpHeading);
const fpIsFrench = /Mot de passe oublié/i.test(fpHeading);
if (loginIsEnglish) expect(fpIsEnglish).toBeTruthy();
if (loginIsFrench) expect(fpIsFrench).toBeTruthy();
});
test('08. [BUG #4] html lang correspond à la langue du contenu', async ({ page }) => {
await goToForgotPassword(page);
const htmlLang = (await page.locator('html').getAttribute('lang')) || 'en';
const heading = (await page.locator('h1').textContent()) || '';
if (htmlLang === 'en') {
expect(heading).toMatch(/Forgot Password/i);
} else if (htmlLang === 'fr') {
expect(heading).toMatch(/Mot de passe oublié/i);
} else if (htmlLang === 'es') {
expect(heading).toMatch(/Contraseña olvidada/i);
}
});
});
// =============================================================================
// VALIDATION
// =============================================================================
test.describe('FORGOT PASSWORD — Validation', () => {
test('09. [BUG #7] Email vide montre une erreur au blur', async ({ page }) => {
await goToForgotPassword(page);
const emailInput = page.locator('input[type="email"]');
await emailInput.focus();
await emailInput.blur();
const error = page.getByRole('alert');
await expect(error.first()).toBeVisible({ timeout: 3_000 });
});
test('10. [BUG #7] Email invalide montre une erreur de format', async ({ page }) => {
await goToForgotPassword(page);
await page.locator('input[type="email"]').fill('not-an-email');
await page.locator('input[type="email"]').blur();
const error = page.getByRole('alert');
await expect(error.first()).toBeVisible({ timeout: 3_000 });
});
test('11. L\'erreur disparaît quand l\'utilisateur corrige sa saisie', async ({ page }) => {
await goToForgotPassword(page);
const emailInput = page.locator('input[type="email"]');
await emailInput.fill('bad');
await emailInput.blur();
await expect(page.getByRole('alert').first()).toBeVisible({ timeout: 3_000 });
// Fix the input
await emailInput.fill('valid@example.com');
await page.waitForTimeout(500);
// Validation error should be gone (the API error alert may remain)
const validationErrors = page.locator('[role="alert"]').filter({
hasText: /required|requis|invalid|invalide|requerido|inválido/i,
});
await expect(validationErrors).not.toBeVisible({ timeout: 3_000 });
});
test('12. Soumission vide ne déclenche pas d\'appel API', async ({ page }) => {
await goToForgotPassword(page);
const apiCalls: string[] = [];
page.on('request', (req) => {
if (req.url().includes('/password/reset') && req.method() === 'POST') {
apiCalls.push(req.url());
}
});
// Click submit with empty email
await page.locator(SUBMIT_SELECTOR).click();
await page.waitForTimeout(1_000);
expect(apiCalls.length).toBe(0);
});
});
// =============================================================================
// ACCESSIBILITÉ
// =============================================================================
test.describe('FORGOT PASSWORD — Accessibilité', () => {
test('13. [BUG #3] Le lien "Skip to content" a une cible #main-content existante', async ({ page }) => {
await goToForgotPassword(page);
const mainContent = page.locator('#main-content');
await expect(mainContent).toBeAttached();
});
test('14. L\'input email a un label associé', async ({ page }) => {
await goToForgotPassword(page);
await expect(page.getByRole('textbox', { name: /email/i })).toBeVisible();
});
test('15. Le formulaire a un aria-label', async ({ page }) => {
await goToForgotPassword(page);
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('16. Navigation clavier : Tab traverse email → submit → lien retour', async ({ page }) => {
await goToForgotPassword(page);
const emailInput = page.locator('input[type="email"]');
await emailInput.focus();
await expect(emailInput).toBeFocused();
// Tab to submit button
await page.keyboard.press('Tab');
const focusedTag = await page.evaluate(() => document.activeElement?.tagName.toLowerCase());
expect(focusedTag).toBe('button');
});
});
// =============================================================================
// ÉTAT SUCCÈS
// =============================================================================
test.describe('FORGOT PASSWORD — État succès', () => {
test('17. [BUG #5] Le message de succès est ambigu (pas d\'email affiché)', async ({ page }) => {
await goToForgotPassword(page);
await page.locator('input[type="email"]').fill('test-noexist@example.com');
await page.locator(SUBMIT_SELECTOR).click();
// Wait for success or error state
const statusOrAlert = page.getByRole('alert').or(page.getByRole('status'));
await expect(statusOrAlert.first()).toBeVisible({ timeout: 10_000 });
const body = (await page.textContent('body')) || '';
// Should NOT contain the submitted email address
expect(body).not.toContain('test-noexist@example.com');
// Should contain ambiguous "if an account exists" type wording
expect(body).toMatch(/if.*account.*exist|si.*compte.*existe|si.*cuenta.*existe/i);
});
test('18. [BUG #6] Pas de double lien "Retour à la connexion" dans l\'état succès', async ({ page }) => {
await goToForgotPassword(page);
await page.locator('input[type="email"]').fill(CONFIG.users.listener.email);
await page.locator(SUBMIT_SELECTOR).click();
await page.getByRole('status').or(page.getByRole('alert')).first().waitFor({
state: 'visible',
timeout: 10_000,
});
// Count back-to-login links — should be exactly 1
const backLinks = page.locator('a[href="/login"]');
const count = await backLinks.count();
expect(count).toBe(1);
});
test('19. [BUG #9] Le bouton "Renvoyer" ramène au formulaire', async ({ page }) => {
await goToForgotPassword(page);
await page.locator('input[type="email"]').fill(CONFIG.users.listener.email);
await page.locator(SUBMIT_SELECTOR).click();
await page.getByRole('status').or(page.getByRole('alert')).first().waitFor({
state: 'visible',
timeout: 10_000,
});
// Click resend button
const resendBtn = page.getByRole('button', { name: /resend|renvoyer|reenviar/i });
await expect(resendBtn).toBeVisible({ timeout: 3_000 });
await resendBtn.click();
// Form should reappear
await expect(page.locator(FORM_SELECTOR)).toBeVisible({ timeout: 5_000 });
});
test('20. [BUG #12] Le titre H1 change entre le formulaire et l\'état succès', async ({ page }) => {
await goToForgotPassword(page);
const titleBefore = await page.locator('h1').textContent();
await page.locator('input[type="email"]').fill(CONFIG.users.listener.email);
await page.locator(SUBMIT_SELECTOR).click();
await page.getByRole('status').or(page.getByRole('alert')).first().waitFor({
state: 'visible',
timeout: 10_000,
});
const titleAfter = await page.locator('h1').textContent();
expect(titleBefore).not.toBe(titleAfter);
});
});
// =============================================================================
// UX
// =============================================================================
test.describe('FORGOT PASSWORD — UX', () => {
test('21. [BUG #8] Le titre de la page (document.title) est mis à jour', async ({ page }) => {
await goToForgotPassword(page);
const title = await page.title();
// Should contain forgot/oublié/olvidada and not be the generic default
expect(title).toMatch(/forgot|oublié|olvidada/i);
});
test('22. [BUG #11] Le bouton submit a aria-busy pendant le chargement', async ({ page }) => {
await goToForgotPassword(page);
await page.locator('input[type="email"]').fill(CONFIG.users.listener.email);
// Click and immediately check aria-busy
await page.locator(SUBMIT_SELECTOR).click();
// The button should become busy/disabled during loading
const btn = page.locator(SUBMIT_SELECTOR);
// Check either aria-busy or disabled state was applied
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 goToForgotPassword(page);
await page.locator('input[type="email"]').fill('enter-test@example.com');
await page.locator('input[type="email"]').press('Enter');
// Should reach success or error state (not stay idle)
const statusOrAlert = page.getByRole('alert').or(page.getByRole('status'));
await expect(statusOrAlert.first()).toBeVisible({ timeout: 10_000 });
});
});
// =============================================================================
// RESPONSIVE
// =============================================================================
test.describe('FORGOT PASSWORD — Responsive', () => {
test('24. Mobile (375px) — formulaire visible sans overflow', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await goToForgotPassword(page);
await expect(page.locator(FORM_SELECTOR)).toBeVisible({ timeout: 10_000 });
await expect(page.locator(SUBMIT_SELECTOR)).toBeVisible();
// Check no horizontal overflow
const hasOverflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(hasOverflow).toBe(false);
});
test('25. Tablet (768px) — page affichée correctement', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await goToForgotPassword(page);
await expect(page.locator(FORM_SELECTOR)).toBeVisible({ timeout: 10_000 });
await expect(page.locator(SUBMIT_SELECTOR)).toBeVisible();
});
});