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.
395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|