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.
394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { execSync } from 'child_process';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
|
|
|
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
|
const TEACHER_PASSWORD = 'HomeworkTest123';
|
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
|
|
const projectRoot = join(__dirname, '../..');
|
|
const composeFile = join(projectRoot, 'compose.yaml');
|
|
|
|
function runSql(sql: string) {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
}
|
|
|
|
function clearCache() {
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch {
|
|
// Cache pool may not exist
|
|
}
|
|
}
|
|
|
|
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
|
const output = execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
|
`require "/app/vendor/autoload.php"; ` +
|
|
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
|
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
|
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
|
`' 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
).trim();
|
|
const [schoolId, academicYearId] = output.split('\n');
|
|
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
|
}
|
|
|
|
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 page.getByRole('button', { name: /se connecter/i }).click();
|
|
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
|
|
}
|
|
|
|
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é');
|
|
});
|
|
});
|