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
+36
View File
@@ -0,0 +1,36 @@
import { request } from '@playwright/test';
import { comptes, type Compte } from './comptes';
import { API_URL } from './environnement';
// Crée par l'API un compte neuf, encore sur son mot de passe temporaire. Un compte par appel :
// un nouvel essai après échec ne retombe jamais sur un compte déjà activé.
export async function creerCompteTemporaire(role: 'lecteur' | 'operateur'): Promise<Compte> {
const api = await request.newContext({ baseURL: API_URL, ignoreHTTPSErrors: true });
try {
const connexion = await api.post('auth/login', { data: comptes.admin });
if (!connexion.ok()) {
throw new Error(`Connexion administrateur refusée : ${connexion.status()}`);
}
const { access_token: jeton } = (await connexion.json()) as { access_token: string };
const email = `e2e-${role}-${Date.now()}@enervision.fr`;
const creation = await api.post('users', {
data: { email, role },
headers: { Authorization: `Bearer ${jeton}` },
});
if (!creation.ok()) {
throw new Error(`Création du compte refusée : ${creation.status()}`);
}
const { temporary_password: password } = (await creation.json()) as {
temporary_password: string;
};
return { email, password };
} finally {
await api.dispose();
}
}
export function nouveauMotDePasse(): string {
return `E2e-${Math.random().toString(36).slice(2, 14)}-Aa1!`;
}
+22
View File
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs';
export interface Compte {
email: string;
password: string;
}
interface ComptesDeTest {
admin: Compte;
lecteur: Compte;
operateur: Compte;
}
function lireComptes(): ComptesDeTest {
const chemin = process.env.E2E_COMPTES;
if (!chemin) {
throw new Error('E2E_COMPTES doit désigner le JSON écrit par scripts/comptes-test.sh');
}
return JSON.parse(readFileSync(chemin, 'utf-8')) as ComptesDeTest;
}
export const comptes = lireComptes();
+3
View File
@@ -0,0 +1,3 @@
export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:4200';
export const API_URL = new URL('/api/v1/', BASE_URL).toString();
export const MAILPIT_URL = process.env.E2E_MAILPIT_URL ?? 'http://localhost:8025';
+37
View File
@@ -0,0 +1,37 @@
import { expect, request } from '@playwright/test';
import { MAILPIT_URL } from './environnement';
interface Recherche {
messages: { ID: string }[];
}
// Le courriel part en tâche de fond après la réponse de l'API : on l'attend dans Mailpit.
export async function jetonDeReinitialisation(email: string): Promise<string> {
const mailpit = await request.newContext({ baseURL: MAILPIT_URL });
try {
let identifiant = '';
await expect
.poll(
async () => {
const reponse = await mailpit.get('/api/v1/search', { params: { query: `to:${email}` } });
const { messages } = (await reponse.json()) as Recherche;
identifiant = messages[0]?.ID ?? '';
return identifiant;
},
{ message: `aucun courriel reçu pour ${email}`, timeout: 15_000 },
)
.not.toBe('');
const message = (await (await mailpit.get(`/api/v1/message/${identifiant}`)).json()) as {
Text: string;
};
const jeton = /reset-password\?token=([^\s"<>&]+)/.exec(message.Text)?.[1];
if (!jeton) {
throw new Error('lien de réinitialisation introuvable dans le courriel');
}
return decodeURIComponent(jeton);
} finally {
await mailpit.dispose();
}
}
+30
View File
@@ -0,0 +1,30 @@
import { expect, type Browser, type Page } from '@playwright/test';
import type { Compte } from './comptes';
import { BASE_URL } from './environnement';
export async function seConnecter(page: Page, compte: Compte): Promise<void> {
await page.goto('/login');
await page.getByLabel('Email').fill(compte.email);
await page.getByLabel('Mot de passe').fill(compte.password);
await page.getByRole('button', { name: 'Se connecter' }).click();
}
// Pourquoi : une session par fichier, pas par test. Rejouer un même cookie de refresh dans deux
// contextes révoque toute sa famille (ADR 0002), d'où ni `storageState` partagé ni reconnexions.
export async function ouvrirSession(browser: Browser, compte: Compte): Promise<Page> {
const contexte = await browser.newContext({
baseURL: BASE_URL,
ignoreHTTPSErrors: true,
locale: 'fr-FR',
timezoneId: 'Europe/Paris',
});
const page = await contexte.newPage();
await seConnecter(page, compte);
await expect(page).toHaveURL(/\/dashboard$/);
return page;
}
export async function fermerSession(page: Page): Promise<void> {
await page.context().close();
}