# Qualité, tests et CI/CD ## Le modèle en une phrase Coolify redéploie sur webhook à **chaque** push (`dev` → dev.metalfrom.eu, `main` → metalfrom.eu). Rien ne s'interpose. La porte de qualité est donc **locale**, dans le hook `pre-push`. ``` git push ──▶ [pre-push : npm run check + test:e2e] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy ↑ 6 s + 22 s, bloquant ``` Deux granularités volontaires : `check` (6 s) tourne pendant qu'on code, les parcours Playwright (22 s) seulement au moment de pousser. Mettre l'e2e dans `check` ferait passer la boucle de 6 à 22 s pour un gain marginal — la plupart des régressions tombent déjà dans les tests unitaires. ## Installation (une fois par clone) ```bash npm install npm run hooks:install # active .githooks/pre-push pip install -r requirements-dev.txt ``` ## Commandes et temps mesurés | Commande | Ce que ça fait | Froid | Incrémental | |---|---|---|---| | `npm run check` | **La porte** : ESLint + tsc + Ruff + 620 tests | **6 s** | — | | `npm run check:sequential` | Idem, en série (pour isoler un échec) | 14 s | — | | `npm run test:mutation` | Mutation, logique pure (352 mutants) | 33 s | ~5 s | | `npm run test:mutation:full` | Mutation, toute l'API (2159 mutants) | ~5 min | **8 s** | | `npm run test:e2e` | 85 parcours Playwright (dashboard admin) | 26 s | — | | `npm run test:e2e:install` | Récupère Chromium (clone neuf) | — | — | | `npm run check:full` | `check` + e2e + audits + mutation complète | — | ~1 min | | `npm run check:clean` | Purge les caches ESLint / tsc | — | — | ### Où passe le temps La commande de la boucle de développement, c'est `npm run check` : **6 s**, tests compris. C'est elle qui tourne cent fois par jour, et elle est passée de 17 s à 6 s par trois mesures successives : | Levier | Gain | |---|---| | Les 5 vérifications en parallèle au lieu de `&&` (elles sont indépendantes, et l'enchaînement npm coûtait 7 démarrages) | 14 s → 6 s | | Cache ESLint (`--cache`) et compilation incrémentale tsc | 16 s → 14 s | | Chemin critique restant : vitest 4,9 s, dont ~2 s de mise en place jsdom | — | La mutation n'est pas une commande de boucle courte : - `test:mutation` (352 mutants) : 33 s à froid, ~5 s ensuite. - `test:mutation:full` (1511 mutants) : 3 min 40 **une seule fois**, puis **12 s** après une édition. Le fichier incrémental vit dans `reports/`, qui est gitignoré : un clone neuf paie donc le run complet une fois. **Ce qui a été essayé et rejeté sur mesure** (les intuitions se sont trompées plus d'une fois — tout est re-mesurable) : | Piste | Résultat | |---|---| | Découper la mutation en 5 processus Stryker parallèles | **262 s contre 235 s** : chaque processus repaie son bac à sable et son dry-run. Retiré. | | Monter `concurrency` de 6 à 16 | Aucun effet : ~8,5 mutants/s dans tous les cas. Le débit est limité par l'orchestration mono-processus de Stryker, pas par le CPU. | | Séparer les tests API / frontend en deux processus vitest | Aucun gain : 4,9 s dans les deux cas, jsdom domine. | | Pool vitest `threads` sur la **suite complète** | 18,8 s contre 3,5 s (jsdom pénalisé). Défaut conservé. | | Pool vitest `threads` sur la **config de mutation** (sans jsdom) | Overhead d'amorçage 12 844 ms → **1 592 ms**. Adopté. | **Ce qui n'a délibérément pas été fait** : réduire le périmètre muté. Descendre sous ~1500 mutants sur l'API reviendrait à ne plus mesurer grand-chose, et un score flatteur obtenu en retirant les mutants gênants est pire que pas de score. Le plancher de ~3 min à froid est celui de Stryker sur 1511 mutants, assumé. ## Ce que couvrent les tests | Batterie | Où | Approche | |---|---|---| | **Unitaires** | `apps/api/test/validate.test.js` | Validation pure : bbox, limit/offset, coordonnées, années, recherche | | **Routes / intégration** | `publicRoutes*.test.js`, `adminRoutes*.test.js` | `fastify.inject()` + faux pool. Gating d'auth, allowlists de colonnes, forme du SQL, transactions | | **Sécurité** | `adminAuth.test.js`, `rateLimit.test.js` | JWT forgé, `alg:none`, expiration, anti-énumération, verrouillage de compte, plafonds de débit par IP et par jeton | | **Annulation** | `cancellation.test.js` + `apps/crawler/tests/test_cancellation.py` | Les deux moitiés du protocole coopératif | | **Accessibilité** | `apps/web/test/a11y.test.js` | axe-core + jsdom sur le HTML statique | | **Frontend** | `apps/web/test/pure.test.js` | Filtrage, tri, échappement HTML | | **Méta** | `apps/api/test/harness.test.js` | Vérifie le faux pool lui-même | | **Python** | `apps/geocoder/tests/`, `apps/crawler/tests/` | Parseur de localisations, annulation | | **Parcours (e2e)** | `apps/admin/test/e2e/admin.spec.js` | Playwright sur la VRAIE app Fastify + faux pool : connexion, recherche, édition, déblocage d'une localisation, annulation d'un traitement | | **a11y des vues rendues** | `apps/admin/test/e2e/a11y.spec.js` | axe-core dans un vrai navigateur — contraste inclus, ce que jsdom ne sait pas calculer | | **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 68 % (API complète) | | **Syntaxe SQL** | `apps/api/test/sqlSyntax.test.js` | Chaque requête réellement émise est parsée avec la grammaire PostgreSQL — sans conteneur | | **Santé des services** | `apps/crawler/tests/test_health.py` | Sondes du crawler et du geocoder : base, dépendances externes, fraîcheur des données | ## Principe des tests **Aucun test ne monte de conteneur, ne compile, ni ne touche le réseau.** Deux choix structurants côté API : 1. **Une app Fastify par fichier de test, pas par cas.** `buildServer()` coûte 14 ms ; à raison d'une construction par `it()`, ces 14 ms étaient multipliés par des milliers d'exécutions en mutation. Les tests partagent une app et reprogramment un faux pool via `pool.reset()` (`test/helpers/testApp.js`). 2. **Plafonds de débit paramétrables.** Conséquence du point 1 : une app partagée accumule l'état du limiteur. Les valeurs réelles de production sont donc vérifiées séparément dans `rateLimit.test.js`, pour que cette paramétrisation ne crée pas d'angle mort sur une protection de sécurité. ## Pourquoi deux profils de mutation La mutation prouve qu'un test **échoue** quand le code casse — la couverture ne dit que « cette ligne a été exécutée ». - `test:mutation` (validate.js, adminAuth.js) : **82 %**. Logique pure, score interprétable, assez rapide pour être lancé souvent. - `test:mutation:full` (+ adminRoutes.js, app.js) : **68 %**. Les handlers de route plafonnent structurellement plus bas — beaucoup de mutants portent sur des messages de log ou des branches `catch` dont l'observable exact n'a pas d'importance. Utile en audit, pas en boucle courte. Les seuils d'échec (70 % et 55 %) sont des cliquets anti-régression, pas des objectifs. ### Ce que les survivants ont révélé La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une passe dessus a montré que **tous les filtres de `/admin/api/bands` étaient non testés** (sept mutants survivants par drapeau booléen) — précisément ceux dont dépendent les filtres rapides du dashboard. Les tests écrits pour combler ce trou ont mis au jour un bug réel : `genre` n'était pas tronqué, contrairement aux autres champs de recherche. Le score est passé de 64 % à 68 % en écrivant des tests, pas en retirant des mutants. `reports/mutation-full.json` contient le détail exploitable ; le script de tri utilisé est décrit dans l'historique Git. ## Les parcours e2e `apps/admin/test/e2e/server.mjs` reproduit la topologie de production : un serveur HTTP sert les fichiers statiques et proxifie `/admin/*` vers la VRAIE application Fastify (comme `apps/admin/nginx.conf`), avec un faux pool à la place de Postgres. Sont donc réellement exercés : le routage Fastify, les cookies, le JWT, bcrypt, la validation des entrées et la génération SQL. Ne le sont pas : la validité du SQL pour Postgres et le comportement de PostGIS — affaire de tests d'intégration, pas de tests d'interface. Démarrage ~1 s, aucun conteneur. Une session est enregistrée une fois (`auth.setup.js`) et partagée : sans ça, chacun des 53 scénarios repayait un aller-retour de connexion complet. 4 workers est l'optimum mesuré (8 et 12 sont plus lents — contention au démarrage des navigateurs). ### Ce qui n'est pas couvert - Règles a11y exigeant un moteur de rendu sur le **site public** : le test jsdom ne calcule pas les styles. Le dashboard admin, lui, est couvert par axe dans un vrai navigateur. - `migrate.js` : s'exécute au démarrage du conteneur et appelle `process.exit`. - Crawler et workers de géocodage : le parseur, l'annulation et les contrôles de santé sont couverts ; le reste est de l'I/O réseau et base. - La **sémantique** SQL (noms de colonnes, types, comportement PostGIS) : le parseur ne valide que la syntaxe. Deux requêtes bâties sur `jsonb_build_object` (`/admin/api/activity` et l'agrégation des clusters) ne sont même pas parsables — c'est déclaré explicitement dans le test, et c'est le prix assumé de ne pas monter de Postgres. - `apps/web/site/pure.js` est testé mais **exclu de la mutation** : il est chargé via `fs` + `vm` (comme le fait le navigateur), donc l'instrumentation Stryker ne l'atteindrait pas et afficherait un score faussement parfait. ## Forgejo Actions `.forgejo/workflows/ci.yml` existe mais **ne tourne pas** : aucun runner `act_runner` n'est enregistré sur le VPS. Le fichier est prêt si tu en ajoutes un. Il est volontairement informatif — il ne bloque pas le déploiement.