feat: Provisionner automatiquement un nouvel établissement
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
This commit is contained in:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit dc2be898d5
171 changed files with 11703 additions and 700 deletions

View File

@@ -105,10 +105,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
// Navigate to the student's page to check guardian list
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);

View File

@@ -30,10 +30,8 @@ test.describe('Admin Responsive Navigation', () => {
await page.waitForLoadState('networkidle');
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// =========================================================================

View File

@@ -29,10 +29,8 @@ test.describe('Admin Search & Pagination (Story 2.8b)', () => {
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// ============================================================================

View File

@@ -14,10 +14,8 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
/** Navigate to grades page and verify grade is loaded (pre-seeded via SQL). */

View File

@@ -86,10 +86,8 @@ test.describe('Branding Visual Customization', () => {
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
/**
@@ -214,18 +212,23 @@ test.describe('Branding Visual Customization', () => {
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Set a dark blue color via color picker (more reliable than text input across browsers)
// Wrap in toPass() to handle Firefox timing where fill() may not immediately trigger change event
await expect(async () => {
await page.locator('#primaryColorPicker').fill('#1e3a5f');
await expect(page.getByRole('button', { name: /enregistrer/i })).toBeEnabled({ timeout: 2000 });
}).toPass({ timeout: 10000 });
// Set a dark blue color via JavaScript to ensure change event fires on all browsers
await page.locator('#primaryColorPicker').evaluate((el: HTMLInputElement) => {
el.value = '#1e3a5f';
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
});
// Wait for button to become enabled and click it
const saveBtn = page.getByRole('button', { name: /enregistrer/i });
await expect(saveBtn).toBeEnabled({ timeout: 10000 });
// Click save and wait for API response
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
await page.getByRole('button', { name: /enregistrer/i }).click();
await saveBtn.click();
await responsePromise;
// Success message

View File

@@ -77,20 +77,16 @@ test.describe('Calendar Management (Story 2.11)', () => {
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// ============================================================================

View File

@@ -37,10 +37,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function addGuardianIfNotLinked(page: Page, studentId: string, parentSearchTerm: string, relationship: string) {
@@ -144,10 +142,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test('[P1] parent with multiple children should see child selector', async ({ page }) => {

View File

@@ -60,10 +60,8 @@ test.describe('Admin Class Detail Page [P1]', () => {
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// Helper to open "Nouvelle classe" dialog with proper wait

View File

@@ -88,10 +88,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// Helper to open "Nouvelle classe" dialog with proper wait

View File

@@ -28,20 +28,16 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function loginAsStudent(page: import('@playwright/test').Page, email: string) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(email);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// Deterministic IDs for test data
@@ -233,14 +229,15 @@ test.describe('Competencies Mode (Story 6.5)', () => {
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
const firstCell = page.locator('.level-cell').first();
const levelBtn = firstCell.locator('.level-btn').nth(1);
// Click to set level
await levelBtn.click();
// Use keyboard to set level (more reliable than click for toggle)
await firstCell.click();
await firstCell.press('2');
const levelBtn = firstCell.locator('.level-btn').nth(1);
await expect(levelBtn).toHaveClass(/active/, { timeout: 15000 });
// Click same button immediately to toggle off (no wait for save)
await levelBtn.click();
// Use keyboard to toggle off by pressing same key
await firstCell.press('2');
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 15000 });
});
});
@@ -251,21 +248,21 @@ test.describe('Competencies Mode (Story 6.5)', () => {
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
// Focus the first cell
// Click the first cell to ensure focus (more reliable than focus() across browsers)
const firstCell = page.locator('.level-cell').first();
await firstCell.focus();
await firstCell.click();
// Press '3' to set level 3 (Acquis)
await firstCell.press('3');
// The third level button should become active
await expect(firstCell.locator('.level-btn').nth(2)).toHaveClass(/active/, { timeout: 5000 });
await expect(firstCell.locator('.level-btn').nth(2)).toHaveClass(/active/, { timeout: 10000 });
// Press '1' to set level 1 (Non acquis)
await firstCell.press('1');
// The first level button should become active, third should not
await expect(firstCell.locator('.level-btn').first()).toHaveClass(/active/, { timeout: 5000 });
await expect(firstCell.locator('.level-btn').first()).toHaveClass(/active/, { timeout: 10000 });
await expect(firstCell.locator('.level-btn').nth(2)).not.toHaveClass(/active/, { timeout: 5000 });
});

View File

@@ -21,10 +21,8 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Dashboard Responsive Navigation', () => {

View File

@@ -202,7 +202,7 @@ test.describe('Dashboard', () => {
await expect(page.getByText(/vos classes apparaîtront ici/i)).toBeVisible();
await expect(page.getByText(/évaluations en attente de notation/i)).toBeVisible();
await expect(page.getByText(/les appels à effectuer/i)).toBeVisible();
await expect(page.getByText(/les statistiques de vos classes/i)).toBeVisible();
await expect(page.getByText(/les statistiques apparaîtront après la publication de notes/i)).toBeVisible();
});
});
@@ -497,10 +497,8 @@ test.describe('Dashboard', () => {
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test('clicking "Gerer les utilisateurs" navigates to users page', async ({ page }) => {

View File

@@ -14,10 +14,8 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function navigateToEvaluations(page: import('@playwright/test').Page) {

View File

@@ -0,0 +1,189 @@
import { test, expect } from '@playwright/test';
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
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 ASSIGNED_TEACHER_EMAIL = 'e2e-gac-assigned@example.com';
const UNASSIGNED_TEACHER_EMAIL = 'e2e-gac-unassigned@example.com';
const REPLACEMENT_TEACHER_EMAIL = 'e2e-gac-replacement@example.com';
const PASSWORD = 'GACTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
let evaluationId: string;
let classId: string;
let subjectId: string;
async function loginAs(page: import('@playwright/test').Page, email: string) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(email);
await page.locator('#password').fill(PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Grade Access Control (Story 6.9)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Create users
createTestUser('ecole-alpha', ASSIGNED_TEACHER_EMAIL, PASSWORD, 'ROLE_PROF');
createTestUser('ecole-alpha', UNASSIGNED_TEACHER_EMAIL, PASSWORD, 'ROLE_PROF');
createTestUser('ecole-alpha', REPLACEMENT_TEACHER_EMAIL, PASSWORD, 'ROLE_PROF');
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
// Deterministic IDs
classId = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","gac-class-${TENANT_ID}")->toString();` +
`' 2>&1`
).trim();
subjectId = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","gac-subject-${TENANT_ID}")->toString();` +
`' 2>&1`
).trim();
evaluationId = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","gac-eval-${TENANT_ID}")->toString();` +
`' 2>&1`
).trim();
// Create class and subject
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-GAC-6C', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-GAC-Maths', 'E2GACMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Create one test student
createTestUser('ecole-alpha', 'e2e-gac-student@example.com', PASSWORD, 'ROLE_ELEVE --firstName=Léa --lastName=Dupont');
// Assign student to class
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${academicYearId}', NOW(), NOW(), NOW() ` +
`FROM users u WHERE u.email = 'e2e-gac-student@example.com' AND u.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT (user_id, academic_year_id) DO NOTHING`
);
// Assign ONLY the assigned teacher
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u WHERE u.email = '${ASSIGNED_TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Create evaluation owned by assigned teacher
runSql(
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, created_at, updated_at) ` +
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'E2E-GAC Contrôle', '2026-04-15', 20, 1.0, 'published', NOW(), NOW() ` +
`FROM users u WHERE u.email = '${ASSIGNED_TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Create active replacement for the replacement teacher (valid now → +7 days)
runSql(
`INSERT INTO teacher_replacements (id, tenant_id, replaced_teacher_id, replacement_teacher_id, start_date, end_date, status, reason, created_by, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', ` +
`(SELECT id FROM users WHERE email = '${ASSIGNED_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), ` +
`(SELECT id FROM users WHERE email = '${REPLACEMENT_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), ` +
`NOW() - INTERVAL '1 day', NOW() + INTERVAL '7 days', 'active', 'Maladie', ` +
`(SELECT id FROM users WHERE email = '${ASSIGNED_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), ` +
`NOW(), NOW() ` +
`ON CONFLICT DO NOTHING`
);
// Link replacement to the class/subject
runSql(
`INSERT INTO replacement_classes (replacement_id, class_id, subject_id) ` +
`SELECT tr.id, '${classId}', '${subjectId}' ` +
`FROM teacher_replacements tr ` +
`JOIN users u ON tr.replacement_teacher_id = u.id ` +
`WHERE u.email = '${REPLACEMENT_TEACHER_EMAIL}' AND tr.tenant_id = '${TENANT_ID}' AND tr.status = 'active' ` +
`ON CONFLICT DO NOTHING`
);
clearCache();
});
test('AC1: assigned teacher can access grade grid', async ({ page }) => {
await loginAs(page, ASSIGNED_TEACHER_EMAIL);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
// Should see the grade grid with at least one student
const gradeGrid = page.locator('.grade-grid');
await expect(gradeGrid).toBeVisible({ timeout: 15000 });
// Should see student name
await expect(page.locator('.student-name')).toHaveCount(1);
});
test('AC1: unassigned teacher cannot access grade grid', async ({ page }) => {
await loginAs(page, UNASSIGNED_TEACHER_EMAIL);
// Navigate to the evaluation's grade page
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
// Backend returns 403, frontend shows "Évaluation non trouvée"
await expect(page.getByText(/non trouvée/i)).toBeVisible({ timeout: 15000 });
// Grade grid should NOT be visible
await expect(page.locator('.grade-grid')).not.toBeVisible();
});
test('AC1: active replacement teacher can access grade grid', async ({ page }) => {
await loginAs(page, REPLACEMENT_TEACHER_EMAIL);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
// Should see the grade grid
const gradeGrid = page.locator('.grade-grid');
await expect(gradeGrid).toBeVisible({ timeout: 15000 });
// Should see student name
await expect(page.locator('.student-name')).toHaveCount(1);
});
test('AC1: expired replacement teacher cannot access grade grid', async ({ page }) => {
// Terminate the replacement to simulate expiry
runSql(
`UPDATE teacher_replacements SET status = 'ended', end_date = NOW() - INTERVAL '1 day', ended_at = NOW(), updated_at = NOW() ` +
`WHERE replacement_teacher_id = (SELECT id FROM users WHERE email = '${REPLACEMENT_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') ` +
`AND tenant_id = '${TENANT_ID}'`
);
clearCache();
await loginAs(page, REPLACEMENT_TEACHER_EMAIL);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
// Backend returns 403, frontend shows "Évaluation non trouvée"
await expect(page.getByText(/non trouvée/i)).toBeVisible({ timeout: 15000 });
// Grade grid should NOT be visible
await expect(page.locator('.grade-grid')).not.toBeVisible();
});
test.afterAll(async () => {
// Restaurer le remplacement pour ne pas polluer les autres tests
runSql(
`UPDATE teacher_replacements SET status = 'active', end_date = NOW() + INTERVAL '7 days', ended_at = NULL, updated_at = NOW() ` +
`WHERE replacement_teacher_id = (SELECT id FROM users WHERE email = '${REPLACEMENT_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') ` +
`AND tenant_id = '${TENANT_ID}'`
);
clearCache();
});
});

View File

@@ -14,10 +14,8 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// Deterministic IDs for test data
@@ -171,7 +169,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
await firstInput.fill('25');
// Should show error
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 15000 });
});
});
@@ -253,15 +251,15 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
await page.waitForLoadState('networkidle');
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Start listening for the PUT response BEFORE triggering the save
// Enter grades
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
// Setup response listener right before triggering the change
const savePromise = page.waitForResponse(
(resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Enter grades
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
await firstInput.fill('18');
await expect(page.locator('.status-graded').first()).toContainText('18/20', { timeout: 10000 });
@@ -299,15 +297,15 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
await page.waitForLoadState('networkidle');
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Start listening for PUT before triggering
// Enter a grade and wait for save
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
// Setup response listener right before triggering the change
const savePromise = page.waitForResponse(
(resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Enter a grade
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
await firstInput.fill('14');
await expect(page.locator('.status-graded').first()).toContainText('14/20', { timeout: 10000 });

View File

@@ -93,10 +93,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
/**

View File

@@ -12,7 +12,6 @@ const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
// Réutilise le même enseignant que homework.spec.ts pour partager le setup
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
const TEACHER_PASSWORD = 'HomeworkTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
@@ -86,10 +85,8 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function navigateToHomework(page: import('@playwright/test').Page) {
@@ -105,7 +102,6 @@ async function createHomework(page: import('@playwright/test').Page, title: stri
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill(title);
// Type in WYSIWYG editor (TipTap initializes asynchronously)
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
@@ -158,9 +154,8 @@ function cleanupTempFiles() {
}
}
test.describe('Rich Text & Attachments (Story 5.9)', () => {
test.describe('Homework Attachments (Story 5.9/5.11)', () => {
test.beforeAll(async () => {
// Ensure teacher user exists (same as homework.spec.ts)
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' }
@@ -189,7 +184,6 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
});
test.beforeEach(async () => {
// homework_submissions has NO CASCADE on homework_id — delete submissions first
try {
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
} catch { /* Table may not exist */ }
@@ -198,138 +192,41 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
} catch { /* Table may not exist */ }
try {
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
}
// Disable any homework rules left by other test files (homework-rules-warning,
// homework-rules-hard) to prevent rule warnings blocking homework creation.
} catch { /* Table may not exist */ }
try {
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
} catch { /* Table may not exist */ }
// Clear school calendar entries that may block dates (Vacances de Printemps, etc.)
try {
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
} catch { /* Table may not exist */ }
clearCache();
});
// ============================================================================
// T4.1 : WYSIWYG Editor
// ============================================================================
test.describe('WYSIWYG Editor', () => {
test('create form shows rich text editor with toolbar', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Rich text editor with toolbar should be visible
const editor = page.locator('.rich-text-editor');
await expect(editor).toBeVisible({ timeout: 5000 });
await expect(page.locator('.toolbar')).toBeVisible();
// Toolbar buttons
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
});
test('can create homework with rich text description', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir texte riche');
// Type in rich text editor (TipTap initializes asynchronously)
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await page.keyboard.type('Consignes importantes');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
});
test('bold formatting works in editor', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir gras test');
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await page.keyboard.type('Normal ');
// Apply bold via keyboard shortcut (more reliable than toolbar click)
await page.keyboard.press('Control+b');
await page.keyboard.type('en gras');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
// Verify bold is rendered in the description
const description = page.locator('.homework-description');
await expect(description.locator('strong')).toContainText('en gras');
});
});
// ============================================================================
// T4.2 : Upload attachment
// ============================================================================
test.describe('Attachments', () => {
test.describe('Upload & Delete', () => {
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// Create homework
await createHomework(page, 'Devoir avec PJ');
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Upload file
const pdfPath = createTempPdf();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(pdfPath);
// File appears in list
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
});
// T4.3 : Delete attachment
test('can delete an uploaded attachment', async ({ page }) => {
test.slow(); // upload + delete needs more than 30s
test.slow();
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir suppr PJ');
// Open edit modal and upload
const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
@@ -338,80 +235,142 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
// Delete the attachment
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// T5.9.1 : Invalid file type rejection (P1)
// ============================================================================
test.describe('Invalid File Type Rejection', () => {
test.describe('Drop Zone UI', () => {
test('create form shows drag-and-drop zone for attachments', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir drop zone');
const hwCard = page.locator('.homework-card', { hasText: 'Devoir drop zone' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const dropZone = page.locator('.drop-zone');
await expect(dropZone).toBeVisible({ timeout: 5000 });
await expect(dropZone).toContainText('Glissez-déposez');
await expect(dropZone.locator('.drop-zone-browse')).toContainText('parcourir');
});
test('browse button in drop zone opens file dialog and uploads', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir browse btn');
const hwCard = page.locator('.homework-card', { hasText: 'Devoir browse btn' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const pdfPath = createTempPdf();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(pdfPath);
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
});
test('drag-and-drop visual feedback appears on dragover', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir drag feedback');
const hwCard = page.locator('.homework-card', { hasText: 'Devoir drag feedback' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const dropZone = page.locator('.drop-zone');
await expect(dropZone).toBeVisible({ timeout: 5000 });
await dropZone.evaluate((el) => {
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: new DataTransfer(), bubbles: true }));
});
await expect(dropZone).toHaveClass(/drop-zone-active/);
await dropZone.evaluate((el) => {
el.dispatchEvent(new DragEvent('dragleave', { bubbles: true }));
});
await expect(dropZone).not.toHaveClass(/drop-zone-active/);
});
});
test.describe('File Type Badge', () => {
test('uploaded PDF shows formatted type badge', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir badge type');
const hwCard = page.locator('.homework-card', { hasText: 'Devoir badge type' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const pdfPath = createTempPdf();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(pdfPath);
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
const fileType = page.locator('.file-type');
await expect(fileType).toBeVisible({ timeout: 5000 });
await expect(fileType).toHaveText('PDF');
});
});
test.describe('Validation', () => {
test('rejects a .txt file with an error message', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir rejet fichier');
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Try to upload a .txt file
const txtPath = createTempTxt();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(txtPath);
// Error message should appear
const errorAlert = page.locator('[role="alert"]');
await expect(errorAlert).toBeVisible({ timeout: 5000 });
await expect(errorAlert).toContainText('Type de fichier non accepté');
// The .txt file should NOT appear in the file list
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
});
});
// ============================================================================
// T5.9.2 : Attachment persistence after save (P1)
// ============================================================================
test.describe('Attachment Persistence', () => {
test.describe('Persistence', () => {
test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir persistance PJ');
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Upload a PDF
const pdfPath = createTempPdf();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(pdfPath);
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
// Save the changes
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Reopen the edit modal
const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// The attachment should still be there
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
});
});
// ============================================================================
// T5.9.3 : File size display after upload (P2)
// ============================================================================
test.describe('File Size Display', () => {
test('shows formatted file size after uploading a PDF', async ({ page }) => {
await loginAsTeacher(page);
@@ -419,73 +378,18 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
await createHomework(page, 'Devoir taille fichier');
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Upload a PDF
const pdfPath = createTempPdf();
const fileInput = page.locator('.file-input-hidden');
await fileInput.setInputFiles(pdfPath);
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
// The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko")
const fileSize = page.locator('.file-size');
await expect(fileSize).toBeVisible({ timeout: 5000 });
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
});
});
// ============================================================================
// T4.4 : Backward compatibility
// ============================================================================
test.describe('Backward Compatibility', () => {
test('existing plain text homework displays correctly', async ({ page }) => {
// Create homework with plain text description via SQL
runSql(
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
`FROM users u, school_classes c, subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
`LIMIT 1`
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
// Plain text description displays correctly
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
});
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
runSql(
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
`FROM users u, school_classes c, subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
`LIMIT 1`
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// WYSIWYG editor contains the old text (TipTap initializes asynchronously)
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
});
});
});

View File

@@ -0,0 +1,393 @@
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 TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
const TEACHER_PASSWORD = 'HomeworkTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
function getNextWeekday(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
const day = date.getDay();
if (day === 0) date.setDate(date.getDate() + 1);
if (day === 6) date.setDate(date.getDate() + 2);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function navigateToHomework(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
}
test.describe('Calendar Date Picker (Story 5.11)', () => {
test.beforeAll(async () => {
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' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
test.beforeEach(async () => {
try {
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
} catch { /* Table may not exist */ }
try {
runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
} catch { /* Table may not exist */ }
try {
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch { /* Table may not exist */ }
try {
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
} catch { /* Table may not exist */ }
try {
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
} catch { /* Table may not exist */ }
clearCache();
});
test('create form shows calendar date picker instead of native input', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Calendar date picker should be visible
const picker = page.locator('.calendar-date-picker');
await expect(picker).toBeVisible({ timeout: 5000 });
// Trigger button should show placeholder text
await expect(picker.locator('.picker-trigger')).toContainText('Choisir une date');
// Native date input is sr-only (hidden but present for form semantics)
await expect(page.locator('#hw-due-date')).toHaveAttribute('aria-hidden', 'true');
});
test('clicking picker opens calendar dropdown', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const picker = page.locator('.modal .calendar-date-picker');
await picker.locator('.picker-trigger').click();
// Calendar dropdown should be visible
const dropdown = picker.locator('.calendar-dropdown');
await expect(dropdown).toBeVisible({ timeout: 3000 });
// Day names header should be visible
await expect(dropdown.locator('.day-name').first()).toBeVisible();
// Month label should be visible
await expect(dropdown.locator('.month-label')).toBeVisible();
});
test('weekends are disabled in calendar', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const picker = page.locator('.modal .calendar-date-picker');
await picker.locator('.picker-trigger').click();
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
// Weekend cells should have the weekend class and be disabled
const weekendCells = picker.locator('.day-cell.weekend');
const count = await weekendCells.count();
expect(count).toBeGreaterThan(0);
// Weekend cells should be disabled
const firstWeekend = weekendCells.first();
await expect(firstWeekend).toBeDisabled();
});
test('can select a date by clicking a day in the calendar', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir calendrier test');
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await page.keyboard.type('Test description');
const picker = page.locator('.modal .calendar-date-picker');
await picker.locator('.picker-trigger').click();
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
// Find a valid weekday button (not disabled, not weekend, not before-min)
const validDays = picker.locator('.day-cell:not(.weekend):not(.blocked):not(.before-min):not(.empty):enabled');
const validCount = await validDays.count();
expect(validCount).toBeGreaterThan(0);
// Click the last available day (likely to be far enough in the future)
await validDays.last().click();
// Dropdown should close
await expect(picker.locator('.calendar-dropdown')).not.toBeVisible({ timeout: 3000 });
// Trigger should now show the selected date (not placeholder)
await expect(picker.locator('.picker-value')).toBeVisible();
});
test('can navigate to next/previous month', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const picker = page.locator('.modal .calendar-date-picker');
await picker.locator('.picker-trigger').click();
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
const monthLabel = picker.locator('.month-label');
const initialMonth = await monthLabel.textContent();
// Navigate to next month
await picker.getByRole('button', { name: /mois suivant/i }).click();
const nextMonth = await monthLabel.textContent();
expect(nextMonth).not.toBe(initialMonth);
// Navigate back
await picker.getByRole('button', { name: /mois précédent/i }).click();
const backMonth = await monthLabel.textContent();
expect(backMonth).toBe(initialMonth);
});
test('can create homework using calendar date picker (hidden input fallback)', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir via calendrier');
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await page.keyboard.type('Description du devoir');
// Use hidden date input for programmatic date selection (E2E compatibility)
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir via calendrier')).toBeVisible({ timeout: 10000 });
});
test('edit modal shows calendar date picker with current date selected', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// Create homework first via hidden input
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir edit calendrier');
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await page.keyboard.type('Description');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Open edit modal
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit calendrier' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Edit modal should have calendar picker with date displayed
const editPicker = page.locator('.modal .calendar-date-picker');
await expect(editPicker).toBeVisible({ timeout: 5000 });
await expect(editPicker.locator('.picker-value')).toBeVisible();
});
test('rule-hard blocked dates show colored dots in calendar', async ({ page }) => {
// Seed a homework rule (minimum_delay=30 days, hard mode) so dates within 30 days are blocked.
// Using 30 days ensures the current month always has blocked weekdays visible.
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":30}}]';
runSql(
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` +
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const picker = page.locator('.modal .calendar-date-picker');
await picker.locator('.picker-trigger').click();
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
// Navigate to next month to guarantee blocked dates are visible
await picker.getByRole('button', { name: /mois suivant/i }).click();
// Days within the 30-day delay window should be rule-blocked
const blockedWithDot = picker.locator('.day-cell.blocked .blocked-dot');
await expect(blockedWithDot.first()).toBeVisible({ timeout: 5000 });
// Legend should show "Règle (bloquant)"
await expect(picker.locator('.calendar-legend')).toContainText('Règle (bloquant)');
});
test('blocked dates (holidays) show colored dots in calendar', async ({ page }) => {
// Seed a holiday entry covering a guaranteed weekday next month
const { academicYearId } = resolveDeterministicIds();
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
// Start from the 10th and find the first weekday (Mon-Fri)
let holidayDay = 10;
const probe = new Date(nextMonth.getFullYear(), nextMonth.getMonth(), holidayDay);
while (probe.getDay() === 0 || probe.getDay() === 6) {
holidayDay++;
probe.setDate(holidayDay);
}
const holidayDate = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, '0')}-${String(holidayDay).padStart(2, '0')}`;
try {
runSql(
`INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, description, created_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', 'holiday', '${holidayDate}', '${holidayDate}', 'Jour férié E2E', NULL, NOW())`
);
} catch {
// May already exist
}
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const picker = page.locator('.modal .calendar-date-picker');
await picker.locator('.picker-trigger').click();
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
// Navigate to next month
await picker.getByRole('button', { name: /mois suivant/i }).click();
// The holiday day should be blocked with a colored dot
const holidayCell = picker.getByRole('gridcell', { name: String(holidayDay), exact: true });
await expect(holidayCell).toBeVisible({ timeout: 5000 });
await expect(holidayCell).toBeDisabled();
await expect(holidayCell.locator('.blocked-dot')).toBeVisible();
// Legend should show "Jour férié"
await expect(picker.locator('.calendar-legend')).toContainText('Jour férié');
});
});

View File

@@ -67,10 +67,8 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click(),
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
@@ -297,8 +295,8 @@ test.describe('Homework Exception Request (Story 5.6)', () => {
.fill('Justification suffisamment longue pour être valide.');
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
// Wait for homework to appear
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
// Wait for homework to appear (Firefox needs more time after exception flow)
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 20000 });
// Exception badge visible (⚠ Exception text or rule override badge)
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });

View File

@@ -71,10 +71,8 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click(),
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function navigateToHomework(page: import('@playwright/test').Page) {

View File

@@ -71,10 +71,8 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click(),
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function navigateToHomework(page: import('@playwright/test').Page) {

View File

@@ -76,10 +76,8 @@ test.describe('Homework Rules Configuration', () => {
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function waitForPageLoaded(page: import('@playwright/test').Page) {

View File

@@ -37,20 +37,16 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Homework Submission (Story 5.10)', () => {
@@ -240,7 +236,7 @@ test.describe('Homework Submission (Story 5.10)', () => {
// Should show success
await expect(page.locator('.success-banner')).toContainText('rendu avec succès', {
timeout: 10000
timeout: 20000
});
});
});

View File

@@ -0,0 +1,314 @@
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 TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
const TEACHER_PASSWORD = 'HomeworkTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
function getNextWeekday(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
const day = date.getDay();
if (day === 0) date.setDate(date.getDate() + 1);
if (day === 6) date.setDate(date.getDate() + 2);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function navigateToHomework(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
}
test.describe('WYSIWYG Editor & Backward Compatibility (Story 5.9/5.11)', () => {
test.beforeAll(async () => {
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' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
test.beforeEach(async () => {
try {
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
} catch { /* Table may not exist */ }
try {
runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
} catch { /* Table may not exist */ }
try {
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch { /* Table may not exist */ }
try {
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
} catch { /* Table may not exist */ }
try {
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
} catch { /* Table may not exist */ }
clearCache();
});
test.describe('WYSIWYG Editor', () => {
test('create form shows rich text editor with toolbar', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const editor = page.locator('.rich-text-editor');
await expect(editor).toBeVisible({ timeout: 5000 });
await expect(page.locator('.toolbar')).toBeVisible();
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
});
test('can create homework with rich text description', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir texte riche');
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await page.keyboard.type('Consignes importantes');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
});
test('bold formatting works in editor', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir gras test');
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await page.keyboard.type('Normal ');
await page.keyboard.press('Control+b');
await page.keyboard.type('en gras');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
const description = page.locator('.homework-description');
await expect(description.locator('strong')).toContainText('en gras');
});
test('italic formatting works in editor', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir italique test');
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await page.keyboard.type('Normal ');
await page.keyboard.press('Control+i');
await page.keyboard.type('en italique');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir italique test')).toBeVisible({ timeout: 10000 });
const description = page.locator('.homework-description');
await expect(description.locator('em')).toContainText('en italique');
});
test('bullet list formatting works in editor', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir liste test');
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await editorContent.click();
await editorContent.press('Control+Shift+8');
await page.keyboard.type('Premier élément');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir liste test')).toBeVisible({ timeout: 10000 });
const description = page.locator('.homework-description');
await expect(description.locator('ul')).toBeVisible();
await expect(description.locator('li')).toContainText('Premier élément');
});
});
test.describe('Backward Compatibility', () => {
test('existing plain text homework displays correctly', async ({ page }) => {
runSql(
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
`FROM users u, school_classes c, subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
`LIMIT 1`
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
});
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
runSql(
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
`FROM users u, school_classes c, subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
`LIMIT 1`
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
await hwCard.getByRole('button', { name: /modifier/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const editorContent = page.locator('.modal .rich-text-content');
await expect(editorContent).toBeVisible({ timeout: 10000 });
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
});
});
});

View File

@@ -73,10 +73,8 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function navigateToHomework(page: import('@playwright/test').Page) {
@@ -225,7 +223,7 @@ test.describe('Homework Management (Story 5.1)', () => {
test('shows empty state when no homework exists', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await expect(page.getByText(/aucun devoir/i)).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/aucun devoir/i)).toBeVisible({ timeout: 20000 });
});
});
@@ -279,7 +277,7 @@ test.describe('Homework Management (Story 5.1)', () => {
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /annuler/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
});
});

View File

@@ -70,10 +70,8 @@ test.describe('Image Rights Management', () => {
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// Helper to login as student
@@ -81,10 +79,8 @@ test.describe('Image Rights Management', () => {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
/**

View File

@@ -58,10 +58,8 @@ test.describe('Login Flow', () => {
await expect(submitButton).toBeEnabled();
// Submit and wait for navigation to dashboard
await Promise.all([
page.waitForURL('/dashboard', { timeout: 30000 }),
submitButton.click()
]);
await submitButton.click();
await page.waitForURL('/dashboard', { timeout: 30000 });
// We should be on the dashboard
await expect(page).toHaveURL('/dashboard');
@@ -365,7 +363,7 @@ test.describe('Login Flow', () => {
// Should show error (user doesn't exist in this tenant)
const errorBanner = page.locator('.error-banner');
await expect(errorBanner).toBeVisible({ timeout: 5000 });
await expect(errorBanner).toBeVisible({ timeout: 15000 });
await expect(errorBanner).toContainText(/email ou .* mot de passe .* incorrect/i);
// Should still be on login page

View File

@@ -41,10 +41,8 @@ async function loginAsParent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Parent Grade Consultation (Story 6.7)', () => {

View File

@@ -107,20 +107,16 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function loginAsParent(page: Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function addGuardianIfNotLinked(page: Page, studentId: string, parentSearchTerm: string, relationship: string) {

View File

@@ -42,10 +42,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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
function createCsvFixture(filename: string, content: string): string {

View File

@@ -33,10 +33,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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Parent Invitations', () => {

View File

@@ -108,10 +108,8 @@ async function loginAsParent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
/**

View File

@@ -45,10 +45,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// =========================================================================

View File

@@ -38,10 +38,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// ============================================================================
@@ -236,7 +234,7 @@ test.describe('Periods Management (Story 2.3)', () => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/academic-year/periods`);
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: 'T1' })).toBeVisible({ timeout: 20000 });
const modifyButtons = page.getByRole('button', { name: /modifier les dates/i });
await expect(modifyButtons).toHaveCount(3);

View File

@@ -52,12 +52,11 @@ test.describe('Role-Based Access Control [P0]', () => {
password: string
) {
await page.goto(`${ALPHA_URL}/login`);
await page.waitForLoadState('networkidle');
await page.locator('#email').fill(email);
await page.locator('#password').fill(password);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// ============================================================================

View File

@@ -0,0 +1,166 @@
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-roles-admin@example.com';
const ADMIN_PASSWORD = 'RolesAdmin123';
const TARGET_EMAIL = `e2e-roles-target-${Date.now()}@example.com`;
const TARGET_PASSWORD = 'RolesTarget123';
test.describe('Multi-Role Assignment (FR5) [P2]', () => {
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 with single role (PROF)
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' }
);
});
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.waitForLoadState('domcontentloaded');
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function openRolesModalForTarget(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/admin/users`);
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the target user (paginated list may not show them on page 1)
await page.getByRole('searchbox').fill(TARGET_EMAIL);
await page.waitForTimeout(500); // debounce
// Find the target user row and click "Rôles" button
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible({ timeout: 10000 });
await targetRow.getByRole('button', { name: 'Rôles' }).click();
// Modal should appear
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.locator('#roles-modal-title')).toHaveText('Modifier les rôles');
}
test('[P2] admin can open role modal showing current roles', async ({ page }) => {
await loginAsAdmin(page);
await openRolesModalForTarget(page);
// Target user email should be displayed in modal
await expect(page.locator('.roles-modal-user')).toContainText(TARGET_EMAIL);
// ROLE_PROF should be checked (current role)
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
await expect(profCheckbox).toBeChecked();
// Other roles should be unchecked
const adminCheckbox = page.locator('.role-checkbox-label', { hasText: 'Directeur' }).locator('input[type="checkbox"]');
await expect(adminCheckbox).not.toBeChecked();
});
test('[P2] admin can assign multiple roles to a user', async ({ page }) => {
await loginAsAdmin(page);
await openRolesModalForTarget(page);
// Add Vie Scolaire role in addition to PROF
const vieScolaireLabel = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' });
await vieScolaireLabel.locator('input[type="checkbox"]').check();
// Both should now be checked
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
await expect(profCheckbox).toBeChecked();
await expect(vieScolaireLabel.locator('input[type="checkbox"]')).toBeChecked();
// Save
const saveResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
);
await page.getByRole('button', { name: 'Enregistrer' }).click();
const saveResponse = await saveResponsePromise;
expect(saveResponse.status()).toBeLessThan(400);
// Success message should appear
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
});
test('[P2] assigned roles persist after page reload', async ({ page }) => {
await loginAsAdmin(page);
await openRolesModalForTarget(page);
// Both PROF and VIE_SCOLAIRE should still be checked after reload
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
await expect(profCheckbox).toBeChecked();
await expect(vieScolaireCheckbox).toBeChecked();
});
test('[P2] admin can remove a role while keeping at least one', async ({ page }) => {
await loginAsAdmin(page);
await openRolesModalForTarget(page);
// Uncheck Vie Scolaire (added in previous test)
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
await vieScolaireCheckbox.uncheck();
// PROF should still be checked
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
await expect(profCheckbox).toBeChecked();
await expect(vieScolaireCheckbox).not.toBeChecked();
// Save
const saveResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
);
await page.getByRole('button', { name: 'Enregistrer' }).click();
await saveResponsePromise;
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
});
test('[P2] last role checkbox is disabled to prevent removal', async ({ page }) => {
await loginAsAdmin(page);
await openRolesModalForTarget(page);
// Only PROF should be checked now (after previous test removed VIE_SCOLAIRE)
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
await expect(profCheckbox).toBeChecked();
// Last role checkbox should be disabled
await expect(profCheckbox).toBeDisabled();
// "(dernier rôle)" hint should be visible
await expect(
page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('.role-checkbox-hint')
).toContainText('dernier rôle');
});
test('[P2] role modal can be closed with Escape', async ({ page }) => {
await loginAsAdmin(page);
await openRolesModalForTarget(page);
await page.getByRole('dialog').press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});
});

View File

@@ -114,10 +114,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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function waitForScheduleReady(page: import('@playwright/test').Page) {
@@ -464,7 +462,7 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Success message should appear
await expect(page.getByText('Créneau créé.')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Créneau créé.')).toBeVisible({ timeout: 15000 });
});
});

View File

@@ -85,10 +85,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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function waitForScheduleReady(page: import('@playwright/test').Page) {

View File

@@ -48,10 +48,8 @@ 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(TEST_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Sessions Management', () => {
@@ -264,14 +262,15 @@ test.describe('Sessions Management', () => {
await login(page, email);
await page.goto(getTenantUrl('/settings'));
await page.waitForLoadState('networkidle');
// Click logout button
const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i });
await expect(logoutButton).toBeVisible();
await logoutButton.click();
// Wait for redirect to login
await expect(page).toHaveURL(/login/, { timeout: 10000 });
await Promise.all([
page.waitForURL(/login/, { timeout: 30000 }),
logoutButton.click()
]);
});
test('logout clears authentication', async ({ page, browserName }, testInfo) => {
@@ -288,8 +287,8 @@ test.describe('Sessions Management', () => {
await expect(logoutButton).toBeVisible();
await logoutButton.click();
// Wait for redirect to login
await expect(page).toHaveURL(/login/, { timeout: 10000 });
// Wait for redirect to login (Firefox can be slow to redirect)
await expect(page).toHaveURL(/login/, { timeout: 20000 });
// Try to access protected page
await page.goto(getTenantUrl('/settings/sessions'));

View File

@@ -56,10 +56,8 @@ test.describe('Settings Page [P1]', () => {
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: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
}
// ============================================================================
@@ -153,10 +151,11 @@ test.describe('Settings Page [P1]', () => {
await login(page, email);
await page.goto(getTenantUrl('/settings'));
await page.waitForLoadState('networkidle');
await expect(
page.getByRole('link', { name: /tableau de bord/i })
).toBeVisible();
).toBeVisible({ timeout: 10000 });
});
test('[P1] settings layout shows Parametres navigation link as active', async ({ page }, testInfo) => {
@@ -176,19 +175,22 @@ test.describe('Settings Page [P1]', () => {
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');
test.setTimeout(90000);
const email = getTestEmail(testInfo.project.name);
await login(page, email);
await page.goto(getTenantUrl('/settings'));
await expect(page.getByRole('heading', { name: /paramètres/i })).toBeVisible({ timeout: 10000 });
await page.waitForLoadState('networkidle');
// Click logout button
// Click logout button and wait for the API call + redirect
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 });
// Should redirect to login — logout involves an API call before navigation
await expect(page).toHaveURL(/\/login/, { timeout: 60000 });
});
// ============================================================================
@@ -202,8 +204,9 @@ test.describe('Settings Page [P1]', () => {
await login(page, email);
await page.goto(getTenantUrl('/settings'));
await expect(page.getByRole('heading', { name: /paramètres/i })).toBeVisible({ timeout: 10000 });
// Click the logo button
// Click the logo button and wait for navigation
await page.locator('.logo-button').click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 });

View File

@@ -57,10 +57,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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function waitForStudentsPage(page: import('@playwright/test').Page) {
@@ -484,7 +482,7 @@ test.describe('Student Creation & Management (Story 3.0)', () => {
// Should find the student (use .first() because AC3 duplicate test creates a second one)
await expect(
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first()
).toBeVisible({ timeout: 10000 });
).toBeVisible({ timeout: 20000 });
});
test('rows are clickable and navigate to student detail', async ({ page }) => {

View File

@@ -33,10 +33,8 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Student Grade Consultation (Story 6.6)', () => {

View File

@@ -69,10 +69,8 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Student Homework Consultation (Story 5.7)', () => {

View File

@@ -45,10 +45,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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// Create CSV fixture file for tests
@@ -513,7 +511,7 @@ test.describe('Student Import via CSV', () => {
// Click the dropzone and verify the file chooser opens
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser', { timeout: 5000 }),
page.waitForEvent('filechooser', { timeout: 10000 }),
page.locator('.dropzone').click()
]);

View File

@@ -126,10 +126,8 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Student Schedule Consultation (Story 4.3)', () => {
@@ -481,7 +479,7 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
await navigateToSeededDay(page);
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
await expect(firstSlot).toBeVisible({ timeout: 15000 });
await expect(firstSlot).toBeVisible({ timeout: 20000 });
await firstSlot.click();
const dialog = page.getByRole('dialog');

View File

@@ -108,10 +108,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
/**

View File

@@ -86,10 +86,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
// Helper to open "Nouvelle matière" dialog with proper wait

View File

@@ -0,0 +1,205 @@
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 projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
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 SA_PASSWORD = 'SuperAdmin123';
const UNIQUE_SUFFIX = Date.now();
function getSuperAdminEmail(browserName: string): string {
return `e2e-prov-sa-${browserName}@test.com`;
}
// eslint-disable-next-line no-empty-pattern
test.beforeAll(async ({}, testInfo) => {
const browserName = testInfo.project.name;
const saEmail = getSuperAdminEmail(browserName);
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`,
{ encoding: 'utf-8' }
);
} catch (error) {
console.error(`[${browserName}] Failed to create super admin:`, error);
}
});
async function loginAsSuperAdmin(
page: import('@playwright/test').Page,
email: string
) {
await page.goto(`${ALPHA_URL}/login`);
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
await page.locator('#email').fill(email);
await page.locator('#password').fill(SA_PASSWORD);
const submitButton = page.getByRole('button', { name: /se connecter/i });
await Promise.all([
page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }),
submitButton.click()
]);
}
async function navigateToEstablishments(page: import('@playwright/test').Page) {
const link = page.getByRole('link', { name: /établissements/i });
await Promise.all([
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
link.click()
]);
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
}
async function navigateToCreateForm(page: import('@playwright/test').Page) {
const newLink = page.getByRole('link', { name: /nouvel établissement/i });
await expect(newLink).toBeVisible({ timeout: 10000 });
await Promise.all([
page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }),
newLink.click()
]);
await expect(page.locator('#name')).toBeVisible({ timeout: 10000 });
}
test.describe('Establishment Provisioning (Story 2-17) [P1]', () => {
test.describe.configure({ mode: 'serial' });
test.describe('Subdomain Auto-generation', () => {
test('[P1] typing name auto-generates subdomain', async ({ page }, testInfo) => {
const email = getSuperAdminEmail(testInfo.project.name);
await loginAsSuperAdmin(page, email);
await navigateToEstablishments(page);
await navigateToCreateForm(page);
await page.locator('#name').fill('École Saint-Exupéry');
// Subdomain should be auto-generated: accents removed, spaces→hyphens, lowercase
await expect(page.locator('#subdomain')).toHaveValue('ecole-saint-exupery');
});
test('[P2] subdomain suffix .classeo.fr is displayed', async ({ page }, testInfo) => {
const email = getSuperAdminEmail(testInfo.project.name);
await loginAsSuperAdmin(page, email);
await navigateToEstablishments(page);
await navigateToCreateForm(page);
await expect(page.locator('.subdomain-suffix')).toHaveText('.classeo.fr');
});
});
test.describe('Create Establishment Flow', () => {
const establishmentName = `E2E Test ${UNIQUE_SUFFIX}`;
const adminEmailForEstab = `admin-prov-${UNIQUE_SUFFIX}@test.com`;
test('[P1] submitting form creates establishment and redirects to list', async ({ page }, testInfo) => {
const email = getSuperAdminEmail(testInfo.project.name);
await loginAsSuperAdmin(page, email);
await navigateToEstablishments(page);
await navigateToCreateForm(page);
// Fill in the form
await page.locator('#name').fill(establishmentName);
await page.locator('#adminEmail').fill(adminEmailForEstab);
// Subdomain should be auto-generated
const subdomain = await page.locator('#subdomain').inputValue();
expect(subdomain.length).toBeGreaterThan(0);
// Submit
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
await expect(submitButton).toBeEnabled();
const apiResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/super-admin/establishments') && resp.request().method() === 'POST'
);
await submitButton.click();
// Verify API returns establishment in provisioning status
const apiResponse = await apiResponsePromise;
expect(apiResponse.status()).toBeLessThan(400);
const body = await apiResponse.json();
expect(body.status).toBe('provisioning');
// Should redirect back to establishments list
await page.waitForURL('**/super-admin/establishments', { timeout: 15000 });
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
});
test('[P1] created establishment appears in the list', async ({ page }, testInfo) => {
const email = getSuperAdminEmail(testInfo.project.name);
await loginAsSuperAdmin(page, email);
await navigateToEstablishments(page);
// The establishment created in previous test should be visible
await expect(page.locator('table')).toBeVisible({ timeout: 10000 });
await expect(page.locator('td', { hasText: establishmentName })).toBeVisible({ timeout: 10000 });
});
test('[P1] created establishment has a visible status badge', async ({ page }, testInfo) => {
const email = getSuperAdminEmail(testInfo.project.name);
await loginAsSuperAdmin(page, email);
await navigateToEstablishments(page);
// Find the row for our establishment
const row = page.locator('tr', { has: page.locator(`text=${establishmentName}`) });
await expect(row).toBeVisible({ timeout: 10000 });
// Status badge should be visible (provisioning status already verified via API response in creation test)
const badge = row.locator('.badge');
await expect(badge).toBeVisible();
await expect(badge).not.toHaveText('');
});
});
test.describe('Form Validation', () => {
test('[P2] submit button disabled with empty fields', async ({ page }, testInfo) => {
const email = getSuperAdminEmail(testInfo.project.name);
await loginAsSuperAdmin(page, email);
await navigateToEstablishments(page);
await navigateToCreateForm(page);
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
await expect(submitButton).toBeDisabled();
});
test('[P2] submit button enabled when all fields filled', async ({ page }, testInfo) => {
const email = getSuperAdminEmail(testInfo.project.name);
await loginAsSuperAdmin(page, email);
await navigateToEstablishments(page);
await navigateToCreateForm(page);
await page.locator('#name').fill('Test School');
await page.locator('#adminEmail').fill('admin@test.com');
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
await expect(submitButton).toBeEnabled();
});
test('[P2] cancel button returns to establishments list', async ({ page }, testInfo) => {
const email = getSuperAdminEmail(testInfo.project.name);
await loginAsSuperAdmin(page, email);
await navigateToEstablishments(page);
await navigateToCreateForm(page);
const cancelLink = page.getByRole('link', { name: /annuler/i });
await Promise.all([
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
cancelLink.click()
]);
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible();
});
});
});

View File

@@ -59,10 +59,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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function waitForPageReady(page: import('@playwright/test').Page) {

View File

@@ -45,10 +45,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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
function createCsvFixture(filename: string, content: string): string {

View File

@@ -53,12 +53,11 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string }
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.waitForLoadState('networkidle');
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function waitForPageReady(page: import('@playwright/test').Page) {

View File

@@ -0,0 +1,333 @@
import { test, expect } from '@playwright/test';
import { execWithRetry, runSql, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
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 TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
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 page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
function querySql(sql: string): string {
return execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`
);
}
// =========================================================================
// Smoke tests — navigation only, no data dependency
// =========================================================================
test.describe('Teacher Statistics — Navigation (Story 6.8)', () => {
const TEACHER_EMAIL = 'e2e-stats-teacher@example.com';
const TEACHER_PASSWORD = 'StatsTest123';
test.beforeAll(async () => {
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
});
test('should display statistics page with navigation link', async ({ page }) => {
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
const statsLink = page.getByRole('link', { name: /statistiques/i });
await expect(statsLink).toBeVisible({ timeout: 10000 });
await statsLink.click();
await expect(page).toHaveURL(/\/dashboard\/teacher\/statistics/);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
});
test('should show overview or empty state', async ({ page }) => {
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
// Page must show either class cards or the "Aucune donnée" empty state
const hasCards = await page.getByRole('button', { name: /moyenne/i }).count();
const hasEmptyState = await page.getByText('Aucune donnée statistique').count();
expect(hasCards + hasEmptyState).toBeGreaterThan(0);
});
});
// =========================================================================
// Data-driven tests with seeded evaluations and grades
// =========================================================================
test.describe('Teacher Statistics — Data-Driven (Story 6.8)', () => {
const DATA_TEACHER_EMAIL = 'e2e-stats-data-teacher@example.com';
const DATA_TEACHER_PASSWORD = 'StatsData123';
let classId: string;
let subjectId: string;
let teacherId: string;
test.beforeAll(async () => {
createTestUser('ecole-alpha', DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD, 'ROLE_PROF');
const { academicYearId } = resolveDeterministicIds(TENANT_ID);
// Resolve teacher ID
const teacherOutput = querySql(
`SELECT id FROM users WHERE email = '${DATA_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'`
);
teacherId = teacherOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
// Find existing class
classId = querySql(
`SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' LIMIT 1`
).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
// Find existing subject
subjectId = querySql(
`SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' LIMIT 1`
).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
if (!teacherId || !classId || !subjectId) return;
// Ensure at least 3 students in the class for grade diversity
const testStudents = [
{ email: 'e2e-stats-student-a@example.com', firstName: 'Alice', lastName: 'Stats' },
{ email: 'e2e-stats-student-b@example.com', firstName: 'Bob', lastName: 'Stats' },
{ email: 'e2e-stats-student-c@example.com', firstName: 'Charlie', lastName: 'Stats' },
];
for (const { email, firstName, lastName } of testStudents) {
createTestUser('ecole-alpha', email, 'StatsStudent123', 'ROLE_ELEVE');
try {
runSql(
`UPDATE users SET first_name = '${firstName}', last_name = '${lastName}' ` +
`WHERE email = '${email}' AND tenant_id = '${TENANT_ID}' AND first_name = ''`
);
} catch { /* best effort */ }
const sidOutput = querySql(
`SELECT id FROM users WHERE email = '${email}' AND tenant_id = '${TENANT_ID}'`
);
const sid = sidOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0];
if (sid) {
try {
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${sid}', '${classId}', '${academicYearId}', NOW(), NOW()) ` +
`ON CONFLICT DO NOTHING`
);
} catch { /* may exist */ }
}
}
// Create teacher assignment
try {
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${teacherId}', '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW()) ` +
`ON CONFLICT DO NOTHING`
);
} catch { /* may exist */ }
// Create a published evaluation with grades
try {
const evalIdOutput = querySql(`SELECT gen_random_uuid()::text`);
const evalId = evalIdOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
runSql(
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
`VALUES ('${evalId}', '${TENANT_ID}', '${classId}', '${subjectId}', '${teacherId}', 'E2E Stats Eval', CURRENT_DATE - INTERVAL '7 days', 20, 1.0, 'published', NOW(), NOW(), NOW()) ` +
`ON CONFLICT DO NOTHING`
);
// Get students in the class (class_assignments stores student-class links)
const studentOutput = querySql(
`SELECT ca.user_id FROM class_assignments ca WHERE ca.school_class_id = '${classId}' AND ca.tenant_id = '${TENANT_ID}' LIMIT 3`
);
const studentIds = [...studentOutput.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g)].map(m => m[0]);
// Find current academic period for student_averages
const periodOutput = querySql(
`SELECT id FROM academic_periods WHERE tenant_id = '${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1`
);
const periodId = periodOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
const grades = [15.0, 7.0, 12.0];
studentIds.forEach((sid, i) => {
const grade = grades[i] ?? 10.0;
try {
runSql(
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${evalId}', '${sid}', ${grade}, 'graded', '${teacherId}', NOW(), NOW()) ` +
`ON CONFLICT DO NOTHING`
);
// Populate student_averages so difficulty badges work
if (periodId) {
runSql(
`INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${sid}', '${subjectId}', '${periodId}', ${grade}, 1, NOW()) ` +
`ON CONFLICT DO NOTHING`
);
}
} catch { /* may exist */ }
});
} catch { /* may already exist */ }
});
test.afterAll(() => {
// Cleanup seeded data
if (teacherId) {
try {
runSql(`DELETE FROM student_averages WHERE tenant_id = '${TENANT_ID}' AND subject_id = '${subjectId}'`);
runSql(`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}' AND created_by = '${teacherId}'`);
runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id = '${teacherId}'`);
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}' AND teacher_id = '${teacherId}'`);
} catch { /* best effort cleanup */ }
}
});
test('should display class cards with evaluation and student counts', async ({ page }) => {
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
// Class card is a button containing "Moyenne" stat label
const classCard = page.locator('button.class-card').first();
await expect(classCard).toBeVisible({ timeout: 10000 });
// Card should contain key stat labels
await expect(classCard.getByText('Moyenne')).toBeVisible();
await expect(classCard.getByText('Évaluations')).toBeVisible();
await expect(classCard.getByText('Élèves')).toBeVisible();
});
test('should show export and print buttons in class detail', async ({ page }) => {
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
// Click first class card — data is seeded so it must exist
const classCard = page.locator('button.class-card').first();
await expect(classCard).toBeVisible({ timeout: 10000 });
await classCard.click();
// Detail view buttons
await expect(page.getByRole('button', { name: /exporter csv/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: /imprimer/i })).toBeVisible();
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible();
});
test('should navigate back from detail to overview', async ({ page }) => {
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
const classCard = page.locator('button.class-card').first();
await expect(classCard).toBeVisible({ timeout: 10000 });
await classCard.click();
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /retour/i }).click();
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible();
});
test('should show class detail with student table and histogram', async ({ page }) => {
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
const classCard = page.locator('button.class-card').first();
await expect(classCard).toBeVisible({ timeout: 10000 });
await classCard.click();
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
// Histogram section
await expect(page.locator('.histogram')).toBeVisible({ timeout: 10000 });
// Student table with headers
await expect(page.getByRole('columnheader', { name: 'Élève' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Moyenne' })).toBeVisible();
await expect(page.getByRole('columnheader', { name: 'Statut' })).toBeVisible();
// At least one student row
const studentRows = page.locator('table.student-table tbody tr');
const count = await studentRows.count();
expect(count).toBeGreaterThan(0);
});
test('should show difficulty indicators for struggling students', async ({ page }) => {
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
const classCard = page.locator('button.class-card').first();
await expect(classCard).toBeVisible({ timeout: 10000 });
await classCard.click();
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
// Student with grade 7.0 (below 8.0 threshold) should have "En difficulté" badge
await expect(page.getByText('En difficulté')).toBeVisible({ timeout: 10000 });
});
test('should trigger CSV export with correct response headers', async ({ page }) => {
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
const classCard = page.locator('button.class-card').first();
await expect(classCard).toBeVisible({ timeout: 10000 });
await classCard.click();
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
const exportButton = page.getByRole('button', { name: /exporter csv/i });
await expect(exportButton).toBeVisible({ timeout: 10000 });
const downloadPromise = page.waitForEvent('download', { timeout: 15000 });
await exportButton.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.csv');
});
test('should navigate to student progression view', async ({ page }) => {
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
const classCard = page.locator('button.class-card').first();
await expect(classCard).toBeVisible({ timeout: 10000 });
await classCard.click();
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
// Click a student who has grades (test student "Alice Stats" has grade data)
const studentLink = page.getByRole('button', { name: /Alice Stats/i });
await expect(studentLink).toBeVisible({ timeout: 20000 });
await studentLink.click();
// Wait for loading to finish
await expect(page.getByText('Chargement de la progression...')).not.toBeVisible({ timeout: 15000 });
// Progression view should show chart or empty state
const chart = page.getByRole('img', { name: /progression/i });
const emptyState = page.getByText(/aucune note publiée/i);
await expect(chart.or(emptyState)).toBeVisible({ timeout: 10000 });
});
});

View File

@@ -95,7 +95,7 @@ test.describe('Expired/Invalid Token Scenarios [P0]', () => {
await page.goto(`/activate/${token}`);
const form = page.locator('form');
await expect(form).toBeVisible({ timeout: 5000 });
await expect(form).toBeVisible({ timeout: 15000 });
// Fill valid password (must include special char for 5/5 requirements)
await page.locator('#password').fill('SecurePass123!');

View File

@@ -70,20 +70,16 @@ test.describe('User Blocking Mid-Session [P1]', () => {
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
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: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function blockUserViaAdmin(page: import('@playwright/test').Page) {

View File

@@ -76,10 +76,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test('admin can block a user and sees blocked status', async ({ page }) => {

View File

@@ -33,10 +33,8 @@ 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test('admin can invite a user with roles array', async ({ page }) => {