feat: Pagination et recherche des sections admin

Les listes admin (utilisateurs, classes, matières, affectations) chargeaient
toutes les données d'un coup, ce qui dégradait l'expérience avec un volume
croissant. La pagination côté serveur existait dans la config API Platform
mais aucun Provider ne l'exploitait.

Cette implémentation ajoute la pagination serveur (30 items/page, max 100)
avec recherche textuelle sur toutes les sections, des composants frontend
réutilisables (Pagination + SearchInput avec debounce), et la synchronisation
URL pour le partage de liens filtrés.

Les Query valident leurs paramètres (clamp page/limit, trim search) pour
éviter les abus. Les affectations utilisent des lookup maps pour résoudre
les noms sans N+1 queries. Les pages admin gèrent les race conditions
via AbortController.
This commit is contained in:
2026-02-15 13:54:51 +01:00
parent 88e7f319db
commit 76e16db0d8
57 changed files with 3123 additions and 181 deletions

View File

@@ -96,7 +96,7 @@ test.describe('Activation with Parent-Child Auto-Link', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);

View File

@@ -0,0 +1,339 @@
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-search-admin@example.com';
const ADMIN_PASSWORD = 'SearchTest123';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
test.describe('Admin Search & Pagination (Story 2.8b)', () => {
test.beforeAll(async () => {
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: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// ============================================================================
// USERS PAGE - Search & Pagination
// ============================================================================
test.describe('Users Page', () => {
test('displays search input on users page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i);
});
test('search filters users and shows results', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
// Wait for initial load
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('e2e-search-admin');
// Wait for debounce + API response
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Should find our test admin user
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
});
test('search with no results shows empty state', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('zzz-nonexistent-user-xyz');
// Wait for debounce + API response
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Should show "Aucun résultat" empty state
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i);
});
test('search term is synced to URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('test-search');
// Wait for debounce
await page.waitForTimeout(500);
// URL should contain search param
await expect(page).toHaveURL(/[?&]search=test-search/);
});
test('search term from URL is restored on page load', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users?search=admin`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toHaveValue('admin');
});
test('clear search button resets results', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('test');
// Wait for debounce
await page.waitForTimeout(500);
// Clear button should appear
const clearButton = page.locator('.search-clear');
await expect(clearButton).toBeVisible();
await clearButton.click();
// Search input should be empty
await expect(searchInput).toHaveValue('');
});
test('Escape key clears search', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('test');
await page.waitForTimeout(500);
await searchInput.press('Escape');
await expect(searchInput).toHaveValue('');
});
test('filters work together with search', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Apply a role filter
await page.locator('#filter-role').selectOption('ROLE_ADMIN');
await page.getByRole('button', { name: /filtrer/i }).click();
await page.waitForLoadState('networkidle');
// Then search
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('e2e-search');
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// URL should have both params
await expect(page).toHaveURL(/search=e2e-search/);
});
});
// ============================================================================
// CLASSES PAGE - Search & Pagination
// ============================================================================
test.describe('Classes Page', () => {
test('displays search input on classes page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i);
});
test('search with no results shows empty state', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.waitForLoadState('networkidle');
// Wait for initial load
await expect(
page.locator('.classes-grid, .empty-state, .loading-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('zzz-nonexistent-class-xyz');
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i);
});
test('search term is synced to URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.classes-grid, .empty-state, .loading-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('6eme');
await page.waitForTimeout(500);
await expect(page).toHaveURL(/[?&]search=6eme/);
});
});
// ============================================================================
// SUBJECTS PAGE - Search & Pagination
// ============================================================================
test.describe('Subjects Page', () => {
test('displays search input on subjects page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i);
});
test('search with no results shows empty state', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.subjects-grid, .empty-state, .loading-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('zzz-nonexistent-subject-xyz');
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i);
});
test('search term is synced to URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.subjects-grid, .empty-state, .loading-state')
).toBeVisible({ timeout: 10000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('MATH');
await page.waitForTimeout(500);
await expect(page).toHaveURL(/[?&]search=MATH/);
});
});
// ============================================================================
// ASSIGNMENTS PAGE - Search & Pagination
// ============================================================================
test.describe('Assignments Page', () => {
test('displays search input on assignments page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
await expect(searchInput).toBeVisible({ timeout: 15000 });
await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i);
});
test('search with no results shows empty state', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await page.waitForLoadState('networkidle');
// Wait for initial load
await expect(
page.locator('.table-container, .empty-state, .loading-state')
).toBeVisible({ timeout: 15000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('zzz-nonexistent-teacher-xyz');
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i);
});
test('search term is synced to URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/assignments`);
await page.waitForLoadState('networkidle');
await expect(
page.locator('.table-container, .empty-state, .loading-state')
).toBeVisible({ timeout: 15000 });
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('Dupont');
await page.waitForTimeout(500);
await expect(page).toHaveURL(/[?&]search=Dupont/);
});
});
});

View File

@@ -37,7 +37,7 @@ async function loginAsAdmin(page: Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -132,7 +132,7 @@ test.describe('Child Selector', () => {
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -38,7 +38,7 @@ test.describe('Admin Class Detail Page [P1]', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -42,7 +42,7 @@ test.describe('Classes Management (Story 2.1)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -427,7 +427,7 @@ test.describe('Dashboard', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -94,7 +94,7 @@ test.describe('Guardian Management', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -59,7 +59,7 @@ test.describe('Login Flow', () => {
// Submit and wait for navigation to dashboard
await Promise.all([
page.waitForURL('/dashboard', { timeout: 10000 }),
page.waitForURL('/dashboard', { timeout: 30000 }),
submitButton.click()
]);
@@ -350,7 +350,7 @@ test.describe('Login Flow', () => {
await submitButton.click();
// Should redirect to dashboard (successful login)
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 });
});
test('user cannot login on different tenant', async ({ page }) => {
@@ -383,7 +383,7 @@ test.describe('Login Flow', () => {
await submitButton.click();
// Should redirect to dashboard (successful login)
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 });
});
});
});

View File

@@ -46,7 +46,7 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -39,7 +39,7 @@ test.describe('Periods Management (Story 2.3)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -0,0 +1,212 @@
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 per role
const ADMIN_EMAIL = 'e2e-rbac-admin@example.com';
const ADMIN_PASSWORD = 'RbacAdmin123';
const TEACHER_EMAIL = 'e2e-rbac-teacher@example.com';
const TEACHER_PASSWORD = 'RbacTeacher123';
const PARENT_EMAIL = 'e2e-rbac-parent@example.com';
const PARENT_PASSWORD = 'RbacParent123';
test.describe('Role-Based Access Control [P0]', () => {
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 teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
// Create parent user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`,
{ encoding: 'utf-8' }
);
});
async function loginAs(
page: import('@playwright/test').Page,
email: string,
password: string
) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(email);
await page.locator('#password').fill(password);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// ============================================================================
// Admin access - should have access to all /admin pages
// ============================================================================
test.describe('Admin Access', () => {
test('[P0] admin user can access /admin/users page', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/users`);
// Admin should see the users management page
await expect(page).toHaveURL(/\/admin\/users/);
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
});
test('[P0] admin user can access /admin/classes page', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/classes`);
await expect(page).toHaveURL(/\/admin\/classes/);
await expect(
page.getByRole('heading', { name: /gestion des classes/i })
).toBeVisible({ timeout: 10000 });
});
test('[P0] admin user can access /admin/pedagogy page', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await expect(page).toHaveURL(/\/admin\/pedagogy/);
});
test('[P0] admin user can access /admin page', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
await expect(page).toHaveURL(/\/admin/);
await expect(
page.getByRole('heading', { name: /administration/i })
).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// Teacher access - should NOT have access to /admin pages
// ============================================================================
test.describe('Teacher Access Restrictions', () => {
test('[P0] teacher cannot access /admin/users page', async ({ page }) => {
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/users`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
test('[P0] teacher cannot access /admin page', async ({ page }) => {
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
});
// ============================================================================
// Parent access - should NOT have access to /admin pages
// ============================================================================
test.describe('Parent Access Restrictions', () => {
test('[P0] parent cannot access /admin/users page', async ({ page }) => {
await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/users`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
test('[P0] parent cannot access /admin/classes page', async ({ page }) => {
await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
test('[P0] parent cannot access /admin page', async ({ page }) => {
await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
expect(page.url()).toContain('/dashboard');
});
});
// ============================================================================
// Unauthenticated user - should be redirected to /login
// ============================================================================
test.describe('Unauthenticated Access', () => {
test('[P0] unauthenticated user is redirected from /settings/sessions to /login', async ({ page }) => {
// Clear any existing session
await page.context().clearCookies();
await page.goto(`${ALPHA_URL}/settings/sessions`);
// Should be redirected to login
await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
});
test('[P0] unauthenticated user is redirected from /admin/users to /login', async ({ page }) => {
await page.context().clearCookies();
await page.goto(`${ALPHA_URL}/admin/users`);
// Should be redirected away from /admin/users (to /login or /dashboard)
await page.waitForURL((url) => !url.toString().includes('/admin/users'), { timeout: 10000 });
expect(page.url()).not.toContain('/admin/users');
});
});
// ============================================================================
// Navigation reflects role permissions
// ============================================================================
test.describe('Navigation Reflects Permissions', () => {
test('[P0] admin layout shows admin navigation links', async ({ page }) => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
// Admin layout should show navigation links (scoped to header nav to avoid action cards)
const nav = page.locator('.header-nav');
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 });
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
});
test('[P0] teacher sees dashboard without admin navigation', async ({ page }) => {
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
// Teacher should be on dashboard
await expect(page).toHaveURL(/\/dashboard/);
// Teacher should not see admin-specific navigation in the dashboard layout
// The dashboard header should not have admin links like "Utilisateurs"
const adminUsersLink = page.locator('.header-nav').getByRole('link', { name: 'Utilisateurs' });
await expect(adminUsersLink).not.toBeVisible();
});
});
});

View File

@@ -49,7 +49,7 @@ async function login(page: import('@playwright/test').Page, email: string) {
await page.locator('#email').fill(email);
await page.locator('#password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(getTenantUrl('/dashboard'), { timeout: 10000 });
await page.waitForURL(getTenantUrl('/dashboard'), { timeout: 30000 });
}
test.describe('Sessions Management', () => {

View File

@@ -57,7 +57,7 @@ test.describe('Settings Page [P1]', () => {
await page.locator('#email').fill(email);
await page.locator('#password').fill(USER_PASSWORD);
await Promise.all([
page.waitForURL(getTenantUrl('/dashboard'), { timeout: 10000 }),
page.waitForURL(getTenantUrl('/dashboard'), { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -197,6 +197,6 @@ test.describe('Settings Page [P1]', () => {
// Click the logo button
await page.locator('.logo-button').click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 });
});
});

View File

@@ -86,7 +86,7 @@ test.describe('Student Management', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -100,11 +100,11 @@ test.describe('Student Management', () => {
* Waiting for one of these ensures the component is interactive.
*/
async function waitForGuardianSection(page: import('@playwright/test').Page) {
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 15000 });
await expect(
page.getByText(/aucun parent\/tuteur lié/i)
.or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
).toBeVisible({ timeout: 15000 });
}
// ============================================================================
@@ -151,8 +151,20 @@ test.describe('Student Management', () => {
await waitForGuardianSection(page);
// Clean up guardians if any exist (cross-browser interference: parallel
// browser projects share the same DB, so another browser may have added
// guardians between our beforeAll cleanup and this test).
while (await page.locator('.guardian-item').count() > 0) {
const item = page.locator('.guardian-item').first();
await item.getByRole('button', { name: /retirer/i }).click();
await expect(item.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
await item.getByRole('button', { name: /oui/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(500);
}
// Should show the empty state
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible();
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 15000 });
// The "add guardian" button should be visible
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible();
@@ -342,6 +354,12 @@ test.describe('Student Management', () => {
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Search for the student user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(STUDENT_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// The student email should appear in the users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
@@ -355,6 +373,12 @@ test.describe('Student Management', () => {
// Wait for users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the student user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(STUDENT_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the student row and verify role
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
await expect(studentRow).toContainText(/élève/i);

View File

@@ -42,7 +42,7 @@ test.describe('Subjects Management (Story 2.2)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -49,7 +49,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -51,7 +51,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -61,7 +61,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.locator('#email').fill(TARGET_EMAIL);
await page.locator('#password').fill(TARGET_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -70,6 +70,12 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.goto(`${ALPHA_URL}/admin/users`);
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
@@ -93,6 +99,12 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.goto(`${ALPHA_URL}/admin/users`);
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
@@ -181,7 +193,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await userPage.getByRole('button', { name: /se connecter/i }).click();
// Should redirect to dashboard (successful login)
await expect(userPage).toHaveURL(/\/dashboard/, { timeout: 10000 });
await expect(userPage).toHaveURL(/\/dashboard/, { timeout: 30000 });
} finally {
await userContext.close();
}
@@ -196,6 +208,12 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the admin user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(ADMIN_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the admin's own row
const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) });
await expect(adminRow).toBeVisible();

View File

@@ -41,7 +41,7 @@ test.describe('User Blocking', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -53,6 +53,12 @@ test.describe('User Blocking', () => {
// Wait for users table to load
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the target user row
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
@@ -86,6 +92,12 @@ test.describe('User Blocking', () => {
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the suspended target user row
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
@@ -111,6 +123,12 @@ test.describe('User Blocking', () => {
await page.goto(`${ALPHA_URL}/admin/users`);
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(TARGET_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(async () => {
await targetRow.getByRole('button', { name: /bloquer/i }).click();
@@ -141,6 +159,12 @@ test.describe('User Blocking', () => {
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the admin user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(ADMIN_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the admin's own row
const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) });
await expect(adminRow).toBeVisible();

View File

@@ -34,7 +34,7 @@ test.describe('User Creation', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -71,6 +71,12 @@ test.describe('User Creation', () => {
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(INVITED_EMAIL);
// Search for the newly created user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(INVITED_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Verify the user appears in the table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const newUserRow = page.locator('tr', { has: page.locator(`text=${INVITED_EMAIL}`) });