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 3575d095a1
106 changed files with 9586 additions and 380 deletions

View File

@@ -0,0 +1,330 @@
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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
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: 10000 });
await studentLink.click();
// Progression view should show chart with proper ARIA label
await expect(page.getByRole('img', { name: /progression/i })).toBeVisible({ timeout: 10000 });
});
});