feat: Provisionner automatiquement un nouvel établissement
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:
333
frontend/e2e/teacher-statistics.spec.ts
Normal file
333
frontend/e2e/teacher-statistics.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user