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); // Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) 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 — unique to this spec to avoid cross-spec collisions const ADMIN_EMAIL = 'e2e-imgrights-admin@example.com'; const ADMIN_PASSWORD = 'ImgRightsAdmin123'; const STUDENT_EMAIL = 'e2e-imgrights-student@example.com'; const STUDENT_PASSWORD = 'ImgRightsStudent123'; 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('Image Rights 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); 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 in all environments } }); // Helper to login as admin 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: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } // Helper to login as student async function loginAsStudent(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(STUDENT_EMAIL); await page.locator('#password').fill(STUDENT_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } /** * Waits for the image rights page to finish loading. * * After hydration, the page either shows the student tables (sections with * h2 headings) or the empty state. Waiting for one of these ensures the * component is interactive and API data has been fetched. */ async function waitForPageLoaded(page: import('@playwright/test').Page) { await expect( page.getByRole('heading', { name: /droit à l'image/i }) ).toBeVisible({ timeout: 15000 }); // Wait for either the stats bar (data loaded) or empty state (no students) await expect( page.locator('.stats-bar') .or(page.locator('.empty-state')) ).toBeVisible({ timeout: 15000 }); } // ============================================================================ // [P1] Page loads with correct structure // ============================================================================ test('[P1] page loads with correct structure', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/image-rights`); await waitForPageLoaded(page); // Title await expect( page.getByRole('heading', { name: /droit à l'image/i }) ).toBeVisible(); // Subtitle await expect( page.getByText(/consultez et gérez les autorisations/i) ).toBeVisible(); // Status filter dropdown const filterSelect = page.locator('#filter-status'); await expect(filterSelect).toBeVisible(); // Verify filter options const options = filterSelect.locator('option'); await expect(options.filter({ hasText: /tous les statuts/i })).toHaveCount(1); await expect(options.filter({ hasText: /^Autorisé$/ })).toHaveCount(1); await expect(options.filter({ hasText: /^Refusé$/ })).toHaveCount(1); await expect(options.filter({ hasText: /^Non renseigné$/ })).toHaveCount(1); // Search input await expect( page.locator('input[type="search"]') ).toBeVisible(); // Export CSV button await expect( page.getByRole('button', { name: /exporter csv/i }) ).toBeVisible(); // Filter and reset buttons await expect( page.getByRole('button', { name: /^filtrer$/i }) ).toBeVisible(); await expect( page.getByRole('button', { name: /réinitialiser/i }) ).toBeVisible(); // At least one student section should be visible (pagination may hide the other on page 1) await expect( page.getByRole('heading', { name: /élèves autorisés/i }) .or(page.getByRole('heading', { name: /élèves non autorisés/i })) .first() ).toBeVisible(); // Stats bar await expect(page.locator('.stats-bar')).toBeVisible(); }); // ============================================================================ // [P1] Filter by status works // ============================================================================ test('[P1] filter by status works', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/image-rights`); await waitForPageLoaded(page); // Select "Autorisé" in the status filter await page.locator('#filter-status').selectOption('authorized'); // Click "Filtrer" to apply await page.getByRole('button', { name: /^filtrer$/i }).click(); // Wait for the page to reload with filtered data await expect(page).toHaveURL(/status=authorized/); await waitForPageLoaded(page); // After filtering by "Autorisé", either: // - The stats bar shows with 0 unauthorized, OR // - The empty state shows (no authorized students found) const statsBarVisible = await page.locator('.stats-bar').isVisible(); if (statsBarVisible) { const dangerCount = await page.locator('.stat-count.stat-danger').count(); if (dangerCount > 0) { const unauthorizedCount = await page.locator('.stat-count.stat-danger').textContent(); expect(parseInt(unauthorizedCount ?? '0', 10)).toBe(0); } // No stat-danger element means no unauthorized students — correct after filtering by "Autorisé" } else { await expect(page.locator('.empty-state')).toBeVisible(); } // Reset filters to restore original state await page.getByRole('button', { name: 'Réinitialiser', exact: true }).click(); await waitForPageLoaded(page); // URL should no longer contain status filter expect(page.url()).not.toContain('status='); }); // ============================================================================ // [P1] Status update via dropdown // ============================================================================ test('[P1] status update via dropdown', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/image-rights`); await waitForPageLoaded(page); // Find the first student's action select (in either authorized or unauthorized table) const firstActionSelect = page.locator('td[data-label="Actions"] select').first(); await expect(firstActionSelect).toBeVisible({ timeout: 10000 }); // Get the current value const currentValue = await firstActionSelect.inputValue(); // Pick a different status to switch to const newValue = currentValue === 'authorized' ? 'refused' : 'authorized'; // Change the status await firstActionSelect.selectOption(newValue); // Success message should appear await expect( page.locator('.alert-success') ).toBeVisible({ timeout: 10000 }); await expect( page.locator('.alert-success') ).toContainText(/statut mis à jour/i); // Restore original status to leave test data clean // Need to re-locate because the student may have moved to a different section const restoredSelect = page.locator('td[data-label="Actions"] select').first(); await restoredSelect.selectOption(currentValue); await expect( page.locator('.alert-success') ).toBeVisible({ timeout: 10000 }); }); // ============================================================================ // [P1] Export CSV button triggers download // ============================================================================ test('[P1] export CSV button triggers download', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/image-rights`); await waitForPageLoaded(page); // Listen for download event const downloadPromise = page.waitForEvent('download', { timeout: 15000 }); // Click the export button await page.getByRole('button', { name: /exporter csv/i }).click(); // Verify the download starts const download = await downloadPromise; expect(download.suggestedFilename()).toBe('droits-image.csv'); // Success message should appear await expect( page.locator('.alert-success') ).toBeVisible({ timeout: 10000 }); await expect( page.locator('.alert-success') ).toContainText(/export csv/i); }); // ============================================================================ // [P2] Text search filters students // ============================================================================ test('[P2] text search filters students', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/image-rights`); await waitForPageLoaded(page); // Get the total student count before searching const totalBefore = await page.locator('.stat-count.stat-total').textContent(); const totalBeforeNum = parseInt(totalBefore ?? '0', 10); // Type a search term that matches the test student email prefix const searchInput = page.locator('input[type="search"]'); await searchInput.fill('e2e-imgrights'); // Wait for the debounced search to apply (300ms debounce + some margin) await page.waitForTimeout(500); // The URL should be updated with the search parameter await expect(page).toHaveURL(/search=e2e-imgrights/); // The filtered count should be less than or equal to the total // and specifically should find our test student const totalAfter = await page.locator('.stat-count.stat-total').textContent(); const totalAfterNum = parseInt(totalAfter ?? '0', 10); expect(totalAfterNum).toBeGreaterThanOrEqual(1); expect(totalAfterNum).toBeLessThanOrEqual(totalBeforeNum); // Clear the search await searchInput.clear(); await page.waitForTimeout(500); // Total should be restored expect(page.url()).not.toContain('search='); }); // ============================================================================ // [P2] Unauthorized role redirected // ============================================================================ test('[P2] student role cannot access image rights page', async ({ page }) => { await loginAsStudent(page); await page.goto(`${ALPHA_URL}/admin/image-rights`); // Admin guard in +layout.svelte redirects non-admin users to /dashboard await page.waitForURL(/\/dashboard/, { timeout: 30000 }); expect(page.url()).toContain('/dashboard'); }); });