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.
307 lines
12 KiB
TypeScript
307 lines
12 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}`;
|
|
|
|
// Test credentials
|
|
const ADMIN_EMAIL = 'e2e-guardian-admin@example.com';
|
|
const ADMIN_PASSWORD = 'GuardianTest123';
|
|
const STUDENT_EMAIL = 'e2e-guardian-student@example.com';
|
|
const STUDENT_PASSWORD = 'StudentTest123';
|
|
const PARENT_EMAIL = 'e2e-guardian-parent@example.com';
|
|
const PARENT_PASSWORD = 'ParentTest123';
|
|
const PARENT_FIRST_NAME = 'GuardParent';
|
|
const PARENT_LAST_NAME = 'TestOne';
|
|
const PARENT2_EMAIL = 'e2e-guardian-parent2@example.com';
|
|
const PARENT2_PASSWORD = 'Parent2Test123';
|
|
const PARENT2_FIRST_NAME = 'GuardParent';
|
|
const PARENT2_LAST_NAME = 'TestTwo';
|
|
|
|
let studentUserId: string;
|
|
|
|
/**
|
|
* Extracts the User ID from the Symfony console table output.
|
|
*
|
|
* The create-test-user command outputs a table like:
|
|
* | Property | Value |
|
|
* | User ID | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
|
|
*/
|
|
function extractUserId(output: string): string {
|
|
const match = output.match(/User ID\s+([a-f0-9-]{36})/i);
|
|
if (!match) {
|
|
throw new Error(`Could not extract User ID from command output:\n${output}`);
|
|
}
|
|
return match[1];
|
|
}
|
|
|
|
test.describe('Guardian Management', () => {
|
|
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 student user and capture userId
|
|
const studentOutput = execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
studentUserId = extractUserId(studentOutput);
|
|
|
|
// Create first parent user (with name for search-based linking)
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
// Create second parent user for the max guardians test
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT2_EMAIL} --password=${PARENT2_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT2_FIRST_NAME} --lastName=${PARENT2_LAST_NAME} 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
// Clean up any existing guardian links for this student (DB + cache)
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM student_guardians WHERE student_id = '${studentUserId}'" 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear student_guardians.cache --env=dev 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch {
|
|
// Ignore cleanup errors -- table may not have data yet
|
|
}
|
|
});
|
|
|
|
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 });
|
|
}
|
|
|
|
/**
|
|
* Waits for the guardian section to be fully hydrated (client-side JS loaded).
|
|
*
|
|
* The server renders the section with a "Chargement..." indicator. Only after
|
|
* client-side hydration does the $effect() fire, triggering loadGuardians().
|
|
* When that completes, either the empty-state or the guardian-list appears.
|
|
* Waiting for one of these ensures the component is interactive.
|
|
*/
|
|
async function waitForGuardianSection(page: import('@playwright/test').Page) {
|
|
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
|
|
await expect(
|
|
page.getByText(/aucun parent\/tuteur lié/i)
|
|
.or(page.locator('.guardian-list'))
|
|
).toBeVisible({ timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Opens the add-guardian dialog, searches for a parent by name,
|
|
* selects them from results, and submits.
|
|
* Waits for the success message before returning.
|
|
*/
|
|
async function addGuardianViaDialog(
|
|
page: import('@playwright/test').Page,
|
|
parentSearchTerm: string,
|
|
relationshipType: string
|
|
) {
|
|
await page.getByRole('button', { name: /ajouter un parent/i }).click();
|
|
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
|
|
// Type in search field and wait for results
|
|
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
|
|
await searchInput.fill(parentSearchTerm);
|
|
|
|
// Wait for autocomplete results
|
|
const listbox = dialog.locator('#parent-search-listbox');
|
|
await expect(listbox).toBeVisible({ timeout: 10000 });
|
|
const option = listbox.locator('[role="option"]').first();
|
|
await option.click();
|
|
|
|
// Verify a parent was selected
|
|
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
|
|
|
|
await dialog.getByLabel(/type de relation/i).selectOption(relationshipType);
|
|
await dialog.getByRole('button', { name: 'Ajouter' }).click();
|
|
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
}
|
|
|
|
/**
|
|
* Removes the first guardian in the list using the two-step confirmation.
|
|
* Waits for the success message before returning.
|
|
*/
|
|
async function removeFirstGuardian(page: import('@playwright/test').Page) {
|
|
const guardianItem = page.locator('.guardian-item').first();
|
|
await expect(guardianItem).toBeVisible();
|
|
await guardianItem.getByRole('button', { name: /retirer/i }).click();
|
|
|
|
await expect(guardianItem.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
|
|
await guardianItem.getByRole('button', { name: /oui/i }).click();
|
|
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
}
|
|
|
|
test('[P1] should display empty guardian list for student with no guardians', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
|
|
|
await waitForGuardianSection(page);
|
|
|
|
// Should show the empty state since no guardians are linked
|
|
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible();
|
|
|
|
// The "add guardian" button should be visible
|
|
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible();
|
|
});
|
|
|
|
test('[P1] should link a guardian to a student', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
|
|
|
await waitForGuardianSection(page);
|
|
|
|
// Add the guardian via the dialog (search by email)
|
|
await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
|
|
|
|
// Verify success message
|
|
await expect(page.locator('.alert-success')).toContainText(/parent ajouté/i);
|
|
|
|
// The guardian list should now contain the new item
|
|
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
|
|
const guardianItem = page.locator('.guardian-item').first();
|
|
await expect(guardianItem).toBeVisible();
|
|
await expect(guardianItem).toContainText('Père');
|
|
|
|
// Empty state should no longer be visible
|
|
await expect(page.getByText(/aucun parent\/tuteur lié/i)).not.toBeVisible();
|
|
});
|
|
|
|
test('[P1] should not show already-linked parent in search results', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
|
|
|
await waitForGuardianSection(page);
|
|
|
|
// The parent from the previous test is still linked
|
|
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Open the add-guardian dialog and search for the already-linked parent
|
|
await page.getByRole('button', { name: /ajouter un parent/i }).click();
|
|
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
|
|
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
|
|
await searchInput.fill(PARENT_EMAIL);
|
|
|
|
// The listbox should show "Aucun parent trouvé" since the parent is excluded
|
|
const listbox = dialog.locator('#parent-search-listbox');
|
|
await expect(listbox).toBeVisible({ timeout: 10000 });
|
|
await expect(listbox.getByText(/aucun parent trouvé/i)).toBeVisible();
|
|
|
|
// No option should be selectable
|
|
await expect(listbox.locator('[role="option"]')).toHaveCount(0);
|
|
|
|
// The guardian count should still be 1
|
|
await dialog.locator('.modal-close').click();
|
|
await expect(page.locator('.guardian-item')).toHaveCount(1);
|
|
});
|
|
|
|
test('[P1] should unlink a guardian from a student', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
|
|
|
await waitForGuardianSection(page);
|
|
|
|
// Wait for the guardian list to be loaded (from previous test)
|
|
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Remove the first guardian using the two-step confirmation
|
|
await removeFirstGuardian(page);
|
|
|
|
// Verify success message
|
|
await expect(page.locator('.alert-success')).toContainText(/liaison supprimée/i);
|
|
|
|
// The empty state should return since the only guardian was removed
|
|
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('[P2] should not show add button when maximum guardians reached', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
|
|
|
await waitForGuardianSection(page);
|
|
|
|
// Link first guardian (père) — search by email
|
|
await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
|
|
|
|
// Wait for the add button to still be available after first link
|
|
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible({ timeout: 5000 });
|
|
|
|
// Link second guardian (mère) — search by email
|
|
await addGuardianViaDialog(page, PARENT2_EMAIL, 'mère');
|
|
|
|
// Now with 2 guardians linked, the add button should NOT be visible
|
|
await expect(page.getByRole('button', { name: /ajouter un parent/i })).not.toBeVisible({ timeout: 5000 });
|
|
|
|
// Verify both guardian items are displayed
|
|
await expect(page.locator('.guardian-item')).toHaveCount(2);
|
|
|
|
// Clean up: remove both guardians so the state is clean for potential re-runs
|
|
await removeFirstGuardian(page);
|
|
await expect(page.locator('.guardian-item')).toHaveCount(1, { timeout: 5000 });
|
|
await removeFirstGuardian(page);
|
|
|
|
// Verify empty state returns
|
|
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
|
|
test('[P1] should clear search field when reopening add dialog after successful link', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
|
|
|
await waitForGuardianSection(page);
|
|
|
|
// Link a guardian via dialog (this fills the search, selects, and submits)
|
|
await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
|
|
|
|
// Reopen the add-guardian dialog
|
|
await page.getByRole('button', { name: /ajouter un parent/i }).click();
|
|
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
|
|
|
// The search field should be empty (reset after successful link)
|
|
await expect(dialog.getByRole('combobox', { name: /rechercher/i })).toHaveValue('');
|
|
|
|
// No parent should be marked as selected
|
|
await expect(dialog.getByText(/sélectionné/i)).not.toBeVisible();
|
|
|
|
// Close modal and clean up
|
|
await dialog.locator('.modal-close').click();
|
|
await removeFirstGuardian(page);
|
|
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|