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