Files
Classeo/frontend/e2e/appreciations.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

364 lines
15 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-appr-teacher@example.com';
const TEACHER_PASSWORD = 'ApprTest123';
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 });
}
/** Navigate to grades page and verify grade is loaded (pre-seeded via SQL). */
async function waitForGradeLoaded(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Grade was pre-inserted in beforeEach, should show as 15/20
await expect(page.locator('.status-graded').first()).toContainText('15/20', { timeout: 10000 });
}
let evaluationId: string;
let classId: string;
let student1Id: string;
test.describe('Appreciations (Story 6.4)', () => {
test.beforeAll(async () => {
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
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","appr-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-APPR-4A', '4ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
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","appr-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-APPR-Français', 'E2APRFR', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
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`
);
createTestUser('ecole-alpha', 'e2e-appr-student1@example.com', 'Student123', 'ROLE_ELEVE --firstName=Claire --lastName=Petit');
const studentIds = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email = 'e2e-appr-student1@example.com' AND tenant_id='${TENANT_ID}'" 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 >= 1) {
student1Id = idMatches[0]!;
}
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`
);
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","appr-eval-${TENANT_ID}")->toString();` +
`' 2>&1`
).trim();
evaluationId = evalOutput;
clearCache();
});
test.beforeEach(async () => {
// Clean appreciation templates for the teacher
runSql(`DELETE FROM appreciation_templates WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
// 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='E2APRFR' AND tenant_id='${TENANT_ID}' LIMIT 1), ` +
`u.id, 'E2E Contrôle Français', '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()`
);
// Pre-insert a grade for the student so appreciation tests don't depend on auto-save
runSql(
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', '${evaluationId}', '${student1Id}', 15, 'graded', ` +
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), NOW(), NOW() ` +
`ON CONFLICT DO NOTHING`
);
clearCache();
});
test.describe('Appreciation Input', () => {
test('clicking appreciation icon opens text area', async ({ page }) => {
await loginAsTeacher(page);
await waitForGradeLoaded(page);
// Click appreciation button
const apprBtn = page.locator('.btn-appreciation').first();
await apprBtn.click();
// Appreciation panel should open with textarea
await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.appreciation-textarea')).toBeVisible();
});
test('typing appreciation shows character counter', async ({ page }) => {
await loginAsTeacher(page);
await waitForGradeLoaded(page);
// Open appreciation panel
await page.locator('.btn-appreciation').first().click();
await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 });
// Type appreciation
await page.locator('.appreciation-textarea').fill('Bon travail');
// Should show character count
await expect(page.locator('.char-counter')).toContainText('11/500');
});
// Firefox: auto-save debounce (setTimeout) doesn't trigger reliably with Playwright fill()
test('appreciation auto-saves after typing', async ({ page, browserName }) => {
test.skip(browserName === 'firefox', 'Firefox auto-save timing unreliable with Playwright');
await loginAsTeacher(page);
await waitForGradeLoaded(page);
// Open appreciation panel
await page.locator('.btn-appreciation').first().click();
await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 });
// Type appreciation text (pressSequentially to reliably trigger Svelte bind + oninput)
const textarea = page.locator('.appreciation-textarea');
await textarea.click();
await expect(textarea).toBeFocused();
await textarea.pressSequentially('Bon travail', { delay: 50 });
await expect(textarea).not.toHaveValue('');
// Wait for auto-save by checking the UI status indicator (1s debounce + network)
await expect(page.getByText('Sauvegardé')).toBeVisible({ timeout: 15000 });
});
test('appreciation icon changes when appreciation exists', async ({ page }) => {
// Pre-insert appreciation via SQL
runSql(
`UPDATE grades SET appreciation = 'Excellent' WHERE evaluation_id = '${evaluationId}' AND student_id = '${student1Id}' AND tenant_id = '${TENANT_ID}'`
);
clearCache();
await loginAsTeacher(page);
await waitForGradeLoaded(page);
// Button should have "has-appreciation" class since appreciation was pre-inserted
await expect(page.locator('.btn-appreciation.has-appreciation').first()).toBeVisible({ timeout: 5000 });
});
});
test.describe('Appreciation Templates', () => {
test('can open template manager and create a template', async ({ page }) => {
await loginAsTeacher(page);
await waitForGradeLoaded(page);
// Open appreciation panel
await page.locator('.btn-appreciation').first().click();
await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 });
// Click "Gérer" to open template manager
await page.locator('.btn-template-manage').click();
// Template manager modal should be visible
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal.getByText('Gérer les modèles')).toBeVisible();
// Fill the new template form
await modal.locator('.template-input').fill('Très bon travail');
await modal.locator('.template-textarea').fill('Très bon travail, continuez ainsi !');
await modal.getByLabel('Positive').check();
// Listen for POST
const createPromise = page.waitForResponse(
(resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST',
{ timeout: 30000 }
);
// Create template
await modal.getByRole('button', { name: 'Créer' }).click();
await createPromise;
// Template should appear in list
await expect(modal.getByText('Très bon travail, continuez')).toBeVisible({ timeout: 5000 });
});
test('can apply template to appreciation', async ({ page }) => {
await loginAsTeacher(page);
await waitForGradeLoaded(page);
// Open appreciation panel
await page.locator('.btn-appreciation').first().click();
await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 });
// Create a template first via the manager
await page.locator('.btn-template-manage').click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
await modal.locator('.template-input').fill('Progrès encourageants');
await modal.locator('.template-textarea').fill('Progrès encourageants ce trimestre, poursuivez vos efforts.');
const createPromise = page.waitForResponse(
(resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST',
{ timeout: 30000 }
);
await modal.getByRole('button', { name: 'Créer' }).click();
await createPromise;
// Close manager
await modal.getByRole('button', { name: 'Fermer' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// The appreciation panel may still be open from before the modal,
// or it may have closed. Toggle if needed.
const panel = page.locator('.appreciation-panel');
if (!(await panel.isVisible())) {
await page.locator('.btn-appreciation').first().click();
}
await expect(panel).toBeVisible({ timeout: 10000 });
// Click "Modèles" to show template dropdown
await page.locator('.btn-template-select').click();
await expect(page.locator('.template-dropdown')).toBeVisible({ timeout: 5000 });
// Listen for appreciation auto-save
const apprSavePromise = page.waitForResponse(
(resp) => resp.url().includes('/appreciation') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Click the template
await page.locator('.template-item').first().click();
// Textarea should contain the template content
await expect(page.locator('.appreciation-textarea')).toHaveValue('Progrès encourageants ce trimestre, poursuivez vos efforts.', { timeout: 5000 });
// Wait for auto-save
await apprSavePromise;
});
test('can edit an existing template', async ({ page }) => {
await loginAsTeacher(page);
await waitForGradeLoaded(page);
// Open appreciation panel then template manager
await page.locator('.btn-appreciation').first().click();
await page.locator('.btn-template-manage').click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
// Create a template to edit
await modal.locator('.template-input').fill('Avant modification');
await modal.locator('.template-textarea').fill('Contenu avant modification');
const createPromise = page.waitForResponse(
(resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST',
{ timeout: 30000 }
);
await modal.getByRole('button', { name: 'Créer' }).click();
await createPromise;
// Click "Modifier" on the template
await modal.getByRole('button', { name: 'Modifier' }).first().click();
// Form should show "Modifier le modèle" and be pre-filled
await expect(modal.getByText('Modifier le modèle')).toBeVisible({ timeout: 5000 });
// Clear and fill with new values
await modal.locator('.template-input').fill('Après modification');
await modal.locator('.template-textarea').fill('Contenu après modification');
// Submit the edit
const updatePromise = page.waitForResponse(
(resp) => resp.url().includes('/appreciation-templates/') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
await modal.getByRole('button', { name: 'Modifier' }).first().click();
await updatePromise;
// Verify updated template is displayed
await expect(modal.getByText('Après modification', { exact: true })).toBeVisible({ timeout: 5000 });
await expect(modal.getByText('Avant modification', { exact: true })).not.toBeVisible({ timeout: 5000 });
});
test('can delete a template', async ({ page }) => {
await loginAsTeacher(page);
await waitForGradeLoaded(page);
// Open appreciation panel then template manager
await page.locator('.btn-appreciation').first().click();
await page.locator('.btn-template-manage').click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
// Create a template
await modal.locator('.template-input').fill('À supprimer');
await modal.locator('.template-textarea').fill('Contenu test');
const createPromise = page.waitForResponse(
(resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST',
{ timeout: 30000 }
);
await modal.getByRole('button', { name: 'Créer' }).click();
await createPromise;
// Template should be visible
await expect(modal.getByText('À supprimer')).toBeVisible({ timeout: 5000 });
// Delete it
const deletePromise = page.waitForResponse(
(resp) => resp.url().includes('/appreciation-templates/') && resp.request().method() === 'DELETE',
{ timeout: 30000 }
);
await modal.getByRole('button', { name: 'Supprimer' }).click();
await deletePromise;
// Template should disappear
await expect(modal.getByText('À supprimer')).not.toBeVisible({ timeout: 5000 });
});
});
});