Deux angles morts fermés. 1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js) Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête syntaxiquement invalide passait tous les tests et n'échouait qu'en production — c'est précisément ce qui s'était produit avec crawler_pending. Chaque requête réellement émise par les 28 routes est désormais parsée avec la grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH et les casts par type de resolve-conflict. 48 tests, aucun conteneur. Limite déclarée explicitement : la sémantique n'est pas validée, et deux requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il passe au vert à vide. 2. Supervision des services de fond (migration 015) Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un worker à court de quota Geoapify, restait muet — le seul symptôme était l'absence de données nouvelles, qu'il fallait remarquer soi-même. Chaque service écrit un battement de cœur horaire dans service_health : - crawler : base, FlareSolverr joignable, dernier run terminé < 12 h - geocoder : base, clé Geoapify présente, API joignable, progression < 6 h Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL (> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier contrôle réussi le ferait sinon passer pour sain indéfiniment. Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou silencieux en alerte actionnable. Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle surveille. Toute exception devient un échec de sonde, l'écriture du résultat et la journalisation échouent en silence. Un contrôle de santé qui fait tomber le crawler serait pire que pas de contrôle. Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3) Co-Authored-By: Claude <noreply@anthropic.com>
175 lines
9.6 KiB
Markdown
175 lines
9.6 KiB
Markdown
# 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.
|