feat: Permettre à l'élève de rendre un devoir avec réponse texte et pièces jointes
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

L'élève peut désormais répondre à un devoir via un éditeur WYSIWYG,
joindre des fichiers (PDF, JPEG, PNG, DOCX), sauvegarder un brouillon
et soumettre définitivement son rendu. Le système détecte automatiquement
les soumissions en retard par rapport à la date d'échéance.

Côté enseignant, une page dédiée affiche la liste complète des élèves
avec leur statut (soumis, en retard, brouillon, non rendu), le détail
de chaque rendu avec ses pièces jointes téléchargeables, et les
statistiques de rendus par classe.
This commit is contained in:
2026-03-25 19:38:25 +01:00
parent ab835e5c3d
commit df25a8cbb0
48 changed files with 4519 additions and 12 deletions

View File

@@ -0,0 +1,394 @@
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);
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';
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);
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: 30000 }),
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: 30000 }),
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 {
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' }
);
} 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' }
);
// 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' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// 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 = 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 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();
});
});
});