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.
331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
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 });
|
|
});
|
|
});
|