feat: Calculer automatiquement les moyennes après chaque saisie de notes
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

Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
This commit is contained in:
2026-03-30 06:22:03 +02:00
parent b70d5ec2ad
commit e745cf326a
733 changed files with 113156 additions and 286 deletions

View File

@@ -37,16 +37,26 @@ test.describe('Activation with Parent-Child Auto-Link', () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
const run = (cmd: string) => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return execSync(cmd, { encoding: 'utf-8' });
} catch (e) {
if (attempt === 2) throw e;
execSync('sleep 2');
}
}
return '';
};
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
run(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`
);
// Create student user and capture userId
const studentOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
const studentOutput = run(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`
);
studentUserId = extractUserId(studentOutput);
@@ -96,7 +106,7 @@ test.describe('Activation with Parent-Child Auto-Link', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);

View File

@@ -31,7 +31,7 @@ test.describe('Admin Responsive Navigation', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -30,7 +30,7 @@ test.describe('Admin Search & Pagination (Story 2.8b)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -0,0 +1,361 @@
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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
/** 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
await page.locator('.appreciation-textarea').fill('Très bon travail ce trimestre');
// Wait for auto-save by checking the UI status indicator
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 });
});
});
});

View File

@@ -87,7 +87,7 @@ test.describe('Branding Visual Customization', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -78,7 +78,7 @@ test.describe('Calendar Management (Story 2.11)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -88,7 +88,7 @@ test.describe('Calendar Management (Story 2.11)', () => {
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -38,7 +38,7 @@ async function loginAsAdmin(page: Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -145,7 +145,7 @@ test.describe('Child Selector', () => {
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -61,7 +61,7 @@ test.describe('Admin Class Detail Page [P1]', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -168,13 +168,13 @@ test.describe('Admin Class Detail Page [P1]', () => {
// Go back to list and verify the new name appears (use search for pagination)
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[type="search"]');
if (await searchInput.isVisible()) {
await searchInput.fill(newName);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
}
await expect(page.getByText(newName)).toBeVisible({ timeout: 10000 });
await expect(page.getByText(newName)).toBeVisible({ timeout: 15000 });
});
// ============================================================================

View File

@@ -89,7 +89,7 @@ test.describe('Classes Management (Story 2.1)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -22,7 +22,7 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -498,7 +498,7 @@ test.describe('Dashboard', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -1,10 +1,5 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { runSql, clearCache, resolveDeterministicIds, createTestUser } from './helpers';
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
@@ -15,48 +10,12 @@ const TEACHER_EMAIL = 'e2e-eval-teacher@example.com';
const TEACHER_PASSWORD = 'EvalTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -79,7 +38,7 @@ async function selectClassAndSubject(page: import('@playwright/test').Page) {
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
const { academicYearId } = resolveDeterministicIds(TENANT_ID);
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) ` +
@@ -98,13 +57,10 @@ function seedTeacherAssignments() {
test.describe('Evaluation Management (Story 6.1)', () => {
test.beforeAll(async () => {
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
// Ensure classes and subject exist
const { schoolId, academicYearId } = resolveDeterministicIds();
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
@@ -133,15 +89,15 @@ test.describe('Evaluation Management (Story 6.1)', () => {
// Table may not exist
}
const { schoolId, academicYearId } = resolveDeterministicIds();
const { schoolId: sId, academicYearId: ayId } = resolveDeterministicIds(TENANT_ID);
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${sId}', '${ayId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${sId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
@@ -361,7 +317,7 @@ test.describe('Evaluation Management (Story 6.1)', () => {
test.describe('Filter by class', () => {
test('class filter dropdown filters the evaluation list', async ({ page }) => {
// Seed a second class and assignment for this test
const { schoolId, academicYearId } = resolveDeterministicIds();
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +

View File

@@ -1,10 +1,5 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
@@ -15,48 +10,12 @@ const TEACHER_EMAIL = 'e2e-grade-teacher@example.com';
const TEACHER_PASSWORD = 'GradeTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -70,20 +29,16 @@ let student2Id: string;
test.describe('Grade Input Grid (Story 6.2)', () => {
test.beforeAll(async () => {
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
const { schoolId, academicYearId } = resolveDeterministicIds();
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
// Create test class
const classOutput = execSync(
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","grade-class-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
`' 2>&1`
).trim();
classId = classOutput;
@@ -93,12 +48,11 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
);
// Create test subject
const subjectOutput = execSync(
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","grade-subject-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
`' 2>&1`
).trim();
const subjectId = subjectOutput;
@@ -116,19 +70,12 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
);
// Create 2 test students
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student1@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Alice --lastName=Durand 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student2@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Bob --lastName=Martin 2>&1`,
{ encoding: 'utf-8' }
);
createTestUser('ecole-alpha', 'e2e-grade-student1@example.com', 'Student123', 'ROLE_ELEVE --firstName=Alice --lastName=Durand');
createTestUser('ecole-alpha', 'e2e-grade-student2@example.com', 'Student123', 'ROLE_ELEVE --firstName=Bob --lastName=Martin');
// Assign students to class
const studentIds = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`,
{ encoding: 'utf-8' }
const studentIds = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 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 >= 2) {
@@ -147,12 +94,11 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
);
// Create test evaluation
const evalOutput = execSync(
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","grade-eval-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
`' 2>&1`
).trim();
evaluationId = evalOutput;
@@ -236,9 +182,10 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('/abs');
await firstInput.clear();
await firstInput.pressSequentially('/abs');
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 15000 });
});
test('/disp marks student as dispensed', async ({ page }) => {
@@ -247,9 +194,10 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('/disp');
await firstInput.clear();
await firstInput.pressSequentially('/disp');
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 15000 });
});
});

View File

@@ -94,7 +94,7 @@ test.describe('Guardian Management', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

58
frontend/e2e/helpers.ts Normal file
View File

@@ -0,0 +1,58 @@
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const projectRoot = join(__dirname, '../..');
export const composeFile = join(projectRoot, 'compose.yaml');
export function execWithRetry(command: string, maxRetries = 3): string {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return execSync(command, { encoding: 'utf-8' });
} catch (error) {
if (attempt === maxRetries) throw error;
// Wait before retry: 1s, 2s, 3s
execSync(`sleep ${attempt}`);
}
}
throw new Error('Unreachable');
}
export function runSql(sql: string) {
execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`
);
}
export function clearCache() {
try {
execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`
);
} catch {
// Cache pool may not exist
}
}
export function resolveDeterministicIds(tenantId: string): { schoolId: string; academicYearId: string } {
const output = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${tenantId}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
export function createTestUser(tenant: string, email: string, password: string, role: string) {
execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=${tenant} --email=${email} --password=${password} --role=${role} 2>&1`
);
}

View File

@@ -68,7 +68,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click(),
]);
}

View File

@@ -87,7 +87,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -323,6 +323,7 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
// T4.3 : Delete attachment
test('can delete an uploaded attachment', async ({ page }) => {
test.slow(); // upload + delete needs more than 30s
await loginAsTeacher(page);
await navigateToHomework(page);

View File

@@ -72,7 +72,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click(),
]);
}

View File

@@ -72,7 +72,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click(),
]);
}

View File

@@ -77,7 +77,7 @@ test.describe('Homework Rules Configuration', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -1,10 +1,5 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
@@ -17,42 +12,6 @@ const TEACHER_EMAIL = 'e2e-sub-teacher@example.com';
const TEACHER_PASSWORD = 'SubTeacher123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
function getNextWeekday(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
@@ -79,7 +38,7 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -89,7 +48,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -102,27 +61,20 @@ test.describe('Homework Submission (Story 5.10)', () => {
test.beforeAll(async () => {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`,
{ encoding: 'utf-8' }
execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`
);
} catch {
// Cache pools may not exist
}
// Create student user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
createTestUser('ecole-alpha', STUDENT_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE');
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
const { schoolId, academicYearId } = resolveDeterministicIds();
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
// Ensure class exists
try {
@@ -358,9 +310,8 @@ test.describe('Homework Submission (Story 5.10)', () => {
await loginAsTeacher(page);
// Get the homework ID from the database
const homeworkIdOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM homework WHERE title = 'E2E Devoir à rendre' AND tenant_id = '${TENANT_ID}' LIMIT 1" 2>&1`,
{ encoding: 'utf-8' }
const homeworkIdOutput = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM homework WHERE title = 'E2E Devoir à rendre' AND tenant_id = '${TENANT_ID}' LIMIT 1" 2>&1`
);
const idMatch = homeworkIdOutput.match(

View File

@@ -74,7 +74,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -435,12 +435,14 @@ test.describe('Homework Management (Story 5.1)', () => {
await editorVal.click();
await editorVal.pressSequentially('Test validation');
// Set a past date — fill() works with Svelte 5 bind:value
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const y = yesterday.getFullYear();
const m = String(yesterday.getMonth() + 1).padStart(2, '0');
const d = String(yesterday.getDate()).padStart(2, '0');
// Set a past weekday — must be Mon-Fri to avoid frontend weekend validation
const pastDay = new Date();
do {
pastDay.setDate(pastDay.getDate() - 1);
} while (pastDay.getDay() === 0 || pastDay.getDay() === 6);
const y = pastDay.getFullYear();
const m = String(pastDay.getMonth() + 1).padStart(2, '0');
const d = String(pastDay.getDate()).padStart(2, '0');
const pastDate = `${y}-${m}-${d}`;
await page.locator('#hw-due-date').fill(pastDate);

View File

@@ -71,7 +71,7 @@ test.describe('Image Rights Management', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -82,7 +82,7 @@ test.describe('Image Rights Management', () => {
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -315,7 +315,7 @@ test.describe('Image Rights Management', () => {
await page.goto(`${ALPHA_URL}/admin/image-rights`);
// Admin guard in +layout.svelte redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
expect(page.url()).toContain('/dashboard');
});
});

View File

@@ -108,7 +108,7 @@ async function loginAsAdmin(page: Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -43,7 +43,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -34,7 +34,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -109,7 +109,7 @@ async function loginAsParent(page: import('@playwright/test').Page) {
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -46,7 +46,7 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -39,7 +39,7 @@ test.describe('Periods Management (Story 2.3)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -55,7 +55,7 @@ test.describe('Role-Based Access Control [P0]', () => {
await page.locator('#email').fill(email);
await page.locator('#password').fill(password);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -128,7 +128,7 @@ test.describe('Role-Based Access Control [P0]', () => {
await page.goto(`${ALPHA_URL}/admin/users`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
expect(page.url()).toContain('/dashboard');
});
@@ -137,7 +137,7 @@ test.describe('Role-Based Access Control [P0]', () => {
await page.goto(`${ALPHA_URL}/admin/classes`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
expect(page.url()).toContain('/dashboard');
});
@@ -146,7 +146,7 @@ test.describe('Role-Based Access Control [P0]', () => {
await page.goto(`${ALPHA_URL}/admin`);
// Admin guard redirects non-admin users to /dashboard
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
expect(page.url()).toContain('/dashboard');
});
});

View File

@@ -115,7 +115,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -86,7 +86,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -49,7 +49,7 @@ async function login(page: import('@playwright/test').Page, email: string) {
await page.locator('#email').fill(email);
await page.locator('#password').fill(TEST_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -58,7 +58,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -70,7 +70,7 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -357,7 +357,7 @@ test.describe('Student Homework Consultation (Story 5.7)', () => {
await page.locator('.filter-chip', { hasText: /maths/i }).click();
const cards = page.locator('.homework-card');
await expect(cards).toHaveCount(1, { timeout: 5000 });
await expect(cards).toHaveCount(1);
await expect(cards.first().locator('.card-title')).toContainText('Exercices chapitre 3');
});
@@ -369,10 +369,10 @@ test.describe('Student Homework Consultation (Story 5.7)', () => {
// Filter then unfilter
await page.locator('.filter-chip', { hasText: /maths/i }).click();
await expect(page.locator('.homework-card')).toHaveCount(1, { timeout: 5000 });
await expect(page.locator('.homework-card')).toHaveCount(1);
await page.locator('.filter-chip', { hasText: /tous/i }).click();
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 5000 });
await expect(page.locator('.homework-card')).toHaveCount(2);
});
});
@@ -413,7 +413,7 @@ test.describe('Student Homework Consultation (Story 5.7)', () => {
// Reload the page
await page.reload();
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 15000 });
// Done state should persist (localStorage)
await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 });

View File

@@ -46,7 +46,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -109,7 +109,7 @@ test.describe('Student Management', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -87,7 +87,7 @@ test.describe('Subjects Management (Story 2.2)', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -60,7 +60,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -46,7 +46,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -56,7 +56,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -71,7 +71,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -81,7 +81,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
await page.locator('#email').fill(TARGET_EMAIL);
await page.locator('#password').fill(TARGET_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -77,7 +77,7 @@ test.describe('User Blocking', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}

View File

@@ -34,7 +34,7 @@ test.describe('User Creation', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}