Les établissements utilisant le mode "Hard" des règles de devoirs empêchent désormais les enseignants de créer des devoirs hors règles. Contrairement au mode "Soft" (avertissement avec possibilité de passer outre), le mode "Hard" est un blocage strict : même acknowledgeWarning ne permet pas de contourner. L'API retourne 422 (au lieu de 409 pour le soft) avec des dates conformes suggérées calculées via le calendrier scolaire (weekends, fériés, vacances exclus). Le frontend affiche un modal de blocage avec les raisons, des dates cliquables, et une validation client inline qui empêche la soumission de dates non conformes.
348 lines
13 KiB
TypeScript
348 lines
13 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-rules-hard-teacher@example.com';
|
|
const TEACHER_PASSWORD = 'RulesHard123';
|
|
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! };
|
|
}
|
|
|
|
/**
|
|
* Returns a weekday date string (YYYY-MM-DD), N days from now.
|
|
* Skips weekends.
|
|
*/
|
|
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: 30000 }),
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure homework rules with minimum_delay of 7 days in hard mode
|
|
* so that a homework due in 1-2 days triggers a blocking error.
|
|
*/
|
|
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()`,
|
|
);
|
|
}
|
|
|
|
function clearRules() {
|
|
try {
|
|
runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`);
|
|
} catch {
|
|
// Table may not exist
|
|
}
|
|
}
|
|
|
|
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 Rules - Hard Mode Blocking (Story 5.5)', () => {
|
|
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 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-RH-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-RH-Maths', 'E2ERHM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
|
|
);
|
|
} catch {
|
|
// May already exist
|
|
}
|
|
|
|
seedTeacherAssignments();
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
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
|
|
}
|
|
|
|
clearRules();
|
|
clearCache();
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC1: Blocking when hard mode is active
|
|
// ============================================================================
|
|
test.describe('AC1: Hard mode blocks creation', () => {
|
|
test('shows blocking modal instead of warning when hard mode is active', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir hard block', 2);
|
|
|
|
// Blocking modal appears (not warning)
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
await expect(blockedDialog.getByText(/impossible de créer ce devoir/i)).toBeVisible();
|
|
|
|
// No "Continuer malgré tout" button
|
|
await expect(blockedDialog.getByRole('button', { name: /continuer malgré tout/i })).not.toBeVisible();
|
|
});
|
|
|
|
test('cannot bypass hard mode with acknowledgment', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir bypass test', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
// Only "Modifier la date" button, no bypass option
|
|
await expect(blockedDialog.getByRole('button', { name: /modifier la date/i })).toBeVisible();
|
|
await expect(blockedDialog.getByRole('button', { name: /continuer/i })).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC2: Blocking message with reason and suggestions
|
|
// ============================================================================
|
|
test.describe('AC2: Blocking message with suggestions', () => {
|
|
test('shows violation reason in blocking modal', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir raison blocage', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
// Shows the reason
|
|
await expect(blockedDialog.getByText(/au moins/i)).toBeVisible();
|
|
await expect(blockedDialog.getByText(/interdisent la création/i)).toBeVisible();
|
|
});
|
|
|
|
test('shows suggested conforming dates', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir suggestions', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
// Suggested dates section visible
|
|
await expect(blockedDialog.getByText(/dates conformes suggérées/i)).toBeVisible();
|
|
|
|
// At least one suggested date button
|
|
const suggestedGroup = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i });
|
|
await expect(suggestedGroup.getByRole('button').first()).toBeVisible();
|
|
});
|
|
|
|
test('clicking suggested date reopens form with that date', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir select date', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click first suggested date
|
|
const firstSuggested = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i }).getByRole('button').first();
|
|
await firstSuggested.click();
|
|
|
|
// Blocked modal closes, create form reopens
|
|
await expect(blockedDialog).not.toBeVisible({ timeout: 3000 });
|
|
const createDialog = page.getByRole('dialog');
|
|
await expect(createDialog).toBeVisible();
|
|
await expect(createDialog.locator('#hw-due-date')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC3: Exception request information
|
|
// ============================================================================
|
|
test.describe('AC3: Exception request information', () => {
|
|
test('shows exception contact information in blocking modal', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir exception link', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
// Exception information visible
|
|
await expect(blockedDialog.getByText(/exception.*contactez/i)).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// AC4: Calendar with conforming dates
|
|
// ============================================================================
|
|
test.describe('AC4: Calendar enforces conforming dates', () => {
|
|
test('modify date reopens form with conforming minimum date', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir modify date hard', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click "Modifier la date"
|
|
await blockedDialog.getByRole('button', { name: /modifier la date/i }).click();
|
|
|
|
// Form reopens
|
|
await expect(blockedDialog).not.toBeVisible({ timeout: 3000 });
|
|
const createDialog = page.getByRole('dialog');
|
|
await expect(createDialog).toBeVisible();
|
|
|
|
// Date input should have min attribute enforcing conforming dates
|
|
const dateInput = createDialog.locator('#hw-due-date');
|
|
await expect(dateInput).toBeVisible();
|
|
const minValue = await dateInput.getAttribute('min');
|
|
expect(minValue).toBeTruthy();
|
|
});
|
|
|
|
test('homework can be created with compliant date after modification', async ({ page }) => {
|
|
seedHardRules();
|
|
clearCache();
|
|
|
|
await loginAsTeacher(page);
|
|
await navigateToHomework(page);
|
|
await openCreateAndFillForm(page, 'Devoir compliant hard', 2);
|
|
|
|
const blockedDialog = page.getByRole('alertdialog');
|
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click first suggested date
|
|
const firstSuggested = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i }).getByRole('button').first();
|
|
await firstSuggested.click();
|
|
|
|
// Submit with the compliant date
|
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
|
|
|
// Homework created successfully
|
|
await expect(page.getByText('Devoir compliant hard')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
});
|
|
});
|