diff --git a/frontend/e2e/activation-parent-link.spec.ts b/frontend/e2e/activation-parent-link.spec.ts index 23ce948..ac5f6ce 100644 --- a/frontend/e2e/activation-parent-link.spec.ts +++ b/frontend/e2e/activation-parent-link.spec.ts @@ -95,8 +95,10 @@ test.describe('Activation with Parent-Child Auto-Link', () => { // Now login as admin to verify the auto-link await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); // Navigate to the student's page to check guardian list await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); diff --git a/frontend/e2e/child-selector.spec.ts b/frontend/e2e/child-selector.spec.ts index 3292b04..9157de2 100644 --- a/frontend/e2e/child-selector.spec.ts +++ b/frontend/e2e/child-selector.spec.ts @@ -36,8 +36,10 @@ async function loginAsAdmin(page: Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } async function addGuardianIfNotLinked(page: Page, studentId: string, guardianId: string, relationship: string) { @@ -129,8 +131,10 @@ test.describe('Child Selector', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(PARENT_EMAIL); await page.locator('#password').fill(PARENT_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } test('[P1] parent with multiple children should see child selector', async ({ page }) => { diff --git a/frontend/e2e/class-detail.spec.ts b/frontend/e2e/class-detail.spec.ts new file mode 100644 index 0000000..a543616 --- /dev/null +++ b/frontend/e2e/class-detail.spec.ts @@ -0,0 +1,270 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +// Test credentials +const ADMIN_EMAIL = 'e2e-class-detail-admin@example.com'; +const ADMIN_PASSWORD = 'ClassDetail123'; + +test.describe('Admin Class Detail Page [P1]', () => { + test.describe.configure({ mode: 'serial' }); + + // Class name used for the test class (shared across serial tests) + const CLASS_NAME = `DetailTest-${Date.now()}`; + + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + // Helper to open "Nouvelle classe" dialog with proper wait + async function openNewClassDialog(page: import('@playwright/test').Page) { + const button = page.getByRole('button', { name: /nouvelle classe/i }); + await button.waitFor({ state: 'visible' }); + await page.waitForLoadState('networkidle'); + + await button.click(); + + const dialog = page.getByRole('dialog'); + try { + await expect(dialog).toBeVisible({ timeout: 5000 }); + } catch { + // Retry once - webkit sometimes needs a second click + await button.click(); + await expect(dialog).toBeVisible({ timeout: 10000 }); + } + } + + // Helper to create a class and navigate to its detail page + async function createClassAndNavigateToDetail(page: import('@playwright/test').Page, name: string) { + await page.goto(`${ALPHA_URL}/admin/classes`); + await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible(); + + await openNewClassDialog(page); + await page.locator('#class-name').fill(name); + await page.locator('#class-level').selectOption('CM1'); + await page.locator('#class-capacity').fill('25'); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Click modify to go to detail page + const classCard = page.locator('.class-card', { hasText: name }); + await expect(classCard).toBeVisible(); + await classCard.getByRole('button', { name: /modifier/i }).click(); + + // Verify we are on the edit page + await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/); + } + + // ============================================================================ + // Navigate to class detail page and see edit form + // ============================================================================ + test('[P1] navigates to class detail page and sees edit form', async ({ page }) => { + await loginAsAdmin(page); + await createClassAndNavigateToDetail(page, CLASS_NAME); + + // Should show the edit form heading + await expect( + page.getByRole('heading', { name: /modifier la classe/i }) + ).toBeVisible(); + + // Form fields should be pre-populated + await expect(page.locator('#class-name')).toHaveValue(CLASS_NAME); + await expect(page.locator('#class-level')).toHaveValue('CM1'); + await expect(page.locator('#class-capacity')).toHaveValue('25'); + + // Breadcrumb should be visible + await expect(page.locator('.breadcrumb')).toBeVisible(); + await expect( + page.getByRole('main').getByRole('link', { name: 'Classes' }) + ).toBeVisible(); + }); + + // ============================================================================ + // Modify class name and save successfully + // ============================================================================ + test('[P1] modifies class name and saves successfully', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a fresh class for this test + const originalName = `ModName-${Date.now()}`; + await openNewClassDialog(page); + await page.locator('#class-name').fill(originalName); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Navigate to edit page + const classCard = page.locator('.class-card', { hasText: originalName }); + await classCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/); + + // Modify the name + const newName = `Renamed-${Date.now()}`; + await page.locator('#class-name').fill(newName); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + // Should show success message + await expect(page.getByText(/modifiée avec succès/i)).toBeVisible({ timeout: 10000 }); + + // Go back to list and verify the new name appears + await page.goto(`${ALPHA_URL}/admin/classes`); + await expect(page.getByText(newName)).toBeVisible(); + await expect(page.getByText(originalName)).not.toBeVisible(); + }); + + // ============================================================================ + // Modify class level and save + // ============================================================================ + test('[P1] modifies class level and saves successfully', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class with specific level + const className = `ModLevel-${Date.now()}`; + await openNewClassDialog(page); + await page.locator('#class-name').fill(className); + await page.locator('#class-level').selectOption('CE1'); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Navigate to edit page + const classCard = page.locator('.class-card', { hasText: className }); + await classCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/); + + // Change level from CE1 to CM2 + await page.locator('#class-level').selectOption('CM2'); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + // Should show success message + await expect(page.getByText(/modifiée avec succès/i)).toBeVisible({ timeout: 10000 }); + + // Go back and verify the level changed in the card + await page.goto(`${ALPHA_URL}/admin/classes`); + const updatedCard = page.locator('.class-card', { hasText: className }); + await expect(updatedCard).toBeVisible(); + await expect(updatedCard.getByText('CM2')).toBeVisible(); + }); + + // ============================================================================ + // Cancel modification preserves original values + // ============================================================================ + test('[P1] cancelling modification preserves original values', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class + const originalName = `NoCancel-${Date.now()}`; + await openNewClassDialog(page); + await page.locator('#class-name').fill(originalName); + await page.locator('#class-level').selectOption('6ème'); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Navigate to edit page + const classCard = page.locator('.class-card', { hasText: originalName }); + await classCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/); + + // Modify the name but cancel + await page.locator('#class-name').fill('Should-Not-Persist'); + await page.locator('#class-level').selectOption('CM2'); + + // Cancel + await page.getByRole('button', { name: /annuler/i }).click(); + + // Should go back to the classes list + await expect(page).toHaveURL(/\/admin\/classes$/); + + // The original name should still be visible, modified name should not + await expect(page.getByText(originalName)).toBeVisible(); + await expect(page.getByText('Should-Not-Persist')).not.toBeVisible(); + }); + + // ============================================================================ + // Breadcrumb navigation back to classes list + // ============================================================================ + test('[P1] breadcrumb navigates back to classes list', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class + const className = `Breadcrumb-${Date.now()}`; + await openNewClassDialog(page); + await page.locator('#class-name').fill(className); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Navigate to edit page + const classCard = page.locator('.class-card', { hasText: className }); + await classCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/); + + // Click breadcrumb "Classes" link (scoped to main to avoid nav link) + await page.getByRole('main').getByRole('link', { name: 'Classes' }).click(); + + // Should navigate back to the classes list + await expect(page).toHaveURL(/\/admin\/classes$/); + await expect( + page.getByRole('heading', { name: /gestion des classes/i }) + ).toBeVisible(); + }); + + // ============================================================================ + // Empty required field (name) prevents submission + // ============================================================================ + test('[P1] empty required field (name) prevents submission', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Create a class + const className = `EmptyField-${Date.now()}`; + await openNewClassDialog(page); + await page.locator('#class-name').fill(className); + await page.getByRole('button', { name: /créer la classe/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + // Navigate to edit page + const classCard = page.locator('.class-card', { hasText: className }); + await classCard.getByRole('button', { name: /modifier/i }).click(); + await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/); + + // Clear the required name field + await page.locator('#class-name').fill(''); + + // Submit button should be disabled when name is empty + const submitButton = page.getByRole('button', { name: /enregistrer/i }); + await expect(submitButton).toBeDisabled(); + }); +}); diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index 895e272..f16ad4e 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -41,8 +41,10 @@ test.describe('Classes Management (Story 2.1)', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } // Helper to open "Nouvelle classe" dialog with proper wait diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index e86b714..54ce74e 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -426,8 +426,10 @@ test.describe('Dashboard', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } test('clicking "Gerer les utilisateurs" navigates to users page', async ({ page }) => { diff --git a/frontend/e2e/guardian-management.spec.ts b/frontend/e2e/guardian-management.spec.ts index 9d972c8..d86903f 100644 --- a/frontend/e2e/guardian-management.spec.ts +++ b/frontend/e2e/guardian-management.spec.ts @@ -93,8 +93,10 @@ test.describe('Guardian Management', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } /** diff --git a/frontend/e2e/pedagogy.spec.ts b/frontend/e2e/pedagogy.spec.ts index a50066a..b7c1ac4 100644 --- a/frontend/e2e/pedagogy.spec.ts +++ b/frontend/e2e/pedagogy.spec.ts @@ -45,8 +45,10 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } // ========================================================================= diff --git a/frontend/e2e/periods.spec.ts b/frontend/e2e/periods.spec.ts index 6c95c41..d225c19 100644 --- a/frontend/e2e/periods.spec.ts +++ b/frontend/e2e/periods.spec.ts @@ -38,8 +38,10 @@ test.describe('Periods Management (Story 2.3)', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } // ============================================================================ diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 0000000..9dd0a78 --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -0,0 +1,202 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const USER_EMAIL = 'e2e-settings-user@example.com'; +const USER_PASSWORD = 'SettingsTest123'; + +function getTenantUrl(path: string): string { + return `${ALPHA_URL}${path}`; +} + +test.describe('Settings Page [P1]', () => { + // eslint-disable-next-line no-empty-pattern + test.beforeAll(async ({}, testInfo) => { + const browserName = testInfo.project.name; + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create a test user (any role works for settings) + const email = `e2e-settings-${browserName}@example.com`; + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${email} --password=${USER_PASSWORD} 2>&1`, + { encoding: 'utf-8' } + ); + } catch (error) { + console.error(`[${browserName}] Failed to create settings test user:`, error); + } + + // Also create the shared user + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${USER_EMAIL} --password=${USER_PASSWORD} 2>&1`, + { encoding: 'utf-8' } + ); + } catch (error) { + console.error('Failed to create shared settings test user:', error); + } + }); + + function getTestEmail(browserName: string): string { + return `e2e-settings-${browserName}@example.com`; + } + + async function login(page: import('@playwright/test').Page, email: string) { + await page.goto(getTenantUrl('/login')); + await page.locator('#email').fill(email); + await page.locator('#password').fill(USER_PASSWORD); + await Promise.all([ + page.waitForURL(getTenantUrl('/dashboard'), { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + // ============================================================================ + // Settings page access + // ============================================================================ + test('[P1] authenticated user can access /settings page', async ({ page }, testInfo) => { + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + await expect(page).toHaveURL(/\/settings/); + await expect( + page.getByRole('heading', { name: /paramètres/i }) + ).toBeVisible({ timeout: 10000 }); + }); + + // ============================================================================ + // Settings page content + // ============================================================================ + test('[P1] settings page shows description text', async ({ page }, testInfo) => { + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + await expect( + page.getByText(/gérez votre compte et vos préférences/i) + ).toBeVisible(); + }); + + test('[P1] settings page shows Sessions navigation card', async ({ page }, testInfo) => { + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + // The Sessions card should be visible + await expect(page.getByRole('heading', { name: /mes sessions/i })).toBeVisible(); + await expect( + page.getByText(/gérez vos sessions actives/i) + ).toBeVisible(); + }); + + // ============================================================================ + // Navigation from settings to sessions + // ============================================================================ + test('[P1] clicking Sessions card navigates to /settings/sessions', async ({ page, browserName }, testInfo) => { + // Skip on webkit due to navigation timing issues with SvelteKit + test.skip(browserName === 'webkit', 'Webkit has navigation timing issues with SvelteKit'); + + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + // Click on the Sessions card (it's a button with heading text) + await page.getByText(/mes sessions/i).click(); + + await expect(page).toHaveURL(/\/settings\/sessions/); + await expect( + page.getByRole('heading', { name: /mes sessions/i }) + ).toBeVisible(); + }); + + // ============================================================================ + // Settings layout - header with logo + // ============================================================================ + test('[P1] settings layout shows header with Classeo logo', async ({ page }, testInfo) => { + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + // Header should have the Classeo logo text + await expect(page.locator('.logo-text')).toBeVisible(); + await expect(page.locator('.logo-text')).toHaveText('Classeo'); + }); + + test('[P1] settings layout shows Tableau de bord navigation link', async ({ page }, testInfo) => { + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + await expect( + page.getByRole('link', { name: /tableau de bord/i }) + ).toBeVisible(); + }); + + test('[P1] settings layout shows Parametres navigation link as active', async ({ page }, testInfo) => { + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + const parametresLink = page.getByRole('link', { name: /parametres/i }); + await expect(parametresLink).toBeVisible(); + await expect(parametresLink).toHaveClass(/active/); + }); + + // ============================================================================ + // Logout from settings layout + // ============================================================================ + test('[P1] logout button on settings layout logs user out', async ({ page, browserName }, testInfo) => { + // Skip on webkit due to navigation timing issues with SvelteKit + test.skip(browserName === 'webkit', 'Webkit has navigation timing issues with SvelteKit'); + + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + // Click logout button + const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i }); + await expect(logoutButton).toBeVisible(); + await logoutButton.click(); + + // Should redirect to login + await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); + }); + + // ============================================================================ + // Logo click navigates to dashboard + // ============================================================================ + test('[P1] clicking Classeo logo navigates to dashboard', async ({ page, browserName }, testInfo) => { + // Skip on webkit due to navigation timing issues with SvelteKit + test.skip(browserName === 'webkit', 'Webkit has navigation timing issues with SvelteKit'); + + const email = getTestEmail(testInfo.project.name); + await login(page, email); + + await page.goto(getTenantUrl('/settings')); + + // Click the logo button + await page.locator('.logo-button').click(); + + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + }); +}); diff --git a/frontend/e2e/students.spec.ts b/frontend/e2e/students.spec.ts index 287d548..8c64106 100644 --- a/frontend/e2e/students.spec.ts +++ b/frontend/e2e/students.spec.ts @@ -85,8 +85,10 @@ test.describe('Student Management', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } /** diff --git a/frontend/e2e/subjects.spec.ts b/frontend/e2e/subjects.spec.ts index 5a7f037..bd475ef 100644 --- a/frontend/e2e/subjects.spec.ts +++ b/frontend/e2e/subjects.spec.ts @@ -41,8 +41,10 @@ test.describe('Subjects Management (Story 2.2)', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } // Helper to open "Nouvelle matière" dialog with proper wait diff --git a/frontend/e2e/user-blocking-session.spec.ts b/frontend/e2e/user-blocking-session.spec.ts new file mode 100644 index 0000000..0be287c --- /dev/null +++ b/frontend/e2e/user-blocking-session.spec.ts @@ -0,0 +1,206 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-block-session-admin@example.com'; +const ADMIN_PASSWORD = 'BlockSession123'; +const TARGET_EMAIL = 'e2e-block-session-target@example.com'; +const TARGET_PASSWORD = 'TargetSession123'; + +test.describe('User Blocking Mid-Session [P1]', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create target user to be blocked mid-session + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + // Ensure target user is unblocked before tests start + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "UPDATE users SET statut = 'actif', blocked_reason = NULL WHERE email = '${TARGET_EMAIL}'" 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Ignore cleanup errors + } + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + async function loginAsTarget(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TARGET_EMAIL); + await page.locator('#password').fill(TARGET_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + async function blockUserViaAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/admin/users`); + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await expect(targetRow).toBeVisible(); + + // Click "Bloquer" and wait for modal (retry handles hydration timing) + await expect(async () => { + await targetRow.getByRole('button', { name: /bloquer/i }).click(); + await expect(page.locator('#block-modal-title')).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 10000 }); + + // Fill in the reason + await page.locator('#block-reason').fill('Blocage mid-session E2E test'); + + // Confirm the block + await page.getByRole('button', { name: /confirmer le blocage/i }).click(); + + // Wait for the success message + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 5000 }); + } + + async function unblockUserViaAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/admin/users`); + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await expect(targetRow).toBeVisible(); + + const unblockButton = targetRow.getByRole('button', { name: /débloquer/i }); + await expect(unblockButton).toBeVisible(); + await unblockButton.click(); + + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 5000 }); + } + + // ============================================================================ + // AC1: Admin blocks a user mid-session + // ============================================================================ + test('[P1] admin blocks user mid-session - blocked user next request results in redirect', async ({ browser }) => { + // Use two separate browser contexts to simulate two concurrent sessions + const adminContext = await browser.newContext(); + const targetContext = await browser.newContext(); + + const adminPage = await adminContext.newPage(); + const targetPage = await targetContext.newPage(); + + try { + // Step 1: Target user logs in and is on the dashboard + await loginAsTarget(targetPage); + await expect(targetPage).toHaveURL(/\/dashboard/); + + // Step 2: Admin logs in and blocks the target user + await loginAsAdmin(adminPage); + await blockUserViaAdmin(adminPage); + + // Step 3: The blocked user tries to navigate or make an API call + // Navigating to a protected page should result in redirect to login + await targetPage.goto(`${ALPHA_URL}/settings/sessions`); + + // The blocked user should be redirected to login (API returns 401/403) + await expect(targetPage).toHaveURL(/\/login/, { timeout: 10000 }); + } finally { + await adminContext.close(); + await targetContext.close(); + } + }); + + // ============================================================================ + // AC2: Blocked user cannot log in + // ============================================================================ + test('[P1] blocked user cannot log in and sees suspended error', async ({ page }) => { + // The user was blocked in the previous test; attempt to log in + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TARGET_EMAIL); + await page.locator('#password').fill(TARGET_PASSWORD); + await page.getByRole('button', { name: /se connecter/i }).click(); + + // Should see a suspended account error, not the generic credentials error + const errorBanner = page.locator('.error-banner.account-suspended'); + await expect(errorBanner).toBeVisible({ timeout: 5000 }); + await expect(errorBanner).toContainText(/suspendu|contactez/i); + }); + + // ============================================================================ + // AC3: Admin unblocks user - user can log in again + // ============================================================================ + test('[P1] admin unblocks user and user can log in again', async ({ browser }) => { + const adminContext = await browser.newContext(); + const adminPage = await adminContext.newPage(); + + try { + // Admin unblocks the user + await loginAsAdmin(adminPage); + await unblockUserViaAdmin(adminPage); + + // Verify the status changed back to "Actif" + const updatedRow = adminPage.locator('tr', { has: adminPage.locator(`text=${TARGET_EMAIL}`) }); + await expect(updatedRow.locator('.status-active')).toContainText('Actif'); + } finally { + await adminContext.close(); + } + + // Now the user should be able to log in again (use a new context) + const userContext = await browser.newContext(); + const userPage = await userContext.newPage(); + + try { + await userPage.goto(`${ALPHA_URL}/login`); + await userPage.locator('#email').fill(TARGET_EMAIL); + await userPage.locator('#password').fill(TARGET_PASSWORD); + await userPage.getByRole('button', { name: /se connecter/i }).click(); + + // Should redirect to dashboard (successful login) + await expect(userPage).toHaveURL(/\/dashboard/, { timeout: 10000 }); + } finally { + await userContext.close(); + } + }); + + // ============================================================================ + // AC4: Admin cannot block themselves + // ============================================================================ + test('[P1] admin cannot block themselves from users page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + // Find the admin's own row + const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) }); + await expect(adminRow).toBeVisible(); + + // "Bloquer" button should NOT be present on the admin's own row + await expect(adminRow.getByRole('button', { name: /^bloquer$/i })).not.toBeVisible(); + }); +}); diff --git a/frontend/e2e/user-blocking.spec.ts b/frontend/e2e/user-blocking.spec.ts index a7ec432..d337ed3 100644 --- a/frontend/e2e/user-blocking.spec.ts +++ b/frontend/e2e/user-blocking.spec.ts @@ -40,8 +40,10 @@ test.describe('User Blocking', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } test('admin can block a user and sees blocked status', async ({ page }) => { diff --git a/frontend/e2e/user-creation.spec.ts b/frontend/e2e/user-creation.spec.ts index 0e26984..df45753 100644 --- a/frontend/e2e/user-creation.spec.ts +++ b/frontend/e2e/user-creation.spec.ts @@ -33,8 +33,10 @@ test.describe('User Creation', () => { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /se connecter/i }).click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); } test('admin can invite a user with roles array', async ({ page }) => {