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.
369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
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 TEACHER_EMAIL = 'e2e-comp-teacher@example.com';
|
|
const TEACHER_PASSWORD = 'CompTest123';
|
|
const STUDENT1_EMAIL = 'e2e-comp-student1@example.com';
|
|
const STUDENT2_EMAIL = 'e2e-comp-student2@example.com';
|
|
const STUDENT_PASSWORD = 'Student123';
|
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
|
|
const UUID_NS = '6ba7b814-9dad-11d1-80b4-00c04fd430c8';
|
|
|
|
function uuid5(name: string): string {
|
|
return execWithRetry(
|
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
|
`require "/app/vendor/autoload.php"; ` +
|
|
`echo Ramsey\\Uuid\\Uuid::uuid5("${UUID_NS}","${name}")->toString();` +
|
|
`' 2>&1`
|
|
).trim();
|
|
}
|
|
|
|
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 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 page.getByRole('button', { name: /se connecter/i }).click();
|
|
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
|
|
}
|
|
|
|
// Deterministic IDs for test data
|
|
let classId: string;
|
|
let subjectId: string;
|
|
let evaluationId: string;
|
|
let frameworkId: string;
|
|
let competency1Id: string;
|
|
let competency2Id: string;
|
|
let ce1Id: string;
|
|
let ce2Id: string;
|
|
let student1Id: string;
|
|
let student2Id: string;
|
|
|
|
test.describe('Competencies Mode (Story 6.5)', () => {
|
|
test.beforeAll(async () => {
|
|
// Create test users
|
|
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
|
createTestUser('ecole-alpha', STUDENT1_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Clara --lastName=Dupont');
|
|
createTestUser('ecole-alpha', STUDENT2_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Hugo --lastName=Leroy');
|
|
|
|
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
|
|
|
// Create deterministic IDs
|
|
classId = uuid5(`comp-class-${TENANT_ID}`);
|
|
subjectId = uuid5(`comp-subject-${TENANT_ID}`);
|
|
evaluationId = uuid5(`comp-eval-${TENANT_ID}`);
|
|
frameworkId = uuid5(`comp-framework-${TENANT_ID}`);
|
|
competency1Id = uuid5(`comp-c1-${TENANT_ID}`);
|
|
competency2Id = uuid5(`comp-c2-${TENANT_ID}`);
|
|
ce1Id = uuid5(`comp-ce1-${TENANT_ID}`);
|
|
ce2Id = uuid5(`comp-ce2-${TENANT_ID}`);
|
|
|
|
// Create test class
|
|
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-COMP-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
// Create test subject
|
|
runSql(
|
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
|
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-COMP-Maths', 'E2CMPMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
// Create teacher assignment
|
|
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 = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
|
`ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
// Resolve student IDs
|
|
const studentIds = execWithRetry(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('${STUDENT1_EMAIL}','${STUDENT2_EMAIL}') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`
|
|
);
|
|
const idMatches = studentIds.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g);
|
|
if (idMatches && idMatches.length >= 2) {
|
|
student1Id = idMatches[0]!;
|
|
student2Id = idMatches[1]!;
|
|
}
|
|
|
|
// Assign students to class
|
|
runSql(
|
|
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student1Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
|
|
);
|
|
runSql(
|
|
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student2Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
|
|
);
|
|
|
|
// Create competency framework
|
|
runSql(
|
|
`INSERT INTO competency_frameworks (id, tenant_id, name, is_default, created_at) ` +
|
|
`VALUES ('${frameworkId}', '${TENANT_ID}', 'Socle commun E2E', true, NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
// Create 2 competencies
|
|
runSql(
|
|
`INSERT INTO competencies (id, framework_id, code, name, description, sort_order) ` +
|
|
`VALUES ('${competency1Id}', '${frameworkId}', 'D1.1', 'Comprendre et s''exprimer', 'Langue française', 0) ON CONFLICT DO NOTHING`
|
|
);
|
|
runSql(
|
|
`INSERT INTO competencies (id, framework_id, code, name, description, sort_order) ` +
|
|
`VALUES ('${competency2Id}', '${frameworkId}', 'D2.1', 'Méthodes et outils', 'Organisation du travail', 1) ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
clearCache();
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
// Clean competency results and competency evaluations, then recreate evaluation + links
|
|
runSql(`DELETE FROM student_competency_results WHERE competency_evaluation_id IN ('${ce1Id}', '${ce2Id}')`);
|
|
runSql(`DELETE FROM competency_evaluations WHERE evaluation_id = '${evaluationId}'`);
|
|
runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`);
|
|
|
|
// Create evaluation
|
|
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 Contrôle Compétences', '2026-04-15', 20, 1.0, 'published', NOW(), NOW() ` +
|
|
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
|
`ON CONFLICT (id) DO UPDATE SET updated_at = NOW()`
|
|
);
|
|
|
|
// Link competencies to evaluation
|
|
runSql(
|
|
`INSERT INTO competency_evaluations (id, evaluation_id, competency_id) ` +
|
|
`VALUES ('${ce1Id}', '${evaluationId}', '${competency1Id}') ON CONFLICT DO NOTHING`
|
|
);
|
|
runSql(
|
|
`INSERT INTO competency_evaluations (id, evaluation_id, competency_id) ` +
|
|
`VALUES ('${ce2Id}', '${evaluationId}', '${competency2Id}') ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
clearCache();
|
|
});
|
|
|
|
test.describe('Competency Grid Display', () => {
|
|
test('shows competency grid with students and competencies', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
|
|
|
// Should display evaluation title
|
|
await expect(page.getByRole('heading', { name: /E2E Contrôle Compétences/i })).toBeVisible({ timeout: 15000 });
|
|
|
|
// Should show competency column headers
|
|
await expect(page.getByText('D1.1')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText('D2.1')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Should show student names
|
|
await expect(page.getByText('Dupont Clara')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText('Leroy Hugo')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Should show level-cell elements
|
|
const levelCells = page.locator('.level-cell');
|
|
await expect(levelCells.first()).toBeVisible({ timeout: 10000 });
|
|
// 2 students x 2 competencies = 4 cells
|
|
await expect(levelCells).toHaveCount(4);
|
|
});
|
|
|
|
test('shows level legend with standard levels', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
|
|
|
// Should show level legend
|
|
await expect(page.getByText('Niveaux :')).toBeVisible({ timeout: 15000 });
|
|
const legendItems = page.locator('.legend-item');
|
|
await expect(legendItems).toHaveCount(4, { timeout: 10000 });
|
|
await expect(legendItems.nth(0)).toContainText('Non acquis');
|
|
await expect(legendItems.nth(1)).toContainText("En cours d'acquisition");
|
|
await expect(legendItems.nth(2)).toContainText('Acquis');
|
|
await expect(legendItems.nth(3)).toContainText('Dépassé');
|
|
});
|
|
|
|
test('"Compétences" link navigates from evaluations page', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`);
|
|
await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 });
|
|
|
|
// Wait for evaluation cards to load
|
|
await expect(page.getByText('E2E Contrôle Compétences')).toBeVisible({ timeout: 10000 });
|
|
await page.getByRole('link', { name: /compétences/i }).first().click();
|
|
|
|
await expect(page.getByRole('heading', { name: /E2E Contrôle Compétences/i })).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Competency Level Input', () => {
|
|
test('can set a competency level by clicking button', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
|
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
// Click the first level button (1 = "Non acquis") in the first cell
|
|
const firstCell = page.locator('.level-cell').first();
|
|
const firstLevelBtn = firstCell.locator('.level-btn').first();
|
|
await firstLevelBtn.click();
|
|
|
|
// The button should become active
|
|
await expect(firstLevelBtn).toHaveClass(/active/, { timeout: 5000 });
|
|
});
|
|
|
|
test('toggle same level clears selection', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
|
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstCell = page.locator('.level-cell').first();
|
|
|
|
// 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 });
|
|
|
|
// Use keyboard to toggle off by pressing same key
|
|
await firstCell.press('2');
|
|
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 15000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Keyboard Navigation', () => {
|
|
test('keyboard shortcut 1-4 sets level', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
|
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
// Click the first cell to ensure focus (more reliable than focus() across browsers)
|
|
const firstCell = page.locator('.level-cell').first();
|
|
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: 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: 10000 });
|
|
await expect(firstCell.locator('.level-btn').nth(2)).not.toHaveClass(/active/, { timeout: 5000 });
|
|
});
|
|
|
|
test('Tab moves to next cell', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
|
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstCell = page.locator('.level-cell').first();
|
|
await firstCell.focus();
|
|
await firstCell.press('Tab');
|
|
|
|
// Second cell should be focused (next competency for the same student)
|
|
const secondCell = page.locator('.level-cell').nth(1);
|
|
await expect(secondCell).toBeFocused({ timeout: 3000 });
|
|
});
|
|
|
|
test('Arrow keys navigate the grid', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
|
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstCell = page.locator('.level-cell').first();
|
|
await firstCell.focus();
|
|
|
|
// ArrowRight moves to the next competency (same row)
|
|
await firstCell.press('ArrowRight');
|
|
const secondCell = page.locator('.level-cell').nth(1);
|
|
await expect(secondCell).toBeFocused({ timeout: 3000 });
|
|
|
|
// ArrowDown moves to the same competency for the next student
|
|
// Cell index 1 => student 0, comp 1. ArrowDown => student 1, comp 1 => cell index 3
|
|
await secondCell.press('ArrowDown');
|
|
const cellBelow = page.locator('.level-cell').nth(3);
|
|
await expect(cellBelow).toBeFocused({ timeout: 3000 });
|
|
|
|
// ArrowLeft moves back
|
|
await cellBelow.press('ArrowLeft');
|
|
const cellLeft = page.locator('.level-cell').nth(2);
|
|
await expect(cellLeft).toBeFocused({ timeout: 3000 });
|
|
|
|
// ArrowUp moves back up
|
|
await cellLeft.press('ArrowUp');
|
|
await expect(firstCell).toBeFocused({ timeout: 3000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Student Competency View', () => {
|
|
test.beforeEach(async () => {
|
|
// Seed competency results so the student has data to view
|
|
runSql(
|
|
`INSERT INTO student_competency_results (id, tenant_id, competency_evaluation_id, student_id, level_code, created_at, updated_at) ` +
|
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${ce1Id}', '${student1Id}', 'acquired', NOW(), NOW()) ` +
|
|
`ON CONFLICT (competency_evaluation_id, student_id) DO UPDATE SET level_code = 'acquired', updated_at = NOW()`
|
|
);
|
|
runSql(
|
|
`INSERT INTO student_competency_results (id, tenant_id, competency_evaluation_id, student_id, level_code, created_at, updated_at) ` +
|
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${ce2Id}', '${student1Id}', 'in_progress', NOW(), NOW()) ` +
|
|
`ON CONFLICT (competency_evaluation_id, student_id) DO UPDATE SET level_code = 'in_progress', updated_at = NOW()`
|
|
);
|
|
clearCache();
|
|
});
|
|
|
|
test('student sees competency progress page', async ({ page }) => {
|
|
await loginAsStudent(page, STUDENT1_EMAIL);
|
|
await page.goto(`${ALPHA_URL}/dashboard/student-competencies`);
|
|
|
|
// Should display heading
|
|
await expect(page.getByRole('heading', { name: /mes compétences/i })).toBeVisible({ timeout: 15000 });
|
|
|
|
// Should show competency cards
|
|
const cards = page.locator('.competency-cards');
|
|
await expect(page.locator('.competency-card').first()).toBeVisible({ timeout: 10000 });
|
|
await expect(cards.getByText('D1.1')).toBeVisible({ timeout: 10000 });
|
|
await expect(cards.getByText('D2.1')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Should show current level badges
|
|
await expect(page.locator('.card-badge').first()).toBeVisible({ timeout: 10000 });
|
|
|
|
// Should show the hint text
|
|
await expect(page.getByText(/cliquez sur une compétence pour voir l'historique/i)).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('student can view history by clicking a competency card', async ({ page }) => {
|
|
await loginAsStudent(page, STUDENT1_EMAIL);
|
|
await page.goto(`${ALPHA_URL}/dashboard/student-competencies`);
|
|
|
|
await expect(page.locator('.competency-card').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
// Click the first competency card
|
|
await page.locator('.competency-card').first().click();
|
|
|
|
// The card should be selected
|
|
await expect(page.locator('.competency-card.selected')).toBeVisible({ timeout: 5000 });
|
|
|
|
// The history panel should appear
|
|
await expect(page.locator('.history-panel')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Should show evaluation title in history
|
|
await expect(page.locator('.history-panel').getByText('E2E Contrôle Compétences')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
});
|