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.
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { execSync } from 'child_process';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
|
|
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 });
|
|
}
|
|
|
|
async function createHomework(page: import('@playwright/test').Page, title: string) {
|
|
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(title);
|
|
|
|
const editorContent = page.locator('.modal .rich-text-content');
|
|
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
|
await editorContent.click();
|
|
await page.keyboard.type('Consignes du devoir');
|
|
|
|
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(title)).toBeVisible({ timeout: 10000 });
|
|
}
|
|
|
|
function createTempPdf(): string {
|
|
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
const filePath = join(tmpDir, 'test-attachment.pdf');
|
|
const pdfContent = `%PDF-1.4
|
|
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
|
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
|
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
|
|
xref
|
|
0 4
|
|
0000000000 65535 f
|
|
0000000009 00000 n
|
|
0000000058 00000 n
|
|
0000000115 00000 n
|
|
trailer<</Size 4/Root 1 0 R>>
|
|
startxref
|
|
190
|
|
%%EOF`;
|
|
writeFileSync(filePath, pdfContent);
|
|
return filePath;
|
|
}
|
|
|
|
function createTempTxt(): string {
|
|
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
const filePath = join(tmpDir, 'test-invalid.txt');
|
|
writeFileSync(filePath, 'This is a plain text file that should be rejected.');
|
|
return filePath;
|
|
}
|
|
|
|
function cleanupTempFiles() {
|
|
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
|
for (const name of ['test-attachment.pdf', 'test-invalid.txt']) {
|
|
try {
|
|
unlinkSync(join(tmpDir, name));
|
|
} catch {
|
|
// May not exist
|
|
}
|
|
}
|
|
}
|
|
|
|
test.describe('Homework Attachments (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.afterAll(() => {
|
|
cleanupTempFiles();
|
|
});
|
|
|
|
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('Upload & Delete', () => {
|
|
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
|
|
await createHomework(page, 'Devoir avec PJ');
|
|
|
|
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 });
|
|
|
|
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('can delete an uploaded attachment', async ({ page }) => {
|
|
test.slow();
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
|
|
await createHomework(page, 'Devoir suppr PJ');
|
|
|
|
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 });
|
|
|
|
const pdfPath = createTempPdf();
|
|
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
|
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
|
|
|
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
|
|
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
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');
|
|
|
|
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 });
|
|
|
|
const txtPath = createTempTxt();
|
|
const fileInput = page.locator('.file-input-hidden');
|
|
await fileInput.setInputFiles(txtPath);
|
|
|
|
const errorAlert = page.locator('[role="alert"]');
|
|
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
|
await expect(errorAlert).toContainText('Type de fichier non accepté');
|
|
|
|
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
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');
|
|
|
|
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 });
|
|
|
|
const pdfPath = createTempPdf();
|
|
const fileInput = page.locator('.file-input-hidden');
|
|
await fileInput.setInputFiles(pdfPath);
|
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
|
|
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
|
|
|
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 });
|
|
|
|
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
|
|
test.describe('File Size Display', () => {
|
|
test('shows formatted file size after uploading a PDF', async ({ page }) => {
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
|
|
await createHomework(page, 'Devoir taille fichier');
|
|
|
|
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 });
|
|
|
|
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 fileSize = page.locator('.file-size');
|
|
await expect(fileSize).toBeVisible({ timeout: 5000 });
|
|
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
|
|
});
|
|
});
|
|
});
|