feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
This commit is contained in:
@@ -171,7 +171,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
||||
await firstInput.fill('25');
|
||||
|
||||
// Should show error
|
||||
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
// Réutilise le même enseignant que homework.spec.ts pour partager le setup
|
||||
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
@@ -105,7 +104,6 @@ async function createHomework(page: import('@playwright/test').Page, title: stri
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill(title);
|
||||
|
||||
// Type in WYSIWYG editor (TipTap initializes asynchronously)
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
@@ -158,9 +156,8 @@ function cleanupTempFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
test.describe('Homework Attachments (Story 5.9/5.11)', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Ensure teacher user exists (same as homework.spec.ts)
|
||||
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' }
|
||||
@@ -189,7 +186,6 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// homework_submissions has NO CASCADE on homework_id — delete submissions first
|
||||
try {
|
||||
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||
} catch { /* Table may not exist */ }
|
||||
@@ -198,138 +194,41 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
} catch { /* Table may not exist */ }
|
||||
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
|
||||
}
|
||||
|
||||
// Disable any homework rules left by other test files (homework-rules-warning,
|
||||
// homework-rules-hard) to prevent rule warnings blocking homework creation.
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
|
||||
// Clear school calendar entries that may block dates (Vacances de Printemps, etc.)
|
||||
try {
|
||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.1 : WYSIWYG Editor
|
||||
// ============================================================================
|
||||
test.describe('WYSIWYG Editor', () => {
|
||||
test('create form shows rich text editor with toolbar', 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 });
|
||||
|
||||
// Rich text editor with toolbar should be visible
|
||||
const editor = page.locator('.rich-text-editor');
|
||||
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.toolbar')).toBeVisible();
|
||||
|
||||
// Toolbar buttons
|
||||
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create homework with rich text description', 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 texte riche');
|
||||
|
||||
// Type in rich text editor (TipTap initializes asynchronously)
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Consignes importantes');
|
||||
|
||||
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 texte riche')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('bold formatting works in editor', 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 gras test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Normal ');
|
||||
|
||||
// Apply bold via keyboard shortcut (more reliable than toolbar click)
|
||||
await page.keyboard.press('Control+b');
|
||||
await page.keyboard.type('en gras');
|
||||
|
||||
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 gras test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify bold is rendered in the description
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('strong')).toContainText('en gras');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.2 : Upload attachment
|
||||
// ============================================================================
|
||||
test.describe('Attachments', () => {
|
||||
test.describe('Upload & Delete', () => {
|
||||
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Create homework
|
||||
await createHomework(page, 'Devoir avec PJ');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload file
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
|
||||
// File appears in list
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
// T4.3 : Delete attachment
|
||||
test('can delete an uploaded attachment', async ({ page }) => {
|
||||
test.slow(); // upload + delete needs more than 30s
|
||||
test.slow();
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir suppr PJ');
|
||||
|
||||
// Open edit modal and upload
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
@@ -338,80 +237,142 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Delete the attachment
|
||||
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
|
||||
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.1 : Invalid file type rejection (P1)
|
||||
// ============================================================================
|
||||
test.describe('Invalid File Type Rejection', () => {
|
||||
test.describe('Drop Zone UI', () => {
|
||||
test('create form shows drag-and-drop zone for attachments', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir drop zone');
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir drop zone' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const dropZone = page.locator('.drop-zone');
|
||||
await expect(dropZone).toBeVisible({ timeout: 5000 });
|
||||
await expect(dropZone).toContainText('Glissez-déposez');
|
||||
await expect(dropZone.locator('.drop-zone-browse')).toContainText('parcourir');
|
||||
});
|
||||
|
||||
test('browse button in drop zone opens file dialog and uploads', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir browse btn');
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir browse btn' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('drag-and-drop visual feedback appears on dragover', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir drag feedback');
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir drag feedback' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const dropZone = page.locator('.drop-zone');
|
||||
await expect(dropZone).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await dropZone.evaluate((el) => {
|
||||
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: new DataTransfer(), bubbles: true }));
|
||||
});
|
||||
await expect(dropZone).toHaveClass(/drop-zone-active/);
|
||||
|
||||
await dropZone.evaluate((el) => {
|
||||
el.dispatchEvent(new DragEvent('dragleave', { bubbles: true }));
|
||||
});
|
||||
await expect(dropZone).not.toHaveClass(/drop-zone-active/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('File Type Badge', () => {
|
||||
test('uploaded PDF shows formatted type badge', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir badge type');
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir badge type' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileType = page.locator('.file-type');
|
||||
await expect(fileType).toBeVisible({ timeout: 5000 });
|
||||
await expect(fileType).toHaveText('PDF');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Validation', () => {
|
||||
test('rejects a .txt file with an error message', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir rejet fichier');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Try to upload a .txt file
|
||||
const txtPath = createTempTxt();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(txtPath);
|
||||
|
||||
// Error message should appear
|
||||
const errorAlert = page.locator('[role="alert"]');
|
||||
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
||||
await expect(errorAlert).toContainText('Type de fichier non accepté');
|
||||
|
||||
// The .txt file should NOT appear in the file list
|
||||
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.2 : Attachment persistence after save (P1)
|
||||
// ============================================================================
|
||||
test.describe('Attachment Persistence', () => {
|
||||
test.describe('Persistence', () => {
|
||||
test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir persistance PJ');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload a PDF
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Save the changes
|
||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Reopen the edit modal
|
||||
const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||
await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The attachment should still be there
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.3 : File size display after upload (P2)
|
||||
// ============================================================================
|
||||
test.describe('File Size Display', () => {
|
||||
test('shows formatted file size after uploading a PDF', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
@@ -419,73 +380,18 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
|
||||
await createHomework(page, 'Devoir taille fichier');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload a PDF
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko")
|
||||
const fileSize = page.locator('.file-size');
|
||||
await expect(fileSize).toBeVisible({ timeout: 5000 });
|
||||
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.4 : Backward compatibility
|
||||
// ============================================================================
|
||||
test.describe('Backward Compatibility', () => {
|
||||
test('existing plain text homework displays correctly', async ({ page }) => {
|
||||
// Create homework with plain text description via SQL
|
||||
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, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Plain text description displays correctly
|
||||
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
|
||||
});
|
||||
|
||||
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
|
||||
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, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// WYSIWYG editor contains the old text (TipTap initializes asynchronously)
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
395
frontend/e2e/homework-calendar.spec.ts
Normal file
395
frontend/e2e/homework-calendar.spec.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
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! };
|
||||
}
|
||||
|
||||
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 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 {
|
||||
// May already exist
|
||||
}
|
||||
}
|
||||
|
||||
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: 60000 }),
|
||||
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 });
|
||||
}
|
||||
|
||||
test.describe('Calendar Date Picker (Story 5.11)', () => {
|
||||
test.beforeAll(async () => {
|
||||
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();
|
||||
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 () => {
|
||||
try {
|
||||
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id 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 */ }
|
||||
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 */ }
|
||||
try {
|
||||
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test('create form shows calendar date picker instead of native input', 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 });
|
||||
|
||||
// Calendar date picker should be visible
|
||||
const picker = page.locator('.calendar-date-picker');
|
||||
await expect(picker).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Trigger button should show placeholder text
|
||||
await expect(picker.locator('.picker-trigger')).toContainText('Choisir une date');
|
||||
|
||||
// Native date input is sr-only (hidden but present for form semantics)
|
||||
await expect(page.locator('#hw-due-date')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
test('clicking picker opens calendar dropdown', 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 picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
|
||||
// Calendar dropdown should be visible
|
||||
const dropdown = picker.locator('.calendar-dropdown');
|
||||
await expect(dropdown).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Day names header should be visible
|
||||
await expect(dropdown.locator('.day-name').first()).toBeVisible();
|
||||
|
||||
// Month label should be visible
|
||||
await expect(dropdown.locator('.month-label')).toBeVisible();
|
||||
});
|
||||
|
||||
test('weekends are disabled in calendar', 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 picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Weekend cells should have the weekend class and be disabled
|
||||
const weekendCells = picker.locator('.day-cell.weekend');
|
||||
const count = await weekendCells.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
// Weekend cells should be disabled
|
||||
const firstWeekend = weekendCells.first();
|
||||
await expect(firstWeekend).toBeDisabled();
|
||||
});
|
||||
|
||||
test('can select a date by clicking a day in the calendar', 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 calendrier test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Test description');
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Find a valid weekday button (not disabled, not weekend, not before-min)
|
||||
const validDays = picker.locator('.day-cell:not(.weekend):not(.blocked):not(.before-min):not(.empty):enabled');
|
||||
const validCount = await validDays.count();
|
||||
expect(validCount).toBeGreaterThan(0);
|
||||
|
||||
// Click the last available day (likely to be far enough in the future)
|
||||
await validDays.last().click();
|
||||
|
||||
// Dropdown should close
|
||||
await expect(picker.locator('.calendar-dropdown')).not.toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Trigger should now show the selected date (not placeholder)
|
||||
await expect(picker.locator('.picker-value')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can navigate to next/previous month', 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 picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
const monthLabel = picker.locator('.month-label');
|
||||
const initialMonth = await monthLabel.textContent();
|
||||
|
||||
// Navigate to next month
|
||||
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||
const nextMonth = await monthLabel.textContent();
|
||||
expect(nextMonth).not.toBe(initialMonth);
|
||||
|
||||
// Navigate back
|
||||
await picker.getByRole('button', { name: /mois précédent/i }).click();
|
||||
const backMonth = await monthLabel.textContent();
|
||||
expect(backMonth).toBe(initialMonth);
|
||||
});
|
||||
|
||||
test('can create homework using calendar date picker (hidden input fallback)', 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 via calendrier');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Description du devoir');
|
||||
|
||||
// Use hidden date input for programmatic date selection (E2E compatibility)
|
||||
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 via calendrier')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('edit modal shows calendar date picker with current date selected', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Create homework first via hidden input
|
||||
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 edit calendrier');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Description');
|
||||
|
||||
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 });
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit calendrier' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Edit modal should have calendar picker with date displayed
|
||||
const editPicker = page.locator('.modal .calendar-date-picker');
|
||||
await expect(editPicker).toBeVisible({ timeout: 5000 });
|
||||
await expect(editPicker.locator('.picker-value')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rule-hard blocked dates show colored dots in calendar', async ({ page }) => {
|
||||
// Seed a homework rule (minimum_delay=30 days, hard mode) so dates within 30 days are blocked.
|
||||
// Using 30 days ensures the current month always has blocked weekdays visible.
|
||||
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":30}}]';
|
||||
runSql(
|
||||
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` +
|
||||
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Navigate to next month to guarantee blocked dates are visible
|
||||
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||
|
||||
// Days within the 30-day delay window should be rule-blocked
|
||||
const blockedWithDot = picker.locator('.day-cell.blocked .blocked-dot');
|
||||
await expect(blockedWithDot.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Legend should show "Règle (bloquant)"
|
||||
await expect(picker.locator('.calendar-legend')).toContainText('Règle (bloquant)');
|
||||
});
|
||||
|
||||
test('blocked dates (holidays) show colored dots in calendar', async ({ page }) => {
|
||||
// Seed a holiday entry covering a guaranteed weekday next month
|
||||
const { academicYearId } = resolveDeterministicIds();
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
// Start from the 10th and find the first weekday (Mon-Fri)
|
||||
let holidayDay = 10;
|
||||
const probe = new Date(nextMonth.getFullYear(), nextMonth.getMonth(), holidayDay);
|
||||
while (probe.getDay() === 0 || probe.getDay() === 6) {
|
||||
holidayDay++;
|
||||
probe.setDate(holidayDay);
|
||||
}
|
||||
const holidayDate = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, '0')}-${String(holidayDay).padStart(2, '0')}`;
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, description, created_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', 'holiday', '${holidayDate}', '${holidayDate}', 'Jour férié E2E', NULL, NOW())`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Navigate to next month
|
||||
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||
|
||||
// The holiday day should be blocked with a colored dot
|
||||
const holidayCell = picker.getByRole('gridcell', { name: String(holidayDay), exact: true });
|
||||
await expect(holidayCell).toBeVisible({ timeout: 5000 });
|
||||
await expect(holidayCell).toBeDisabled();
|
||||
await expect(holidayCell.locator('.blocked-dot')).toBeVisible();
|
||||
|
||||
// Legend should show "Jour férié"
|
||||
await expect(picker.locator('.calendar-legend')).toContainText('Jour férié');
|
||||
});
|
||||
});
|
||||
@@ -297,8 +297,8 @@ test.describe('Homework Exception Request (Story 5.6)', () => {
|
||||
.fill('Justification suffisamment longue pour être valide.');
|
||||
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||
|
||||
// Wait for homework to appear
|
||||
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
|
||||
// Wait for homework to appear (Firefox needs more time after exception flow)
|
||||
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Exception badge visible (⚠ Exception text or rule override badge)
|
||||
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
|
||||
|
||||
316
frontend/e2e/homework-wysiwyg.spec.ts
Normal file
316
frontend/e2e/homework-wysiwyg.spec.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
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! };
|
||||
}
|
||||
|
||||
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 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 {
|
||||
// May already exist
|
||||
}
|
||||
}
|
||||
|
||||
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: 60000 }),
|
||||
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 });
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG Editor & Backward Compatibility (Story 5.9/5.11)', () => {
|
||||
test.beforeAll(async () => {
|
||||
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();
|
||||
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 () => {
|
||||
try {
|
||||
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id 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 */ }
|
||||
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 */ }
|
||||
try {
|
||||
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.describe('WYSIWYG Editor', () => {
|
||||
test('create form shows rich text editor with toolbar', 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 editor = page.locator('.rich-text-editor');
|
||||
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.toolbar')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create homework with rich text description', 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 texte riche');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Consignes importantes');
|
||||
|
||||
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 texte riche')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('bold formatting works in editor', 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 gras test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Normal ');
|
||||
|
||||
await page.keyboard.press('Control+b');
|
||||
await page.keyboard.type('en gras');
|
||||
|
||||
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 gras test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('strong')).toContainText('en gras');
|
||||
});
|
||||
|
||||
test('italic formatting works in editor', 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 italique test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Normal ');
|
||||
|
||||
await page.keyboard.press('Control+i');
|
||||
await page.keyboard.type('en italique');
|
||||
|
||||
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 italique test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('em')).toContainText('en italique');
|
||||
});
|
||||
|
||||
test('bullet list formatting works in editor', 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 liste test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
|
||||
await editorContent.press('Control+Shift+8');
|
||||
await page.keyboard.type('Premier élément');
|
||||
|
||||
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 liste test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('ul')).toBeVisible();
|
||||
await expect(description.locator('li')).toContainText('Premier élément');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Backward Compatibility', () => {
|
||||
test('existing plain text homework displays correctly', async ({ page }) => {
|
||||
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, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
|
||||
});
|
||||
|
||||
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
|
||||
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, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
password: string
|
||||
) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(password);
|
||||
await Promise.all([
|
||||
|
||||
167
frontend/e2e/role-assignment.spec.ts
Normal file
167
frontend/e2e/role-assignment.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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 ADMIN_EMAIL = 'e2e-roles-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'RolesAdmin123';
|
||||
const TARGET_EMAIL = `e2e-roles-target-${Date.now()}@example.com`;
|
||||
const TARGET_PASSWORD = 'RolesTarget123';
|
||||
|
||||
test.describe('Multi-Role Assignment (FR5) [P2]', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Create target user with single role (PROF)
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
});
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function openRolesModalForTarget(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Search for the target user (paginated list may not show them on page 1)
|
||||
await page.getByRole('searchbox').fill(TARGET_EMAIL);
|
||||
await page.waitForTimeout(500); // debounce
|
||||
|
||||
// Find the target user row and click "Rôles" button
|
||||
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
|
||||
await expect(targetRow).toBeVisible({ timeout: 10000 });
|
||||
await targetRow.getByRole('button', { name: 'Rôles' }).click();
|
||||
|
||||
// Modal should appear
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.locator('#roles-modal-title')).toHaveText('Modifier les rôles');
|
||||
}
|
||||
|
||||
test('[P2] admin can open role modal showing current roles', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Target user email should be displayed in modal
|
||||
await expect(page.locator('.roles-modal-user')).toContainText(TARGET_EMAIL);
|
||||
|
||||
// ROLE_PROF should be checked (current role)
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
|
||||
// Other roles should be unchecked
|
||||
const adminCheckbox = page.locator('.role-checkbox-label', { hasText: 'Directeur' }).locator('input[type="checkbox"]');
|
||||
await expect(adminCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('[P2] admin can assign multiple roles to a user', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Add Vie Scolaire role in addition to PROF
|
||||
const vieScolaireLabel = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' });
|
||||
await vieScolaireLabel.locator('input[type="checkbox"]').check();
|
||||
|
||||
// Both should now be checked
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireLabel.locator('input[type="checkbox"]')).toBeChecked();
|
||||
|
||||
// Save
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
const saveResponse = await saveResponsePromise;
|
||||
expect(saveResponse.status()).toBeLessThan(400);
|
||||
|
||||
// Success message should appear
|
||||
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('[P2] assigned roles persist after page reload', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Both PROF and VIE_SCOLAIRE should still be checked after reload
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('[P2] admin can remove a role while keeping at least one', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Uncheck Vie Scolaire (added in previous test)
|
||||
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||
await vieScolaireCheckbox.uncheck();
|
||||
|
||||
// PROF should still be checked
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireCheckbox).not.toBeChecked();
|
||||
|
||||
// Save
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await saveResponsePromise;
|
||||
|
||||
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('[P2] last role checkbox is disabled to prevent removal', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Only PROF should be checked now (after previous test removed VIE_SCOLAIRE)
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
|
||||
// Last role checkbox should be disabled
|
||||
await expect(profCheckbox).toBeDisabled();
|
||||
|
||||
// "(dernier rôle)" hint should be visible
|
||||
await expect(
|
||||
page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('.role-checkbox-hint')
|
||||
).toContainText('dernier rôle');
|
||||
});
|
||||
|
||||
test('[P2] role modal can be closed with Escape', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
await page.getByRole('dialog').press('Escape');
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -264,14 +264,15 @@ test.describe('Sessions Management', () => {
|
||||
|
||||
await login(page, email);
|
||||
await page.goto(getTenantUrl('/settings'));
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click logout button
|
||||
const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i });
|
||||
await expect(logoutButton).toBeVisible();
|
||||
await logoutButton.click();
|
||||
|
||||
// Wait for redirect to login
|
||||
await expect(page).toHaveURL(/login/, { timeout: 10000 });
|
||||
await Promise.all([
|
||||
page.waitForURL(/login/, { timeout: 30000 }),
|
||||
logoutButton.click()
|
||||
]);
|
||||
});
|
||||
|
||||
test('logout clears authentication', async ({ page, browserName }, testInfo) => {
|
||||
@@ -288,8 +289,8 @@ test.describe('Sessions Management', () => {
|
||||
await expect(logoutButton).toBeVisible();
|
||||
await logoutButton.click();
|
||||
|
||||
// Wait for redirect to login
|
||||
await expect(page).toHaveURL(/login/, { timeout: 10000 });
|
||||
// Wait for redirect to login (Firefox can be slow to redirect)
|
||||
await expect(page).toHaveURL(/login/, { timeout: 20000 });
|
||||
|
||||
// Try to access protected page
|
||||
await page.goto(getTenantUrl('/settings/sessions'));
|
||||
|
||||
@@ -153,10 +153,11 @@ test.describe('Settings Page [P1]', () => {
|
||||
await login(page, email);
|
||||
|
||||
await page.goto(getTenantUrl('/settings'));
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: /tableau de bord/i })
|
||||
).toBeVisible();
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] settings layout shows Parametres navigation link as active', async ({ page }, testInfo) => {
|
||||
|
||||
@@ -484,7 +484,7 @@ test.describe('Student Creation & Management (Story 3.0)', () => {
|
||||
// Should find the student (use .first() because AC3 duplicate test creates a second one)
|
||||
await expect(
|
||||
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first()
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
|
||||
test('rows are clickable and navigate to student detail', async ({ page }) => {
|
||||
|
||||
@@ -481,7 +481,7 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
||||
await expect(firstSlot).toBeVisible({ timeout: 20000 });
|
||||
await firstSlot.click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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 projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
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 SA_PASSWORD = 'SuperAdmin123';
|
||||
const UNIQUE_SUFFIX = Date.now();
|
||||
|
||||
function getSuperAdminEmail(browserName: string): string {
|
||||
return `e2e-prov-sa-${browserName}@test.com`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
test.beforeAll(async ({}, testInfo) => {
|
||||
const browserName = testInfo.project.name;
|
||||
const saEmail = getSuperAdminEmail(browserName);
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[${browserName}] Failed to create super admin:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
async function loginAsSuperAdmin(
|
||||
page: import('@playwright/test').Page,
|
||||
email: string
|
||||
) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
|
||||
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(SA_PASSWORD);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }),
|
||||
submitButton.click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function navigateToEstablishments(page: import('@playwright/test').Page) {
|
||||
const link = page.getByRole('link', { name: /établissements/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||
link.click()
|
||||
]);
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
async function navigateToCreateForm(page: import('@playwright/test').Page) {
|
||||
const newLink = page.getByRole('link', { name: /nouvel établissement/i });
|
||||
await expect(newLink).toBeVisible({ timeout: 10000 });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }),
|
||||
newLink.click()
|
||||
]);
|
||||
await expect(page.locator('#name')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('Establishment Provisioning (Story 2-17) [P1]', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Subdomain Auto-generation', () => {
|
||||
test('[P1] typing name auto-generates subdomain', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await page.locator('#name').fill('École Saint-Exupéry');
|
||||
|
||||
// Subdomain should be auto-generated: accents removed, spaces→hyphens, lowercase
|
||||
await expect(page.locator('#subdomain')).toHaveValue('ecole-saint-exupery');
|
||||
});
|
||||
|
||||
test('[P2] subdomain suffix .classeo.fr is displayed', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await expect(page.locator('.subdomain-suffix')).toHaveText('.classeo.fr');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create Establishment Flow', () => {
|
||||
const establishmentName = `E2E Test ${UNIQUE_SUFFIX}`;
|
||||
const adminEmailForEstab = `admin-prov-${UNIQUE_SUFFIX}@test.com`;
|
||||
|
||||
test('[P1] submitting form creates establishment and redirects to list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
// Fill in the form
|
||||
await page.locator('#name').fill(establishmentName);
|
||||
await page.locator('#adminEmail').fill(adminEmailForEstab);
|
||||
|
||||
// Subdomain should be auto-generated
|
||||
const subdomain = await page.locator('#subdomain').inputValue();
|
||||
expect(subdomain.length).toBeGreaterThan(0);
|
||||
|
||||
// Submit
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
const apiResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/super-admin/establishments') && resp.request().method() === 'POST'
|
||||
);
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
// Verify API returns establishment in provisioning status
|
||||
const apiResponse = await apiResponsePromise;
|
||||
expect(apiResponse.status()).toBeLessThan(400);
|
||||
const body = await apiResponse.json();
|
||||
expect(body.status).toBe('provisioning');
|
||||
|
||||
// Should redirect back to establishments list
|
||||
await page.waitForURL('**/super-admin/establishments', { timeout: 15000 });
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] created establishment appears in the list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
|
||||
// The establishment created in previous test should be visible
|
||||
await expect(page.locator('table')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('td', { hasText: establishmentName })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] created establishment has a visible status badge', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
|
||||
// Find the row for our establishment
|
||||
const row = page.locator('tr', { has: page.locator(`text=${establishmentName}`) });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Status badge should be visible (provisioning status already verified via API response in creation test)
|
||||
const badge = row.locator('.badge');
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).not.toHaveText('');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Form Validation', () => {
|
||||
test('[P2] submit button disabled with empty fields', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[P2] submit button enabled when all fields filled', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await page.locator('#name').fill('Test School');
|
||||
await page.locator('#adminEmail').fill('admin@test.com');
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('[P2] cancel button returns to establishments list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
const cancelLink = page.getByRole('link', { name: /annuler/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||
cancelLink.click()
|
||||
]);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string }
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
|
||||
@@ -95,7 +95,7 @@ test.describe('Expired/Invalid Token Scenarios [P0]', () => {
|
||||
await page.goto(`/activate/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
await expect(form).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Fill valid password (must include special char for 5/5 requirements)
|
||||
await page.locator('#password').fill('SecurePass123!');
|
||||
|
||||
Reference in New Issue
Block a user