Files
Classeo/frontend/e2e/homework-submission.spec.ts
Mathias STRASSER b7dc27f2a5
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: Calculer automatiquement les moyennes après chaque saisie de notes
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.
2026-04-04 02:25:00 +02:00

346 lines
14 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 STUDENT_EMAIL = 'e2e-sub-student@example.com';
const STUDENT_PASSWORD = 'SubStudent123';
const TEACHER_EMAIL = 'e2e-sub-teacher@example.com';
const TEACHER_PASSWORD = 'SubTeacher123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
function getNextWeekday(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
const day = date.getDay();
if (day === 0) date.setDate(date.getDate() + 1);
if (day === 6) date.setDate(date.getDate() + 2);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function getPastDate(daysAgo: number): string {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
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()
]);
}
test.describe('Homework Submission (Story 5.10)', () => {
test.describe.configure({ mode: 'serial' });
const dueDate = getNextWeekday(7);
const pastDueDate = getPastDate(3);
test.beforeAll(async () => {
try {
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
createTestUser('ecole-alpha', STUDENT_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE');
// Create teacher user
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
// Ensure class exists
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-Sub-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure subject exists
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Sub-Maths', 'E2ESUBMAT', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// 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) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
`FROM users u, school_classes c ` +
`WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Clean up submissions and homework
try {
runSql(
`DELETE FROM submission_attachments WHERE submission_id IN ` +
`(SELECT hs.id FROM homework_submissions hs JOIN homework h ON h.id = hs.homework_id ` +
`WHERE h.tenant_id = '${TENANT_ID}' AND h.class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework_submissions WHERE homework_id IN ` +
`(SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework_attachments WHERE homework_id IN ` +
`(SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}')`
);
} catch {
// Tables may not exist
}
// Seed homework (future due date)
runSql(
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'E2E Devoir à rendre', 'Rédigez un texte libre.', '${dueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESUBMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
`WHERE c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}'`
);
// Seed homework (past due date for late submission test)
runSql(
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'E2E Devoir en retard', 'Devoir déjà dû.', '${pastDueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESUBMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
`WHERE c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}'`
);
clearCache();
});
// ======================================================================
// AC1 + AC3: Draft and Submission
// ======================================================================
test.describe('AC1+AC3: Write and submit homework', () => {
test('student sees "Rendre mon devoir" button in homework detail', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
// Click on the homework
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
// Verify "Rendre mon devoir" button is visible
await expect(page.getByRole('button', { name: /rendre mon devoir/i })).toBeVisible();
});
test('student can save a draft', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
// Click "Rendre mon devoir"
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Wait for editor and type
const editor = page.locator('.ProseMirror');
await expect(editor).toBeVisible({ timeout: 10000 });
await editor.click();
await page.keyboard.type('Mon brouillon de reponse');
// Save draft
await page.getByRole('button', { name: /sauvegarder le brouillon/i }).click();
await expect(page.locator('.success-banner')).toContainText('Brouillon sauvegardé', {
timeout: 10000
});
});
test('student can upload a file attachment', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Upload a PDF file
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'devoir-eleve.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('Fake PDF content for E2E test')
});
// Wait for file to appear in the list
await expect(page.locator('.file-item')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('devoir-eleve.pdf')).toBeVisible();
});
test('student can submit homework with confirmation', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Click submit
await page.getByRole('button', { name: /soumettre mon devoir/i }).click();
// Confirmation dialog should appear
await expect(page.locator('[role="alertdialog"]')).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="alertdialog"]')).toContainText(
'Confirmer la soumission'
);
// Confirm submission
await page.getByRole('button', { name: /confirmer/i }).click();
// Should show success
await expect(page.locator('.success-banner')).toContainText('rendu avec succès', {
timeout: 10000
});
});
});
// ======================================================================
// AC4: Status in homework list
// ======================================================================
test.describe('AC4: Submission status in list', () => {
test('student sees submitted status in homework list', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
const card = page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' });
await expect(card.locator('.submission-submitted')).toContainText('Rendu');
});
});
// ======================================================================
// AC4: Late submission
// ======================================================================
test.describe('AC4: Late submission', () => {
test('late submission shows en retard status', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
await page.locator('.homework-card', { hasText: 'E2E Devoir en retard' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Wait for editor to be ready then type
const editor = page.locator('.ProseMirror');
await expect(editor).toBeVisible({ timeout: 10000 });
await editor.click();
await page.keyboard.type('Rendu en retard');
// Save draft first
await page.getByRole('button', { name: /sauvegarder le brouillon/i }).click();
await expect(page.locator('.success-banner')).toBeVisible({ timeout: 10000 });
// Wait for success to appear then submit
await page.getByRole('button', { name: /soumettre mon devoir/i }).click();
await expect(page.locator('[role="alertdialog"]')).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /confirmer/i }).click();
await expect(page.locator('.success-banner')).toContainText('rendu', {
timeout: 15000
});
// Go back to list and check status
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
const card = page.locator('.homework-card', { hasText: 'E2E Devoir en retard' });
await expect(card.locator('.submission-late')).toContainText('retard', {
timeout: 15000
});
});
});
// ======================================================================
// AC5 + AC6: Teacher views submissions and stats
// ======================================================================
test.describe('AC5+AC6: Teacher submission views', () => {
test('teacher can view submissions list and stats', async ({ page }) => {
await loginAsTeacher(page);
// Get the homework ID from the database
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(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
);
if (!idMatch) {
throw new Error('Could not find homework ID');
}
const homeworkId = idMatch[0];
// Navigate to submissions page
await page.goto(
`${ALPHA_URL}/dashboard/teacher/homework/${homeworkId}/submissions`
);
// Should see stats
await expect(page.locator('.stat-value')).toContainText('1', { timeout: 15000 });
// Should see the submission in the table
await expect(page.locator('tbody tr')).toHaveCount(1, { timeout: 10000 });
// Should see "Voir" button
await expect(page.getByRole('button', { name: 'Voir', exact: true })).toBeVisible();
// Click to view detail
await page.getByRole('button', { name: 'Voir', exact: true }).click();
await expect(page.locator('.detail-header')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.response-content')).toBeVisible();
});
});
});