Files
Classeo/frontend/e2e/schedule-advanced.spec.ts
Mathias STRASSER dc2be898d5
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
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.
2026-04-16 09:27:25 +02:00

596 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-schedule-admin@example.com';
const ADMIN_PASSWORD = 'ScheduleTest123';
const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com';
const TEACHER_PASSWORD = 'ScheduleTeacher123';
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}"; ` +
`$dns="6ba7b810-9dad-11d1-80b4-00c04fd430c8"; ` +
`$ay="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($dns,"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($ay,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
function cleanupScheduleData() {
try {
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist yet
}
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
// Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// Table may not exist
}
}
function cleanupCalendarEntries() {
try {
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
}
function seedBlockedDate(date: string, label: string, type: string) {
const { academicYearId } = resolveDeterministicIds();
runSql(
`INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, created_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', '${type}', '${date}', '${date}', '${label}', NOW()) ` +
`ON CONFLICT DO NOTHING`
);
}
function getWeekdayInCurrentWeek(isoDay: number): string {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
const target = new Date(monday);
target.setDate(monday.getDate() + (isoDay - 1));
const y = target.getFullYear();
const m = String(target.getMonth() + 1).padStart(2, '0');
const d = String(target.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
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 page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
}
async function waitForScheduleReady(page: import('@playwright/test').Page) {
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({
timeout: 15000
});
// Wait for either the grid or the empty state to appear
await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({
timeout: 15000
});
}
async function chooseScopeAndEdit(page: import('@playwright/test').Page) {
// Scope modal appears - choose "this occurrence"
const scopeModal = page.getByRole('dialog');
await expect(scopeModal).toBeVisible({ timeout: 10000 });
await scopeModal.getByText('Cette occurrence uniquement').click();
}
async function fillSlotForm(
dialog: import('@playwright/test').Locator,
options: {
className?: string;
dayValue?: string;
startTime?: string;
endTime?: string;
room?: string;
} = {}
) {
const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options;
if (className) {
await dialog.locator('#slot-class').selectOption({ label: className });
}
// Wait for assignments to load, then select subject first (filters teachers)
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions.first()).toBeAttached({ timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
// After subject selection, wait for teacher dropdown to be filtered
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime);
await dialog.locator('#slot-end').fill(endTime);
if (room) {
await dialog.locator('#slot-room').fill(room);
}
}
test.describe('Schedule Management - Modification & Conflicts & Calendar (Story 4.1)', () => {
// Tests share database state (same tenant, users, slots) so they must run sequentially
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// 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 teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Clean up stale test data (e.g. from previous runs with wrong school_id)
try {
runSql(`DELETE FROM schedule_slots WHERE class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}'`);
runSql(`DELETE FROM subjects WHERE code IN ('E2SMATH','E2SFRA') AND tenant_id = '${TENANT_ID}'`);
} catch {
// Tables may not exist
}
// Create test classes
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-Schedule-6A', '6ème', 'active', NOW(), NOW())`
);
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-Schedule-5A', '5ème', 'active', NOW(), NOW())`
);
// Create test subjects
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW())`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Français', 'E2SFRA', '#ef4444', 'active', NOW(), NOW())`
);
cleanupScheduleData();
cleanupCalendarEntries();
clearCache();
});
test.beforeEach(async () => {
cleanupScheduleData();
cleanupCalendarEntries();
try {
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
seedTeacherAssignments();
clearCache();
});
// ==========================================================================
// AC3: Slot Modification & Deletion
// ==========================================================================
test.describe('AC3: Slot Modification & Deletion', () => {
test('clicking a slot opens scope modal then edit modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// First create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog);
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Click on the created slot — scope modal should appear
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
await chooseScopeAndEdit(page);
// Edit modal should appear
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
await expect(
editDialog.getByRole('heading', { name: /modifier le créneau/i })
).toBeVisible();
});
test('can delete a slot via edit modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '2', startTime: '14:00', endTime: '15:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Click on the slot — scope modal then edit modal
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
await chooseScopeAndEdit(page);
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Click delete button — opens scope modal again for delete scope
await dialog.getByRole('button', { name: /supprimer/i }).click();
// Scope modal appears for delete action
const scopeModal = page.locator('.modal-scope');
await expect(scopeModal).toBeVisible({ timeout: 10000 });
await scopeModal.getByText('Cette occurrence uniquement').click();
// Confirmation modal should appear
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
// Confirm deletion
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
// Modal should close and slot should disappear
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).not.toBeVisible({ timeout: 10000 });
});
test('can modify a slot and see updated data in grid', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create initial slot with room
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { room: 'A101' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify initial slot with room A101
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await expect(slotCard.getByText('A101')).toBeVisible();
// Click to open scope modal then edit modal
await slotCard.click();
await chooseScopeAndEdit(page);
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(
dialog.getByRole('heading', { name: /modifier le créneau/i })
).toBeVisible();
// Change room to B202
await dialog.locator('#slot-room').clear();
await dialog.locator('#slot-room').fill('B202');
// Submit modification
await dialog.getByRole('button', { name: /modifier/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify success and updated data
await expect(page.getByText('Créneau modifié.')).toBeVisible({ timeout: 5000 });
const updatedSlot = page.locator('.slot-card').first();
await expect(updatedSlot.getByText('B202')).toBeVisible();
});
});
// ==========================================================================
// AC4: Conflict Detection
// ==========================================================================
test.describe('AC4: Conflict Detection', () => {
test('displays conflict warning when creating slot with same teacher at overlapping time', async ({
page
}) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Step 1: Create first slot (class 6A, Wednesday 10:00-11:00)
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '3', startTime: '10:00', endTime: '11:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
// Step 2: Create conflicting slot with DIFFERENT class but SAME teacher at same time
const timeCell2 = page.locator('.time-cell').first();
await timeCell2.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, {
className: 'E2E-Schedule-5A',
dayValue: '3',
startTime: '10:00',
endTime: '11:00'
});
// Submit - should trigger conflict detection
await dialog.getByRole('button', { name: /créer/i }).click();
// Conflict warning should appear inside the dialog
await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 });
await expect(dialog.getByText(/conflits détectés/i)).toBeVisible();
// Force checkbox should be available
await expect(dialog.getByText(/forcer la création/i)).toBeVisible();
// Dialog should still be open (not closed)
await expect(dialog).toBeVisible();
});
test('can force creation despite detected conflict', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Step 1: Create first slot (class 6A, Thursday 14:00-15:00)
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
let dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '4', startTime: '14:00', endTime: '15:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 });
// Step 2: Create conflicting slot with different class, same teacher, same time
const timeCell2 = page.locator('.time-cell').first();
await timeCell2.click();
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, {
className: 'E2E-Schedule-5A',
dayValue: '4',
startTime: '14:00',
endTime: '15:00'
});
// First submit - triggers conflict warning
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog.locator('.alert-warning')).toBeVisible({ timeout: 10000 });
// Check force checkbox
await dialog.locator('.force-checkbox input[type="checkbox"]').check();
// Submit again with force enabled
await dialog.getByRole('button', { name: /créer/i }).click();
// Modal should close - slot created despite conflict
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Success message should appear
await expect(page.getByText('Créneau créé.')).toBeVisible({ timeout: 15000 });
});
});
// ==========================================================================
// AC5: Calendar Respect (Blocked Days)
// ==========================================================================
test.describe('AC5: Calendar Respect', () => {
test('time validation prevents end before start', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Open creation modal
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Set end time before start time
await dialog.locator('#slot-start').fill('10:00');
await dialog.locator('#slot-end').fill('09:00');
// Error message should appear
await expect(
dialog.getByText(/l'heure de fin doit être après/i)
).toBeVisible();
// Submit should be disabled
await expect(dialog.getByRole('button', { name: /créer/i })).toBeDisabled();
});
test('blocked day is visually marked in the grid', async ({ page }) => {
// Seed a holiday on Wednesday of current week
const wednesdayDate = getWeekdayInCurrentWeek(3);
seedBlockedDate(wednesdayDate, 'Jour férié test', 'holiday');
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Wait for the blocked date badge to appear — confirms API data is loaded
await expect(page.getByText('Jour férié test')).toBeVisible({ timeout: 20000 });
// The third day-column (Wednesday) should have the blocked class
const dayColumns = page.locator('.day-column');
await expect(dayColumns.nth(2)).toHaveClass(/day-blocked/, { timeout: 5000 });
});
test('cannot create a slot on a blocked day', async ({ page }) => {
// Seed a vacation on Tuesday of current week
const tuesdayDate = getWeekdayInCurrentWeek(2);
seedBlockedDate(tuesdayDate, 'Vacances test', 'vacation');
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Tuesday column should be blocked
const dayColumns = page.locator('.day-column');
await expect(dayColumns.nth(1)).toHaveClass(/day-blocked/, { timeout: 10000 });
// Attempt to click a time cell in the blocked day — dialog should NOT open
// Use dispatchEvent to bypass pointer-events: none
await dayColumns.nth(1).locator('.time-cell').first().dispatchEvent('click');
const dialog = page.getByRole('dialog');
await expect(dialog).not.toBeVisible({ timeout: 3000 });
});
test('recurring slots do not appear on vacation days in next week (AC4)', async ({
page
}) => {
// Compute next week Thursday (use local format to avoid UTC shift)
const thisThursday = new Date(getWeekdayInCurrentWeek(4) + 'T00:00:00');
const nextThursday = new Date(thisThursday);
nextThursday.setDate(thisThursday.getDate() + 7);
const y = nextThursday.getFullYear();
const m = String(nextThursday.getMonth() + 1).padStart(2, '0');
const d = String(nextThursday.getDate()).padStart(2, '0');
const nextThursdayStr = `${y}-${m}-${d}`;
// Clean calendar entries and seed a vacation on next Thursday only
cleanupCalendarEntries();
seedBlockedDate(nextThursdayStr, 'Vacances AC4', 'vacation');
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a Thursday slot (if none exists) so we have a recurring slot on Thursday
const dayColumns = page.locator('.day-column');
// Check if Thursday column (index 3) already has a slot
const thursdaySlots = dayColumns.nth(3).locator('.slot-card');
const existingCount = await thursdaySlots.count();
if (existingCount === 0) {
// Create a slot on Thursday
const thursdayCell = dayColumns.nth(3).locator('.time-cell').first();
await thursdayCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '4', startTime: '09:00', endTime: '10:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await waitForScheduleReady(page);
}
// Verify slot is visible on current week's Thursday
await expect(dayColumns.nth(3).locator('.slot-card').first()).toBeVisible({
timeout: 10000
});
// Navigate to next week
await page.getByRole('button', { name: 'Semaine suivante' }).click();
await waitForScheduleReady(page);
// Next week Thursday should be blocked
await expect(dayColumns.nth(3)).toHaveClass(/day-blocked/, { timeout: 10000 });
// No slot card should appear on the blocked Thursday
await expect(dayColumns.nth(3).locator('.slot-card')).toHaveCount(0);
});
});
});