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.
This commit is contained in:
189
frontend/e2e/grade-access-control.spec.ts
Normal file
189
frontend/e2e/grade-access-control.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
|
||||
|
||||
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 ASSIGNED_TEACHER_EMAIL = 'e2e-gac-assigned@example.com';
|
||||
const UNASSIGNED_TEACHER_EMAIL = 'e2e-gac-unassigned@example.com';
|
||||
const REPLACEMENT_TEACHER_EMAIL = 'e2e-gac-replacement@example.com';
|
||||
const PASSWORD = 'GACTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
let evaluationId: string;
|
||||
let classId: string;
|
||||
let subjectId: string;
|
||||
|
||||
async function loginAs(page: import('@playwright/test').Page, email: string) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: /se connecter/i }).click();
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
|
||||
}
|
||||
|
||||
test.describe('Grade Access Control (Story 6.9)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create users
|
||||
createTestUser('ecole-alpha', ASSIGNED_TEACHER_EMAIL, PASSWORD, 'ROLE_PROF');
|
||||
createTestUser('ecole-alpha', UNASSIGNED_TEACHER_EMAIL, PASSWORD, 'ROLE_PROF');
|
||||
createTestUser('ecole-alpha', REPLACEMENT_TEACHER_EMAIL, PASSWORD, 'ROLE_PROF');
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
|
||||
// Deterministic IDs
|
||||
classId = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","gac-class-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
|
||||
subjectId = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","gac-subject-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
|
||||
evaluationId = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","gac-eval-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
|
||||
// Create class and subject
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-GAC-6C', '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 ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-GAC-Maths', 'E2GACMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create one test student
|
||||
createTestUser('ecole-alpha', 'e2e-gac-student@example.com', PASSWORD, 'ROLE_ELEVE --firstName=Léa --lastName=Dupont');
|
||||
|
||||
// Assign student to class
|
||||
runSql(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${academicYearId}', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u WHERE u.email = 'e2e-gac-student@example.com' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT (user_id, academic_year_id) DO NOTHING`
|
||||
);
|
||||
|
||||
// Assign ONLY the assigned teacher
|
||||
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, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u WHERE u.email = '${ASSIGNED_TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create evaluation owned by assigned teacher
|
||||
runSql(
|
||||
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, created_at, updated_at) ` +
|
||||
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'E2E-GAC Contrôle', '2026-04-15', 20, 1.0, 'published', NOW(), NOW() ` +
|
||||
`FROM users u WHERE u.email = '${ASSIGNED_TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create active replacement for the replacement teacher (valid now → +7 days)
|
||||
runSql(
|
||||
`INSERT INTO teacher_replacements (id, tenant_id, replaced_teacher_id, replacement_teacher_id, start_date, end_date, status, reason, created_by, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', ` +
|
||||
`(SELECT id FROM users WHERE email = '${ASSIGNED_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), ` +
|
||||
`(SELECT id FROM users WHERE email = '${REPLACEMENT_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), ` +
|
||||
`NOW() - INTERVAL '1 day', NOW() + INTERVAL '7 days', 'active', 'Maladie', ` +
|
||||
`(SELECT id FROM users WHERE email = '${ASSIGNED_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), ` +
|
||||
`NOW(), NOW() ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Link replacement to the class/subject
|
||||
runSql(
|
||||
`INSERT INTO replacement_classes (replacement_id, class_id, subject_id) ` +
|
||||
`SELECT tr.id, '${classId}', '${subjectId}' ` +
|
||||
`FROM teacher_replacements tr ` +
|
||||
`JOIN users u ON tr.replacement_teacher_id = u.id ` +
|
||||
`WHERE u.email = '${REPLACEMENT_TEACHER_EMAIL}' AND tr.tenant_id = '${TENANT_ID}' AND tr.status = 'active' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test('AC1: assigned teacher can access grade grid', async ({ page }) => {
|
||||
await loginAs(page, ASSIGNED_TEACHER_EMAIL);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
||||
|
||||
// Should see the grade grid with at least one student
|
||||
const gradeGrid = page.locator('.grade-grid');
|
||||
await expect(gradeGrid).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should see student name
|
||||
await expect(page.locator('.student-name')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AC1: unassigned teacher cannot access grade grid', async ({ page }) => {
|
||||
await loginAs(page, UNASSIGNED_TEACHER_EMAIL);
|
||||
|
||||
// Navigate to the evaluation's grade page
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
||||
|
||||
// Backend returns 403, frontend shows "Évaluation non trouvée"
|
||||
await expect(page.getByText(/non trouvée/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Grade grid should NOT be visible
|
||||
await expect(page.locator('.grade-grid')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('AC1: active replacement teacher can access grade grid', async ({ page }) => {
|
||||
await loginAs(page, REPLACEMENT_TEACHER_EMAIL);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
||||
|
||||
// Should see the grade grid
|
||||
const gradeGrid = page.locator('.grade-grid');
|
||||
await expect(gradeGrid).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should see student name
|
||||
await expect(page.locator('.student-name')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AC1: expired replacement teacher cannot access grade grid', async ({ page }) => {
|
||||
// Terminate the replacement to simulate expiry
|
||||
runSql(
|
||||
`UPDATE teacher_replacements SET status = 'ended', end_date = NOW() - INTERVAL '1 day', ended_at = NOW(), updated_at = NOW() ` +
|
||||
`WHERE replacement_teacher_id = (SELECT id FROM users WHERE email = '${REPLACEMENT_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') ` +
|
||||
`AND tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
|
||||
await loginAs(page, REPLACEMENT_TEACHER_EMAIL);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
||||
|
||||
// Backend returns 403, frontend shows "Évaluation non trouvée"
|
||||
await expect(page.getByText(/non trouvée/i)).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Grade grid should NOT be visible
|
||||
await expect(page.locator('.grade-grid')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Restaurer le remplacement pour ne pas polluer les autres tests
|
||||
runSql(
|
||||
`UPDATE teacher_replacements SET status = 'active', end_date = NOW() + INTERVAL '7 days', ended_at = NULL, updated_at = NOW() ` +
|
||||
`WHERE replacement_teacher_id = (SELECT id FROM users WHERE email = '${REPLACEMENT_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') ` +
|
||||
`AND tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
clearCache();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user