Le panneau était découpé par table SQL, pas par question que se pose l'admin. Conséquence : « je constate ici, j'agis dans un autre onglet », et aucun moyen de voir ce qui coince réellement. Redécoupage — un onglet = une question - Pilotage : est-ce que ça tourne ? (fusionne l'ancien « Actions ») - Groupes : trouver et corriger - Localisations : qu'est-ce qui coince dans le géocodage ? ← NOUVEAU - Journal : que s'est-il passé ? - LLM & coûts : combien ça coûte ? Pilotage : chaque chiffre problématique porte son action - Bandeau de santé (groupes, géocodage, file, bloqués, coût) coloré par état - Alertes actionnables : « 12 localisations en erreur » + [Examiner] + [Tout remettre en file], au lieu d'un mur de boutons dans un autre onglet - Déclencheurs de traitements inline, opérations destructives repliées - L'ancien bloc « Éléments de genre » (des centaines de mots-clés jamais consultés) est retiré Localisations : la vue qui manquait totalement L'admin ne disposait que d'actions EN MASSE sur band_locations et d'aucun moyen de voir CE qui échouait. Débloquer un seul lieu supposait de relancer des milliers d'appels Geoapify/Groq facturés. - GET /admin/api/locations liste filtrable (statut, lieu, groupe, pays) - POST /admin/api/locations/:id/requeue relance UNE ligne - PATCH /admin/api/locations/:id saisie manuelle des coordonnées - Diagnostic lisible sans clic : lieu brut, statut, essais, erreur réelle Groupes : recherche d'abord Une barre de recherche et des filtres rapides en chips remplacent les 8 champs texte ; les filtres avancés sont repliés. Accessibilité - Lignes de tableau activables au clavier (role=button, tabindex, Entrée) - Modales : Échap ferme, focus piégé, focus rendu à l'élément d'origine - Contraste : --err (#c61a1a) échouait WCAG AA en texte sur fond sombre (3,4:1). Les messages d'erreur étaient difficiles à lire. Ajout de --err-text / --ok-text (~6,5:1) pour les usages en couleur de texte. Détecté par les nouveaux tests axe sur les vues rendues. - Statuts affichés en français au lieu des valeurs brutes de la base Tests - 36 tests API sur les trois nouveaux endpoints (allowlist de statuts, bornes des coordonnées, audit, 404) - 53 parcours Playwright sur la VRAIE app Fastify + faux pool, dans une topologie identique à la production (statique servi + /admin/* proxifié) - Tests axe sur les vues RENDUES : le test jsdom existant ne voyait que la coquille vide du dashboard, tout étant construit en JavaScript - e2e branché sur le hook pre-push, pas sur `check` : la boucle de développement reste à 6 s, le push coûte 28 s Correction trouvée par les tests Le routeur ne séparait pas la query string du nom de vue : « #/locations?status=error » ne correspondait à aucun alias et retombait sur Pilotage — les liens des alertes ne fonctionnaient pas. Co-Authored-By: Claude <noreply@anthropic.com>
119 lines
4.4 KiB
JavaScript
119 lines
4.4 KiB
JavaScript
/**
|
|
* Serveur de test pour les parcours Playwright.
|
|
*
|
|
* Reproduit la topologie de production : nginx sert les fichiers statiques du
|
|
* dashboard et proxifie `/admin/api/*` et `/admin/auth/*` vers l'API (voir
|
|
* apps/admin/nginx.conf). Ici, un petit serveur HTTP joue le rôle de nginx et
|
|
* transmet à la VRAIE application Fastify — routage, cookies, JWT, bcrypt,
|
|
* validation et génération SQL sont donc ceux de production — avec un faux pool
|
|
* à la place de Postgres.
|
|
*
|
|
* Ce qui est réellement exercé : les parcours d'interface, l'authentification,
|
|
* les états de chargement et d'erreur, l'accessibilité au clavier.
|
|
* Ce qui ne l'est pas : la validité du SQL pour Postgres et le comportement de
|
|
* PostGIS — affaire de tests d'intégration, pas de tests d'interface.
|
|
*/
|
|
import { createServer } from "node:http";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { buildServer } from "../../../api/src/app.js";
|
|
import { makeFakePool, rows } from "../../../api/test/helpers/fakePool.js";
|
|
import { seedHandlers } from "./fixtures.mjs";
|
|
|
|
const SITE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../site");
|
|
|
|
// Secret de session propre aux tests. Sans lui, signAdminSession() lève et la
|
|
// connexion renvoie un 500 opaque — exactement le piège documenté dans
|
|
// infra/.env.example pour la production.
|
|
process.env.ADMIN_JWT_SECRET ||= "secret-e2e-suffisamment-long-pour-passer-32";
|
|
|
|
const MIME = {
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".ico": "image/x-icon",
|
|
".png": "image/png",
|
|
};
|
|
|
|
/** Les mêmes préfixes que ceux proxifiés par nginx.conf. */
|
|
const API_PREFIXES = ["/admin/api/", "/admin/auth/", "/api/"];
|
|
|
|
export async function startTestServer({ port = 0, handlers = seedHandlers() } = {}) {
|
|
const pool = makeFakePool(handlers);
|
|
|
|
const app = await buildServer({
|
|
pool,
|
|
logger: false,
|
|
importToken: "jeton-e2e",
|
|
corsOrigins: ["http://127.0.0.1"],
|
|
seedAdmin: false,
|
|
// Les parcours enchaînent beaucoup d'appels ; les plafonds réels sont
|
|
// vérifiés séparément dans apps/api/test/rateLimit.test.js.
|
|
globalRateLimitMax: 1e9,
|
|
adminRateLimitMax: 1e9,
|
|
authRateLimitMax: 1e9,
|
|
});
|
|
|
|
const front = createServer(async (req, res) => {
|
|
const url = req.url || "/";
|
|
|
|
if (API_PREFIXES.some((p) => url.startsWith(p))) {
|
|
const chunks = [];
|
|
for await (const c of req) chunks.push(c);
|
|
const payload = chunks.length ? Buffer.concat(chunks) : undefined;
|
|
|
|
// `inject()` recalcule content-length à partir du payload : retransmettre
|
|
// celui du client donne un corps tronqué, donc un 400 « champ requis ».
|
|
const headers = { ...req.headers };
|
|
delete headers["content-length"];
|
|
delete headers["transfer-encoding"];
|
|
|
|
// inject() traverse tout le cycle de vie Fastify (hooks, plugins,
|
|
// sérialisation) : c'est la même exécution qu'une requête réseau.
|
|
const reply = await app.inject({ method: req.method, url, headers, payload });
|
|
res.writeHead(reply.statusCode, reply.headers);
|
|
res.end(reply.rawPayload);
|
|
return;
|
|
}
|
|
|
|
const rel = url.split("?")[0].replace(/^\/+/, "") || "index.html";
|
|
const file = path.resolve(SITE, rel);
|
|
if (!file.startsWith(SITE)) {
|
|
res.writeHead(403).end("forbidden");
|
|
return;
|
|
}
|
|
try {
|
|
const buf = await readFile(file);
|
|
res.writeHead(200, { "content-type": MIME[path.extname(file)] || "application/octet-stream" });
|
|
res.end(buf);
|
|
} catch {
|
|
// Application monopage : toute route inconnue retombe sur index.html
|
|
res.writeHead(200, { "content-type": MIME[".html"] });
|
|
res.end(await readFile(path.join(SITE, "index.html")));
|
|
}
|
|
});
|
|
|
|
await new Promise((resolve) => front.listen(port, "127.0.0.1", resolve));
|
|
|
|
return {
|
|
app,
|
|
pool,
|
|
url: `http://127.0.0.1:${front.address().port}`,
|
|
/** Reprogramme les réponses de la base entre deux scénarios. */
|
|
setHandlers: (h) => pool.reset(h),
|
|
close: async () => {
|
|
await new Promise((r) => front.close(r));
|
|
await app.close();
|
|
},
|
|
};
|
|
}
|
|
|
|
export { rows };
|
|
|
|
// Démarrage autonome, utilisé par `webServer` dans playwright.config.js.
|
|
if (process.env.E2E_STANDALONE === "1") {
|
|
const srv = await startTestServer({ port: Number(process.env.E2E_PORT || 4319) });
|
|
console.log(`e2e server: ${srv.url}`);
|
|
}
|