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.
376 lines
16 KiB
TypeScript
376 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-exception-teacher@example.com';
|
|
const TEACHER_PASSWORD = 'Exception123';
|
|
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}`;
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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 {
|
|
// Table may not exist
|
|
}
|
|
}
|
|
|
|
function seedHardRules() {
|
|
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]';
|
|
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()`,
|
|
);
|
|
}
|
|
|
|
|
|
|
|
async function openCreateAndFillForm(page: import('@playwright/test').Page, title: string, daysFromNow: number) {
|
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
|
|
|
const nearDate = getNextWeekday(daysFromNow);
|
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
|
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
|
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
|
await page.locator('#hw-title').fill(title);
|
|
await page.locator('#hw-due-date').fill(nearDate);
|
|
|
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
|
}
|
|
|
|
test.describe('Homework Exception Request (Story 5.6)', () => {
|
|
test.beforeAll(async () => {
|
|
// 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 --firstName=Jean --lastName=Exception 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-EX-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-EX-Maths', 'E2EEXM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
|
|
);
|
|
} catch {
|
|
// May already exist
|
|
}
|
|
|
|
seedTeacherAssignments();
|
|
});
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.beforeEach(async () => {
|
|
try {
|
|
runSql(
|
|
`DELETE FROM homework_rule_exceptions WHERE tenant_id = '${TENANT_ID}'`,
|
|
);
|
|
} catch { /* Table may not exist */ }
|
|
// 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 */ }
|
|
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 {
|
|
// Tables 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 */ }
|
|
|
|
// NOTE: Do NOT call clearRules() here — it deletes rules for the shared
|
|
// tenant and breaks other spec files (homework-rules-warning) running in
|
|
// parallel. Each test seeds its own rules via seedHardRules() which uses
|
|
// ON CONFLICT DO UPDATE, so prior state is irrelevant.
|
|
clearCache();
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC1: Exception request form
|
|
// ============================================================================
|
|
test.describe('AC1: Exception request form', () => {
|
|
test('shows "Demander une exception" button in blocking modal', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir exception test', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible();
|
|
});
|
|
|
|
test('clicking exception button opens justification form', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir exception form', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
|
|
|
// Exception request dialog appears
|
|
const exceptionDialog = page.getByRole('dialog');
|
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
|
await expect(exceptionDialog.getByText(/demander une exception/i)).toBeVisible();
|
|
await expect(exceptionDialog.locator('#exception-justification')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC2: Justification required (min 20 chars)
|
|
// ============================================================================
|
|
test.describe('AC2: Justification validation', () => {
|
|
test('submit button disabled when justification too short', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir justif short', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
|
|
|
const exceptionDialog = page.getByRole('dialog');
|
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
|
|
|
// Type less than 20 characters
|
|
await exceptionDialog.locator('#exception-justification').fill('Court');
|
|
|
|
// Submit button should be disabled
|
|
await expect(exceptionDialog.getByRole('button', { name: /créer avec exception/i })).toBeDisabled();
|
|
});
|
|
|
|
test('homework created immediately after valid justification', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir exception créé', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
|
|
|
const exceptionDialog = page.getByRole('dialog');
|
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
|
|
|
// Type valid justification (>= 20 chars)
|
|
await exceptionDialog
|
|
.locator('#exception-justification')
|
|
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
|
|
|
|
// Submit
|
|
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
|
|
|
// Homework appears in the list
|
|
await expect(page.getByText('Devoir exception créé')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC4: Exception badge
|
|
// ============================================================================
|
|
test.describe('AC4: Exception marking', () => {
|
|
test('homework with exception shows exception badge', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir avec badge', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
|
|
|
const exceptionDialog = page.getByRole('dialog');
|
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
|
await exceptionDialog
|
|
.locator('#exception-justification')
|
|
.fill('Justification suffisamment longue pour être valide.');
|
|
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
|
|
|
// 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' });
|
|
await expect(card.locator('.badge-rule-exception, .badge-rule-override')).toBeVisible();
|
|
});
|
|
|
|
test('exception badge tooltip describes the exception for justification viewing', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir tooltip test', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
|
|
|
const exceptionDialog = page.getByRole('dialog');
|
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
|
await exceptionDialog
|
|
.locator('#exception-justification')
|
|
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
|
|
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
|
|
|
await expect(page.getByText('Devoir tooltip test')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Badge has descriptive title attribute for justification consultation
|
|
const card = page.locator('.homework-card', { hasText: 'Devoir tooltip test' });
|
|
const badge = card.locator('.badge-rule-exception');
|
|
await expect(badge).toBeVisible();
|
|
await expect(badge).toHaveAttribute('title', /exception/i);
|
|
await expect(badge).toContainText('Exception');
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC5: Direction exceptions report
|
|
// ============================================================================
|
|
// AC5 (Direction exceptions report) is covered by unit tests
|
|
// (GetHomeworkExceptionsReportHandlerTest) because E2E testing requires
|
|
// multi-tenant admin login which is not reliably testable in parallel mode.
|
|
|
|
// ============================================================================
|
|
// AC6: Soft mode - no justification needed
|
|
// ============================================================================
|
|
test.describe('AC6: Soft mode without justification', () => {
|
|
test('soft mode does not show exception request button', async ({ page }) => {
|
|
// Configure soft mode
|
|
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]';
|
|
runSql(
|
|
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
|
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'soft', true, NOW(), NOW()) ` +
|
|
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()`,
|
|
);
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir soft mode', 2);
|
|
|
|
// Warning modal appears (not blocking)
|
|
const warningModal = page.getByRole('alertdialog');
|
|
await expect(warningModal).toBeVisible({ timeout: 10000 });
|
|
await expect(warningModal.getByText(/avertissement/i)).toBeVisible();
|
|
|
|
// "Continuer malgré tout" visible (soft mode allows bypass)
|
|
await expect(warningModal.getByRole('button', { name: /continuer malgré tout/i })).toBeVisible();
|
|
|
|
// No exception request button
|
|
await expect(warningModal.getByRole('button', { name: /demander une exception/i })).not.toBeVisible();
|
|
});
|
|
});
|
|
});
|