Files
Classeo/frontend/e2e/branding.spec.ts
Mathias STRASSER e745cf326a
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
feat: Calculer automatiquement les moyennes après chaque saisie de notes
Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
2026-04-02 06:45:41 +02:00

359 lines
13 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');
// Clear rate limiter to prevent login throttling across serial tests
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
// 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
}
});
test.beforeEach(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
});
// 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: 60000 }),
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();
});
});