feat: Permettre aux enseignants de créer et gérer les devoirs
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 avaient besoin d'un outil pour créer des devoirs assignés
à leurs classes, avec filtrage automatique par matière selon la classe
sélectionnée. Le système valide que la date d'échéance tombe un jour
ouvrable (lundi-vendredi) et empêche les dates dans le passé.

Le domaine modélise le devoir comme un agrégat avec pièces jointes,
statut brouillon/publié, et événements métier (création, modification,
suppression). Les handlers de notification écoutent ces événements pour
les futurs envois aux parents et élèves.
This commit is contained in:
2026-03-12 10:11:06 +01:00
parent 56bc808d85
commit e9efb90f59
51 changed files with 4776 additions and 7 deletions

View File

@@ -0,0 +1,450 @@
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 TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
const TEACHER_PASSWORD = 'HomeworkTest123';
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! };
}
/**
* Returns a future weekday date string (YYYY-MM-DD).
* Skips weekends to satisfy DueDateValidator.
*/
function getNextWeekday(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
// Skip to Monday if weekend
const day = date.getDay();
if (day === 0) date.setDate(date.getDate() + 1); // Sunday → Monday
if (day === 6) date.setDate(date.getDate() + 2); // Saturday → Monday
// Use local date components to avoid UTC timezone shift from toISOString()
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 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()
]);
}
async function navigateToHomework(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
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) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// Table may not exist
}
}
test.describe('Homework Management (Story 5.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' }
);
// Ensure class and subject exist
const { schoolId, academicYearId } = resolveDeterministicIds();
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-HW-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-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
test.beforeEach(async () => {
// Clean up homework data
try {
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
}
// Re-ensure data exists
const { schoolId, academicYearId } = resolveDeterministicIds();
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-HW-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-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('homework link appears in teacher navigation', async ({ page }) => {
await loginAsTeacher(page);
const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 });
});
test('can navigate to homework page', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible();
});
});
// ============================================================================
// Empty State
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state when no homework exists', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await expect(page.getByText(/aucun devoir/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC1: Create Homework
// ============================================================================
test.describe('AC1: Create Homework', () => {
test('can create a new homework', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// Open create modal
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Select class
const classSelect = page.locator('#hw-class');
await expect(classSelect).toBeVisible();
await classSelect.selectOption({ index: 1 });
// Select subject (AC2: filtered by class)
const subjectSelect = page.locator('#hw-subject');
await expect(subjectSelect).toBeEnabled();
await subjectSelect.selectOption({ index: 1 });
// Fill title
await page.locator('#hw-title').fill('Exercices chapitre 5');
// Fill description
await page.locator('#hw-description').fill('Pages 42-45, exercices 1 à 10');
// Set due date (next weekday, at least 2 days from now)
await page.locator('#hw-due-date').fill(getNextWeekday(3));
// Submit
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Modal closes and homework appears in list
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Exercices chapitre 5')).toBeVisible({ timeout: 10000 });
});
test('cancel closes the modal without creating', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /annuler/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// AC2: Subject Filtering
// ============================================================================
test.describe('AC2: Subject Filtering', () => {
test('subject dropdown is disabled until class is selected', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Subject should be disabled initially
await expect(page.locator('#hw-subject')).toBeDisabled();
// Select class
await page.locator('#hw-class').selectOption({ index: 1 });
// Subject should now be enabled
await expect(page.locator('#hw-subject')).toBeEnabled();
});
});
// ============================================================================
// AC5: Published Homework Appears in List
// ============================================================================
test.describe('AC5: Published Homework', () => {
test('created homework appears in list with correct info', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// Create a homework
await page.getByRole('button', { name: /nouveau devoir/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir de mathématiques');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify homework card shows
await expect(page.getByText('Devoir de mathématiques')).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/publié/i)).toBeVisible();
});
});
// ============================================================================
// AC6: Edit Homework
// ============================================================================
test.describe('AC6: Edit Homework', () => {
test('can modify an existing homework', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// First create a homework
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir à modifier');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir à modifier')).toBeVisible({ timeout: 10000 });
// Click edit
await page.getByRole('button', { name: /modifier/i }).first().click();
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
// Change title
await page.locator('#edit-title').clear();
await page.locator('#edit-title').fill('Devoir modifié');
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(editDialog).not.toBeVisible({ timeout: 10000 });
// Verify updated title
await expect(page.getByText('Devoir modifié')).toBeVisible({ timeout: 10000 });
});
test('can delete an existing homework', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
// First create a homework
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir à supprimer');
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Devoir à supprimer')).toBeVisible({ timeout: 10000 });
// Click delete
await page.getByRole('button', { name: /supprimer/i }).first().click();
// Confirm in alertdialog
const confirmDialog = page.getByRole('alertdialog');
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
await expect(page.getByText(/êtes-vous sûr/i)).toBeVisible();
await confirmDialog.getByRole('button', { name: /supprimer/i }).click();
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
// Homework should be gone from the list (or show as deleted)
await expect(page.getByText('Devoir à supprimer')).not.toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// Date Validation
// ============================================================================
test.describe('Date Validation', () => {
test('due date input has min attribute set to tomorrow', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const dueDateInput = page.locator('#hw-due-date');
const minValue = await dueDateInput.getAttribute('min');
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const ey = tomorrow.getFullYear();
const em = String(tomorrow.getMonth() + 1).padStart(2, '0');
const ed = String(tomorrow.getDate()).padStart(2, '0');
expect(minValue).toBe(`${ey}-${em}-${ed}`);
});
test('backend rejects a past due date', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir date passée');
await page.locator('#hw-description').fill('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');
const pastDate = `${y}-${m}-${d}`;
await page.locator('#hw-due-date').fill(pastDate);
// Bypass HTML native validation (min attribute) to test backend validation
await page.locator('form.modal-body').evaluate((el) => el.setAttribute('novalidate', ''));
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Backend should reject — an error alert appears
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// Partial Update
// ============================================================================
test.describe('Partial Update', () => {
test('can update only the title without changing other fields', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
const dueDate = getNextWeekday(5);
// Create a homework with description
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Titre original');
await page.locator('#hw-description').fill('Description inchangée');
await page.locator('#hw-due-date').fill(dueDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText('Titre original')).toBeVisible({ timeout: 10000 });
// Open edit modal
await page.getByRole('button', { name: /modifier/i }).first().click();
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
// Verify pre-filled values
await expect(page.locator('#edit-description')).toHaveValue('Description inchangée');
await expect(page.locator('#edit-due-date')).toHaveValue(dueDate);
// Change only the title
await page.locator('#edit-title').clear();
await page.locator('#edit-title').fill('Titre mis à jour');
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(editDialog).not.toBeVisible({ timeout: 10000 });
// Verify title changed
await expect(page.getByText('Titre mis à jour')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Titre original')).not.toBeVisible();
// Verify description still visible
await expect(page.getByText('Description inchangée')).toBeVisible();
});
});
});