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:
167
frontend/e2e/role-assignment.spec.ts
Normal file
167
frontend/e2e/role-assignment.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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-roles-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'RolesAdmin123';
|
||||
const TARGET_EMAIL = `e2e-roles-target-${Date.now()}@example.com`;
|
||||
const TARGET_PASSWORD = 'RolesTarget123';
|
||||
|
||||
test.describe('Multi-Role Assignment (FR5) [P2]', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
// 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 target user with single role (PROF)
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
});
|
||||
|
||||
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 Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function openRolesModalForTarget(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Search for the target user (paginated list may not show them on page 1)
|
||||
await page.getByRole('searchbox').fill(TARGET_EMAIL);
|
||||
await page.waitForTimeout(500); // debounce
|
||||
|
||||
// Find the target user row and click "Rôles" button
|
||||
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
|
||||
await expect(targetRow).toBeVisible({ timeout: 10000 });
|
||||
await targetRow.getByRole('button', { name: 'Rôles' }).click();
|
||||
|
||||
// Modal should appear
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.locator('#roles-modal-title')).toHaveText('Modifier les rôles');
|
||||
}
|
||||
|
||||
test('[P2] admin can open role modal showing current roles', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Target user email should be displayed in modal
|
||||
await expect(page.locator('.roles-modal-user')).toContainText(TARGET_EMAIL);
|
||||
|
||||
// ROLE_PROF should be checked (current role)
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
|
||||
// Other roles should be unchecked
|
||||
const adminCheckbox = page.locator('.role-checkbox-label', { hasText: 'Directeur' }).locator('input[type="checkbox"]');
|
||||
await expect(adminCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('[P2] admin can assign multiple roles to a user', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Add Vie Scolaire role in addition to PROF
|
||||
const vieScolaireLabel = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' });
|
||||
await vieScolaireLabel.locator('input[type="checkbox"]').check();
|
||||
|
||||
// Both should now be checked
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireLabel.locator('input[type="checkbox"]')).toBeChecked();
|
||||
|
||||
// Save
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
const saveResponse = await saveResponsePromise;
|
||||
expect(saveResponse.status()).toBeLessThan(400);
|
||||
|
||||
// Success message should appear
|
||||
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('[P2] assigned roles persist after page reload', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Both PROF and VIE_SCOLAIRE should still be checked after reload
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('[P2] admin can remove a role while keeping at least one', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Uncheck Vie Scolaire (added in previous test)
|
||||
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||
await vieScolaireCheckbox.uncheck();
|
||||
|
||||
// PROF should still be checked
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireCheckbox).not.toBeChecked();
|
||||
|
||||
// Save
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await saveResponsePromise;
|
||||
|
||||
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('[P2] last role checkbox is disabled to prevent removal', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Only PROF should be checked now (after previous test removed VIE_SCOLAIRE)
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
|
||||
// Last role checkbox should be disabled
|
||||
await expect(profCheckbox).toBeDisabled();
|
||||
|
||||
// "(dernier rôle)" hint should be visible
|
||||
await expect(
|
||||
page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('.role-checkbox-hint')
|
||||
).toContainText('dernier rôle');
|
||||
});
|
||||
|
||||
test('[P2] role modal can be closed with Escape', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
await page.getByRole('dialog').press('Escape');
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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 projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
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 SA_PASSWORD = 'SuperAdmin123';
|
||||
const UNIQUE_SUFFIX = Date.now();
|
||||
|
||||
function getSuperAdminEmail(browserName: string): string {
|
||||
return `e2e-prov-sa-${browserName}@test.com`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
test.beforeAll(async ({}, testInfo) => {
|
||||
const browserName = testInfo.project.name;
|
||||
const saEmail = getSuperAdminEmail(browserName);
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[${browserName}] Failed to create super admin:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
async function loginAsSuperAdmin(
|
||||
page: import('@playwright/test').Page,
|
||||
email: string
|
||||
) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
|
||||
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(SA_PASSWORD);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }),
|
||||
submitButton.click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function navigateToEstablishments(page: import('@playwright/test').Page) {
|
||||
const link = page.getByRole('link', { name: /établissements/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||
link.click()
|
||||
]);
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
async function navigateToCreateForm(page: import('@playwright/test').Page) {
|
||||
const newLink = page.getByRole('link', { name: /nouvel établissement/i });
|
||||
await expect(newLink).toBeVisible({ timeout: 10000 });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }),
|
||||
newLink.click()
|
||||
]);
|
||||
await expect(page.locator('#name')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('Establishment Provisioning (Story 2-17) [P1]', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Subdomain Auto-generation', () => {
|
||||
test('[P1] typing name auto-generates subdomain', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await page.locator('#name').fill('École Saint-Exupéry');
|
||||
|
||||
// Subdomain should be auto-generated: accents removed, spaces→hyphens, lowercase
|
||||
await expect(page.locator('#subdomain')).toHaveValue('ecole-saint-exupery');
|
||||
});
|
||||
|
||||
test('[P2] subdomain suffix .classeo.fr is displayed', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await expect(page.locator('.subdomain-suffix')).toHaveText('.classeo.fr');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create Establishment Flow', () => {
|
||||
const establishmentName = `E2E Test ${UNIQUE_SUFFIX}`;
|
||||
const adminEmailForEstab = `admin-prov-${UNIQUE_SUFFIX}@test.com`;
|
||||
|
||||
test('[P1] submitting form creates establishment and redirects to list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
// Fill in the form
|
||||
await page.locator('#name').fill(establishmentName);
|
||||
await page.locator('#adminEmail').fill(adminEmailForEstab);
|
||||
|
||||
// Subdomain should be auto-generated
|
||||
const subdomain = await page.locator('#subdomain').inputValue();
|
||||
expect(subdomain.length).toBeGreaterThan(0);
|
||||
|
||||
// Submit
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
const apiResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/super-admin/establishments') && resp.request().method() === 'POST'
|
||||
);
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
// Verify API returns establishment in provisioning status
|
||||
const apiResponse = await apiResponsePromise;
|
||||
expect(apiResponse.status()).toBeLessThan(400);
|
||||
const body = await apiResponse.json();
|
||||
expect(body.status).toBe('provisioning');
|
||||
|
||||
// Should redirect back to establishments list
|
||||
await page.waitForURL('**/super-admin/establishments', { timeout: 15000 });
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] created establishment appears in the list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
|
||||
// The establishment created in previous test should be visible
|
||||
await expect(page.locator('table')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('td', { hasText: establishmentName })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] created establishment has a visible status badge', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
|
||||
// Find the row for our establishment
|
||||
const row = page.locator('tr', { has: page.locator(`text=${establishmentName}`) });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Status badge should be visible (provisioning status already verified via API response in creation test)
|
||||
const badge = row.locator('.badge');
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).not.toHaveText('');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Form Validation', () => {
|
||||
test('[P2] submit button disabled with empty fields', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[P2] submit button enabled when all fields filled', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await page.locator('#name').fill('Test School');
|
||||
await page.locator('#adminEmail').fill('admin@test.com');
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('[P2] cancel button returns to establishments list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
const cancelLink = page.getByRole('link', { name: /annuler/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||
cancelLink.click()
|
||||
]);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,16 @@
|
||||
</td>
|
||||
<td class="subdomain-cell">{establishment.subdomain}</td>
|
||||
<td>
|
||||
<span class="badge" class:active={establishment.status === 'active'}>
|
||||
{establishment.status === 'active' ? 'Actif' : 'Inactif'}
|
||||
<span
|
||||
class="badge"
|
||||
class:active={establishment.status === 'active'}
|
||||
class:provisioning={establishment.status === 'provisioning'}
|
||||
>
|
||||
{establishment.status === 'active'
|
||||
? 'Actif'
|
||||
: establishment.status === 'provisioning'
|
||||
? 'Provisioning…'
|
||||
: 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="date-cell">
|
||||
@@ -207,6 +215,11 @@
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.badge.provisioning {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user