feat: Provisionner automatiquement un nouvel établissement
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit e72867932d
107 changed files with 9709 additions and 383 deletions

View 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é');
});
});