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.
330 lines
14 KiB
TypeScript
330 lines
14 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-grade-teacher@example.com';
|
|
const TEACHER_PASSWORD = 'GradeTest123';
|
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
|
|
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 });
|
|
}
|
|
|
|
// Deterministic IDs for test data
|
|
let evaluationId: string;
|
|
let classId: string;
|
|
let student1Id: string;
|
|
let student2Id: string;
|
|
|
|
test.describe('Grade Input Grid (Story 6.2)', () => {
|
|
test.beforeAll(async () => {
|
|
// Create teacher user
|
|
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
|
|
|
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
|
|
|
// Create test class
|
|
const classOutput = 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","grade-class-${TENANT_ID}")->toString();` +
|
|
`' 2>&1`
|
|
).trim();
|
|
classId = classOutput;
|
|
|
|
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-GRADE-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
|
);
|
|
|
|
// Create test subject
|
|
const subjectOutput = 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","grade-subject-${TENANT_ID}")->toString();` +
|
|
`' 2>&1`
|
|
).trim();
|
|
const subjectId = subjectOutput;
|
|
|
|
runSql(
|
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
|
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-GRADE-Sciences', 'E2GRDSCI', '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`
|
|
);
|
|
|
|
// Create 2 test students
|
|
createTestUser('ecole-alpha', 'e2e-grade-student1@example.com', 'Student123', 'ROLE_ELEVE --firstName=Alice --lastName=Durand');
|
|
createTestUser('ecole-alpha', 'e2e-grade-student2@example.com', 'Student123', 'ROLE_ELEVE --firstName=Bob --lastName=Martin');
|
|
|
|
// Assign students to class
|
|
const studentIds = execWithRetry(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') 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 test evaluation
|
|
const evalOutput = 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","grade-eval-${TENANT_ID}")->toString();` +
|
|
`' 2>&1`
|
|
).trim();
|
|
evaluationId = evalOutput;
|
|
|
|
clearCache();
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
// Clean grades and recreate evaluation
|
|
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE evaluation_id = '${evaluationId}')`);
|
|
runSql(`DELETE FROM grades WHERE evaluation_id = '${evaluationId}'`);
|
|
runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`);
|
|
|
|
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) ` +
|
|
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', ` +
|
|
`(SELECT id FROM subjects WHERE code='E2GRDSCI' AND tenant_id='${TENANT_ID}' LIMIT 1), ` +
|
|
`u.id, 'E2E Contrôle Sciences', '2026-04-15', 20, 1.0, 'published', NULL, NOW(), NOW() ` +
|
|
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
|
`ON CONFLICT (id) DO UPDATE SET grades_published_at = NULL, updated_at = NOW()`
|
|
);
|
|
|
|
clearCache();
|
|
});
|
|
|
|
test.describe('Grade Grid Display', () => {
|
|
test('shows grade input grid with students', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
|
|
// Should display evaluation title
|
|
await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 });
|
|
|
|
// Should show grade inputs
|
|
const gradeInputs = page.locator('.grade-input');
|
|
await expect(gradeInputs.first()).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('"Saisir les notes" button navigates to grades 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 Sciences')).toBeVisible({ timeout: 10000 });
|
|
await page.getByRole('link', { name: /saisir les notes/i }).first().click();
|
|
|
|
await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Grade Input', () => {
|
|
test('can enter a numeric grade', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstInput = page.locator('.grade-input').first();
|
|
await firstInput.fill('15.5');
|
|
|
|
// Should show the grade in status column
|
|
await expect(page.locator('.status-graded').first()).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('validates grade against scale maximum', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstInput = page.locator('.grade-input').first();
|
|
await firstInput.fill('25');
|
|
|
|
// Should show error
|
|
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Slash Commands', () => {
|
|
test('/abs marks student as absent', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstInput = page.locator('.grade-input').first();
|
|
await firstInput.clear();
|
|
await firstInput.fill('/abs');
|
|
|
|
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 15000 });
|
|
});
|
|
|
|
test('/disp marks student as dispensed', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstInput = page.locator('.grade-input').first();
|
|
await firstInput.clear();
|
|
await firstInput.fill('/disp');
|
|
|
|
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 15000 });
|
|
});
|
|
|
|
});
|
|
|
|
test.describe('Keyboard Navigation', () => {
|
|
test('Tab moves to next student', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstInput = page.locator('.grade-input').first();
|
|
await firstInput.focus();
|
|
await firstInput.press('Tab');
|
|
|
|
// Second input should be focused
|
|
const secondInput = page.locator('.grade-input').nth(1);
|
|
await expect(secondInput).toBeFocused({ timeout: 3000 });
|
|
});
|
|
|
|
test('Enter moves to next student', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const firstInput = page.locator('.grade-input').first();
|
|
await firstInput.fill('15');
|
|
await firstInput.press('Enter');
|
|
|
|
// Second input should be focused
|
|
const secondInput = page.locator('.grade-input').nth(1);
|
|
await expect(secondInput).toBeFocused({ timeout: 3000 });
|
|
});
|
|
|
|
test('Shift+Tab moves to previous student', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
const secondInput = page.locator('.grade-input').nth(1);
|
|
await secondInput.focus();
|
|
await secondInput.press('Shift+Tab');
|
|
|
|
const firstInput = page.locator('.grade-input').first();
|
|
await expect(firstInput).toBeFocused({ timeout: 3000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Publication', () => {
|
|
test('publish requires confirmation via modal', async ({ page }) => {
|
|
clearCache();
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await page.waitForLoadState('networkidle');
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
// 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 }
|
|
);
|
|
await firstInput.fill('18');
|
|
await expect(page.locator('.status-graded').first()).toContainText('18/20', { timeout: 10000 });
|
|
|
|
// Wait for the PUT to complete
|
|
await savePromise;
|
|
|
|
// Click publish button — opens confirmation modal
|
|
await page.getByRole('button', { name: /publier les notes/i }).click();
|
|
|
|
// Confirmation dialog should appear
|
|
const dialog = page.getByRole('alertdialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible();
|
|
|
|
// Start listening for POST before clicking confirm
|
|
const publishPromise = page.waitForResponse(
|
|
(resp) => resp.url().includes('/publish') && resp.request().method() === 'POST',
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
// Confirm publication
|
|
await dialog.getByRole('button', { name: /confirmer la publication/i }).click();
|
|
|
|
// Wait for the POST to complete
|
|
await publishPromise;
|
|
|
|
// Should show published badge
|
|
await expect(page.getByText(/notes publiées/i)).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test('publish modal can be cancelled', async ({ page }) => {
|
|
clearCache();
|
|
await loginAsTeacher(page);
|
|
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
|
await page.waitForLoadState('networkidle');
|
|
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
|
|
|
// 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 }
|
|
);
|
|
await firstInput.fill('14');
|
|
await expect(page.locator('.status-graded').first()).toContainText('14/20', { timeout: 10000 });
|
|
|
|
// Wait for save to complete
|
|
await savePromise;
|
|
|
|
const publishBtn = page.getByRole('button', { name: /publier les notes/i });
|
|
|
|
// Open modal then cancel
|
|
await publishBtn.click();
|
|
const dialog = page.getByRole('alertdialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible();
|
|
await dialog.getByRole('button', { name: /annuler/i }).click();
|
|
|
|
// Modal should close, publish button still visible
|
|
await expect(dialog).not.toBeVisible({ timeout: 3000 });
|
|
await expect(publishBtn).toBeVisible();
|
|
});
|
|
});
|
|
});
|