Files
Classeo/frontend/e2e/parent-grades.spec.ts
Mathias STRASSER dc2be898d5
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
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.
2026-04-16 09:27:25 +02:00

240 lines
9.6 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 PARENT_EMAIL = 'e2e-parent-grades@example.com';
const PARENT_PASSWORD = 'ParentGrades123';
const TEACHER_EMAIL = 'e2e-pg-teacher@example.com';
const TEACHER_PASSWORD = 'TeacherPG123';
const STUDENT_EMAIL = 'e2e-pg-student@example.com';
const STUDENT_PASSWORD = 'StudentPG123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
let parentId: string;
let studentId: string;
let classId: string;
let subjectId: string;
let evalId: string;
let periodId: string;
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("6ba7b814-9dad-11d1-80b4-00c04fd430c8","${name}")->toString();` +
`' 2>&1`
).trim();
}
async function loginAsParent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
test.describe('Parent Grade Consultation (Story 6.7)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Create users
createTestUser(
'ecole-alpha',
PARENT_EMAIL,
PARENT_PASSWORD,
'ROLE_PARENT --firstName=Marie --lastName=Dupont'
);
createTestUser(
'ecole-alpha',
TEACHER_EMAIL,
TEACHER_PASSWORD,
'ROLE_PROF --firstName=Jean --lastName=Martin'
);
createTestUser(
'ecole-alpha',
STUDENT_EMAIL,
STUDENT_PASSWORD,
'ROLE_ELEVE --firstName=Emma --lastName=Dupont'
);
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
// Resolve user IDs
const parentOutput = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${PARENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1`
);
parentId = parentOutput.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
)![0]!;
const studentOutput = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${STUDENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1`
);
studentId = studentOutput.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
)![0]!;
// Create deterministic IDs
classId = uuid5(`pg-class-${TENANT_ID}`);
subjectId = uuid5(`pg-subject-${TENANT_ID}`);
evalId = uuid5(`pg-eval-${TENANT_ID}`);
// Create 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-PG-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Create subject
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-PG-Mathématiques', 'E2EPGMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Assign student 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}', '${studentId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
);
// Link parent to student
runSql(
`INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${parentId}', 'mère', 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 published evaluation (published 48h ago so delay is passed)
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 '${evalId}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'DS Maths Parent', '2026-03-01', 20, 2.0, 'published', NOW() - INTERVAL '48 hours', NOW() - INTERVAL '48 hours', NOW() ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (id) DO NOTHING`
);
// Insert grade for student
runSql(
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId}', '${studentId}', 15.5, 'graded', u.id, NOW(), NOW(), 'Bon travail' ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (evaluation_id, student_id) DO NOTHING`
);
// Insert class statistics
runSql(
`INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` +
`VALUES ('${evalId}', 13.5, 7.0, 18.0, 13.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING`
);
// Find academic period
const periodOutput = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM academic_periods WHERE tenant_id='${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1" 2>&1`
);
const periodMatch = periodOutput.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
);
periodId = periodMatch ? periodMatch[0]! : uuid5(`pg-period-${TENANT_ID}`);
// Insert student averages
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}', '${studentId}', '${subjectId}', '${periodId}', 15.5, 1, NOW()) ` +
`ON CONFLICT (student_id, subject_id, period_id) DO NOTHING`
);
runSql(
`INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${periodId}', 15.5, NOW()) ` +
`ON CONFLICT (student_id, period_id) DO NOTHING`
);
clearCache();
});
// =========================================================================
// AC2: Parent can see child's grades and averages
// =========================================================================
test('AC2: parent navigates to grades page', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
await expect(page.getByRole('heading', { name: 'Notes des enfants' })).toBeVisible({
timeout: 15000
});
});
test('AC2: parent sees child selector', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
// Child selector should be visible with the child's name
await expect(page.getByText('Emma Dupont')).toBeVisible({ timeout: 15000 });
});
test("AC2: parent sees child's grade card", async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
// Wait for grades to load (single child auto-selected)
await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.grade-value', { hasText: '15.5/20' }).first()).toBeVisible();
});
// =========================================================================
// AC4: Subject detail with class statistics
// =========================================================================
test('AC4: parent sees class statistics on grade card', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 });
await expect(page.getByText(/Moy\. classe/)).toBeVisible();
});
test('AC4: parent opens subject detail modal', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
// Wait for grade cards to appear
await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 });
// Click on a grade card to open subject detail
await page.getByRole('button', { name: /DS Maths Parent/ }).click();
// Modal should appear with subject name and grade details
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal.locator('.grade-value', { hasText: '15.5/20' }).first()).toBeVisible();
});
// =========================================================================
// Navigation
// =========================================================================
test('navigation: parent sees Notes link in nav', async ({ page }) => {
await loginAsParent(page);
await expect(page.getByRole('link', { name: 'Notes' })).toBeVisible({ timeout: 15000 });
});
});