Files
Classeo/frontend/e2e/branding.spec.ts
Mathias STRASSER 1db8a7a0b2
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
fix: Corriger les tests E2E après l'introduction du cache-aside paginé
Le commit 23dd717 a introduit un cache Redis (paginated_queries.cache)
pour les requêtes paginées. Les tests E2E qui modifient les données via
SQL direct (beforeAll, cleanup) contournent la couche applicative et ne
déclenchent pas l'invalidation du cache, provoquant des données obsolètes.

De plus, plusieurs problèmes d'isolation entre tests ont été découverts :
- Les tests classes.spec.ts supprimaient les données d'autres specs via
  DELETE FROM school_classes sans nettoyer les FK dépendantes
- Les tests user-blocking utilisaient des emails partagés entre les
  projets Playwright (chromium/firefox/webkit) exécutés en parallèle,
  causant des race conditions sur l'état du compte utilisateur
- Le handler NotifyTeachersPedagogicalDayHandler s'exécutait de manière
  synchrone, bloquant la réponse HTTP pendant l'envoi des emails
- La sélection d'un enseignant remplaçant effaçait l'autre dropdown car
  {#if} supprimait l'option sélectionnée du DOM

Corrections appliquées :
- Ajout de cache:pool:clear après chaque modification SQL directe
- Nettoyage des FK dépendantes avant les DELETE (classes, subjects)
- Emails uniques par projet navigateur pour éviter les race conditions
- Routage de JourneePedagogiqueAjoutee vers le transport async
- Remplacement de {#if} par disabled sur les selects de remplacement
- Recherche par nom sur la page classes pour gérer la pagination
- Patterns toPass() pour la fiabilité Firefox sur les color pickers
2026-03-01 23:33:42 +01:00

336 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);
// 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-branding-admin@example.com';
const ADMIN_PASSWORD = 'BrandingAdmin123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
// Minimal valid 1x1 transparent PNG for logo upload tests
const TEST_LOGO_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
'base64'
);
test.describe('Branding Visual Customization', () => {
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' }
);
// Clean up branding data from previous test runs
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_branding WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up logo files from previous test runs
execSync(
`docker compose -f "${composeFile}" exec -T php sh -c "rm -rf /app/public/uploads/logos/${TENANT_ID}" 2>&1`,
{ encoding: 'utf-8' }
);
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()
]);
}
/**
* Waits for the branding page to finish loading.
*
* After hydration, the page shows the card sections (logo + colors).
* Waiting for the heading and the first .card 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: /identité visuelle/i })
).toBeVisible({ timeout: 15000 });
// Wait for at least one card section to appear (loading finished)
await expect(
page.locator('.card').first()
).toBeVisible({ timeout: 15000 });
}
// ============================================================================
// [P2] Page displays logo and color sections (AC1)
// ============================================================================
test('[P2] page affiche les sections logo et couleurs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Title
await expect(
page.getByRole('heading', { name: /identité visuelle/i })
).toBeVisible();
// Subtitle
await expect(
page.getByText(/personnalisez le logo et les couleurs/i)
).toBeVisible();
// Logo section heading
await expect(
page.getByRole('heading', { name: /logo de l'établissement/i })
).toBeVisible();
// Format info
await expect(
page.getByText(/formats acceptés/i)
).toBeVisible();
// Logo placeholder (no logo initially)
await expect(
page.getByText(/aucun logo configuré/i)
).toBeVisible();
// Upload button
await expect(
page.getByText('Importer un logo')
).toBeVisible();
// Color section heading
await expect(
page.getByRole('heading', { name: /couleur principale/i })
).toBeVisible();
// Color picker and text input
await expect(page.locator('#primaryColorPicker')).toBeVisible();
await expect(page.locator('#primaryColor')).toBeVisible();
// Reset and save buttons
await expect(
page.getByRole('button', { name: /réinitialiser/i })
).toBeVisible();
await expect(
page.getByRole('button', { name: /enregistrer/i })
).toBeVisible();
});
// ============================================================================
// [P1] Changing color updates contrast indicator and preview (AC3)
// ============================================================================
test('[P1] modifier la couleur met à jour l\'indicateur de contraste et l\'aperçu', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Use color picker input for reliable cross-browser reactive updates
// Wrap in toPass() to handle Firefox timing where fill() may not immediately trigger change event
const colorPicker = page.locator('#primaryColorPicker');
// --- Dark blue: passes AA (ratio ~10.3) → "Lisible" ---
await expect(async () => {
await colorPicker.fill('#1e3a5f');
await expect(page.locator('.contrast-badge')).toContainText('Lisible', { timeout: 2000 });
}).toPass({ timeout: 10000 });
await expect(page.locator('.contrast-indicator.pass')).toBeVisible();
await expect(page.locator('.preview-swatch').first()).toBeVisible();
await expect(page.locator('.preview-swatch').first()).toHaveCSS(
'background-color',
'rgb(30, 58, 95)',
{ timeout: 5000 }
);
// --- Yellow: fails AA completely (ratio ~1.07) → "Illisible" ---
await expect(async () => {
await colorPicker.fill('#ffff00');
await expect(page.locator('.contrast-badge')).toContainText('Illisible', { timeout: 2000 });
}).toPass({ timeout: 10000 });
await expect(page.locator('.contrast-indicator.fail')).toBeVisible();
// --- Dark yellow: passes AA Large only (ratio ~3.7) → "Attention" ---
await expect(async () => {
await colorPicker.fill('#8b8000');
await expect(page.locator('.contrast-badge')).toContainText('Attention', { timeout: 2000 });
}).toPass({ timeout: 10000 });
await expect(page.locator('.contrast-indicator.warning')).toBeVisible();
});
// ============================================================================
// [P1] Saving colors applies CSS variables immediately (AC3, AC5)
// ============================================================================
test('[P1] enregistrer les couleurs applique les CSS variables immédiatement', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Set a dark blue color via color picker (more reliable than text input across browsers)
// Wrap in toPass() to handle Firefox timing where fill() may not immediately trigger change event
await expect(async () => {
await page.locator('#primaryColorPicker').fill('#1e3a5f');
await expect(page.getByRole('button', { name: /enregistrer/i })).toBeEnabled({ timeout: 2000 });
}).toPass({ timeout: 10000 });
// Click save and wait for API response
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
);
await page.getByRole('button', { name: /enregistrer/i }).click();
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
// CSS variables applied to document root
const accentPrimary = await page.evaluate(() =>
document.documentElement.style.getPropertyValue('--accent-primary')
);
expect(accentPrimary).toBe('#1E3A5F');
const btnPrimaryBg = await page.evaluate(() =>
document.documentElement.style.getPropertyValue('--btn-primary-bg')
);
expect(btnPrimaryBg).toBe('#1E3A5F');
// Save button should be disabled (no pending changes)
await expect(
page.getByRole('button', { name: /enregistrer/i })
).toBeDisabled();
});
// ============================================================================
// [P2] Upload logo displays preview (AC2)
// ============================================================================
test('[P2] upload logo affiche l\'aperçu', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Initially no logo
await expect(page.getByText(/aucun logo configuré/i)).toBeVisible();
// Trigger file chooser and upload the test PNG
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Importer un logo').click();
const fileChooser = await fileChooserPromise;
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'POST'
);
await fileChooser.setFiles({
name: 'logo.png',
mimeType: 'image/png',
buffer: TEST_LOGO_PNG
});
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/logo mis à jour/i);
// Logo image is now visible
await expect(page.locator('.logo-image')).toBeVisible();
// "Changer le logo" and "Supprimer" buttons visible
await expect(page.getByText('Changer le logo')).toBeVisible();
await expect(
page.getByRole('button', { name: /supprimer/i })
).toBeVisible();
// Placeholder text is gone
await expect(page.getByText(/aucun logo configuré/i)).not.toBeVisible();
});
// ============================================================================
// [P2] Delete logo returns to no-logo state (AC2)
// ============================================================================
test('[P2] supprimer logo revient à l\'état sans logo', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Logo should be visible from previous test
await expect(page.locator('.logo-image')).toBeVisible();
// Accept the confirmation dialog, wait for DELETE response, then click
page.once('dialog', (dialog) => dialog.accept());
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'DELETE'
);
await page.getByRole('button', { name: /supprimer/i }).click();
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/logo supprimé/i);
// Back to placeholder state
await expect(page.getByText(/aucun logo configuré/i)).toBeVisible();
await expect(page.getByText('Importer un logo')).toBeVisible();
});
// ============================================================================
// [P2] Reset restores default theme (AC4)
// ============================================================================
test('[P2] réinitialiser restaure le thème par défaut', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/branding`);
await waitForPageLoaded(page);
// Color should be set from test 3
await expect(page.locator('#primaryColor')).toHaveValue('#1E3A5F');
// Accept the confirmation dialog, wait for PUT response, then click
page.once('dialog', (dialog) => dialog.accept());
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
);
await page.getByRole('button', { name: /réinitialiser/i }).click();
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
// Color input is now empty
await expect(page.locator('#primaryColor')).toHaveValue('');
// CSS variables removed
const accentPrimary = await page.evaluate(() =>
document.documentElement.style.getPropertyValue('--accent-primary')
);
expect(accentPrimary).toBe('');
// Preview swatch should not be visible (no primary color set)
await expect(page.locator('.preview-swatch')).not.toBeVisible();
});
});