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(); }); }); });