test(e2e): joue les parcours utilisateur avec Playwright contre la stack déployée

Aucun parcours n'était vérifié de bout en bout : les tests unitaires du frontend simulent l'API,
ceux du backend n'ouvrent jamais de navigateur.

tests/e2e, paquet npm autonome, 18 parcours dans Chromium :
- authentification, premier login, réinitialisation du mot de passe par Mailpit ;
- rôles : lecteur, opérateur, administrateur ;
- sites, recommandations, fil d'alertes.

e2e.yml, appelé par ci.yml, démarre db, mailpit, backend, frontend et proxy avec
docker-compose.prod.yml sur https://localhost, sème demo.sql, crée les comptes et joue la suite.
Il construit au passage les images backend et frontend, que la CI ne construisait jamais.

Un seul worker et une session par fichier : la zone auth de nginx admet 30 connexions par
minute, et rejouer un cookie de refresh dans un second contexte révoque toute la session.

make e2e-install, e2e-prepare et e2e pour le poste ; make help affiche désormais les cibles
dont le nom contient un chiffre.

Closes #46
This commit is contained in:
Johan LEROY
2026-09-23 09:25:37 +02:00
parent a646635b4c
commit a197af91ff
25 changed files with 774 additions and 17 deletions
+42
View File
@@ -0,0 +1,42 @@
import { expect, test, type Page } from '@playwright/test';
import { comptes } from '../support/comptes';
import { fermerSession, ouvrirSession } from '../support/session';
test.describe.configure({ mode: 'serial' });
let page: Page;
const fil = () => page.getByRole('complementary', { name: 'Alertes actives' });
const alertes = () => fil().locator('li.alert-feed__item');
test.beforeAll(async ({ browser }) => {
page = await ouvrirSession(browser, comptes.lecteur);
});
test.afterAll(async () => fermerSession(page));
test('affiche dix alertes, puis les suivantes à la demande', async () => {
await expect(alertes()).toHaveCount(10);
const plus = fil().getByTestId('show-more');
await expect(plus).toBeVisible();
await plus.click();
await expect.poll(async () => alertes().count()).toBeGreaterThan(10);
});
test('filtre par sévérité', async () => {
await fil().getByTestId('severity-filter').selectOption('critical');
await expect(alertes().first()).toBeVisible();
const severites = await alertes().evaluateAll((items) =>
items.map((item) => item.className.includes('alert-feed__item--critical')),
);
expect(severites.every(Boolean)).toBe(true);
});
test('annonce un fil vide quand aucun critère ne correspond', async () => {
await fil().getByTestId('site-filter').selectOption('demo-ecole');
await expect(fil().getByText('Aucune alerte pour ces critères.')).toBeVisible();
await expect(alertes()).toHaveCount(0);
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';
import { comptes } from '../support/comptes';
import { seConnecter } from '../support/session';
test('renvoie vers la connexion sans session', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible();
});
test('refuse des identifiants incorrects', async ({ page }) => {
await seConnecter(page, { email: comptes.lecteur.email, password: 'Mauvais-mot-de-passe-1!' });
await expect(page.getByRole('alert')).toContainText('Email ou mot de passe incorrect.');
await expect(page).toHaveURL(/\/login$/);
});
test('ouvre le tableau de bord, garde la session au rechargement puis la ferme', async ({
page,
}) => {
await seConnecter(page, comptes.lecteur);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
await expect(page.getByRole('region', { name: 'Indicateurs du parc' })).toBeVisible();
await expect(page.getByRole('complementary', { name: 'Alertes actives' })).toBeVisible();
await page.reload();
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
await page.getByRole('button', { name: 'Déconnexion' }).click();
await expect(page).toHaveURL(/\/login$/);
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login$/);
});
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
import { creerCompteTemporaire, nouveauMotDePasse } from '../support/api';
import { jetonDeReinitialisation } from '../support/mailpit';
test('refuse un lien de réinitialisation invalide', async ({ page }) => {
await page.goto('/reset-password?token=lien-invalide');
await expect(page).toHaveURL(/\/login\?motif=lien-expire$/);
await expect(
page.getByText('Ce lien de réinitialisation est invalide ou a expiré.'),
).toBeVisible();
});
test('réinitialise le mot de passe par le lien reçu par courriel', async ({ page }) => {
const compte = await creerCompteTemporaire('lecteur');
await page.goto('/forgot-password');
await page.getByLabel('Email').fill(compte.email);
await page.getByRole('button', { name: 'Envoyer le lien' }).click();
await expect(page.getByText('Si un compte existe pour cet email')).toBeVisible();
const jeton = await jetonDeReinitialisation(compte.email);
await page.goto(`/reset-password?token=${encodeURIComponent(jeton)}`);
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
await page.getByRole('button', { name: 'Valider' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
});
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
import { creerCompteTemporaire, nouveauMotDePasse } from '../support/api';
import { seConnecter } from '../support/session';
test('impose le changement du mot de passe temporaire avant le tableau de bord', async ({
page,
}) => {
const compte = await creerCompteTemporaire('lecteur');
await seConnecter(page, compte);
await expect(page).toHaveURL(/\/change-password$/);
await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible();
await page.getByLabel('Mot de passe actuel').fill(compte.password);
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
await page.getByRole('button', { name: 'Valider' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
});
+32
View File
@@ -0,0 +1,32 @@
import { expect, test, type Page } from '@playwright/test';
import { comptes } from '../support/comptes';
import { fermerSession, ouvrirSession } from '../support/session';
test.describe.configure({ mode: 'serial' });
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await ouvrirSession(browser, comptes.admin);
});
test.afterAll(async () => fermerSession(page));
test('génère les recommandations à partir des alertes', async () => {
await page.goto('/recommendations');
await page.getByTestId('generate').click();
await expect(page.getByRole('alert').filter({ hasText: /alertes? examinées?/ })).toBeVisible();
await expect(page.locator('[id^="alerte-"]').first()).toBeVisible();
await expect(page.locator('.reco__action').first()).not.toBeEmpty();
});
test("isole les recommandations d'une alerte", async () => {
const carte = page.locator('[id^="alerte-"]').first();
const identifiant = (await carte.getAttribute('id'))?.replace('alerte-', '');
expect(identifiant).toBeTruthy();
await page.goto(`/recommendations?alert=${identifiant}`);
await expect(page.getByText(`Alerte n° ${identifiant}`)).toBeVisible();
await expect(page.locator(`#alerte-${identifiant}`)).toBeVisible();
});
+57
View File
@@ -0,0 +1,57 @@
import { expect, test, type Page } from '@playwright/test';
import { comptes } from '../support/comptes';
import { fermerSession, ouvrirSession } from '../support/session';
test.describe('lecteur', () => {
test.describe.configure({ mode: 'serial' });
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await ouvrirSession(browser, comptes.lecteur);
});
test.afterAll(async () => fermerSession(page));
test('ne voit ni la supervision des capteurs ni la génération', async () => {
await expect(page.getByRole('link', { name: 'Voir les sites' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Supervision des capteurs' })).toHaveCount(0);
await page.getByRole('link', { name: 'Recommandations', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Recommandations' })).toBeVisible();
await expect(page.getByTestId('generate')).toHaveCount(0);
});
test('est renvoyé vers la connexion sur la page réservée aux administrateurs', async () => {
await page.goto('/monitoring/sensors');
await expect(page).toHaveURL(/\/login$/);
});
});
test.describe('opérateur', () => {
test('voit le tableau de bord sans la supervision des capteurs', async ({ browser }) => {
const page = await ouvrirSession(browser, comptes.operateur);
await expect(page.getByRole('heading', { name: "Vue d'ensemble" })).toBeVisible();
await expect(page.getByRole('link', { name: 'Supervision des capteurs' })).toHaveCount(0);
await fermerSession(page);
});
});
test.describe('administrateur', () => {
test('suit la santé des capteurs de chaque site', async ({ browser }) => {
const page = await ouvrirSession(browser, comptes.admin);
await page.getByRole('link', { name: 'Supervision des capteurs' }).click();
await expect(page.getByRole('heading', { name: 'Supervision des capteurs' })).toBeVisible();
const cartes = page.getByTestId('site-card');
await expect(cartes.filter({ hasText: 'Siège Part-Dieu' })).toContainText('ok');
await expect(cartes.filter({ hasText: 'Groupe scolaire Gratte-Ciel' })).toContainText(
'degraded',
);
await fermerSession(page);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, test, type Page } from '@playwright/test';
import { comptes } from '../support/comptes';
import { fermerSession, ouvrirSession } from '../support/session';
test.describe.configure({ mode: 'serial' });
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await ouvrirSession(browser, comptes.lecteur);
});
test.afterAll(async () => fermerSession(page));
test('liste les sites du parc', async () => {
await page.getByRole('link', { name: 'Voir les sites' }).click();
await expect(page.getByRole('heading', { name: 'Sites' })).toBeVisible();
await expect(page.getByRole('row', { name: /Siège Part-Dieu/ })).toBeVisible();
});
test('détaille un site : mesure instantanée et historique', async () => {
await page
.getByRole('row', { name: /Siège Part-Dieu/ })
.getByRole('link', { name: 'Détail' })
.click();
await expect(page).toHaveURL(/\/sites\/demo-siege$/);
await expect(page.getByRole('heading', { level: 1, name: 'Siège Part-Dieu' })).toBeVisible();
await expect(page.getByText('Mesure instantanée')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Historique de consommation' })).toBeVisible();
});
test('ouvre les recommandations filtrées sur ce site', async () => {
await page.getByRole('link', { name: 'Voir dans la vue recommandations' }).click();
await expect(page).toHaveURL(/\/recommendations\?site=demo-siege$/);
await expect(page.getByTestId('site-filter')).toHaveValue('demo-siege');
});