metalfrom.eu/README-CI.md
Nicolas Fryder 60074fb015
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(qualité): outillage de test complet, CI locale, annulation réelle des runs
Le dépôt n'avait aucun test, aucun linter, aucune vérification de types.

Outillage
- ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python
- tsc --checkJs sur l'API (pas de TypeScript, juste la vérification)
- Vitest : 401 tests JS ; pytest : 43 tests Python
- Tests de mutation (Stryker), deux profils : logique pure et API complète
- Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook,
  c'est donc la seule porte de qualité avant la mise en ligne
- Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré)

Sécurité
- Injection SQL authentifiée dans resolve-conflict : `field` était interpolé
  dans le SET sans allowlist
- timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401)
- setErrorHandler écrasait tous les 4xx en 500
- .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part
  alors que leur absence casse toute connexion admin

Annulation réelle des crawl_run (migration 014)
- L'API posait status='error' sans que le crawler en sache rien : le process
  continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur)
- Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler
  écrit lui-même status='cancelled'

Cohérence géographique (migration 014)
- Le trigger 013 supprimait les band_locations sans purger le point dénormalisé
- L'édition admin de lat/lon n'atteignait jamais band_locations : la carte
  ignorait la correction. Override step_order = -1, dans une transaction

Corrections
- limit/offset NaN → 500 au lieu de 400
- OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie)
- listen() sans catch, cast ::text en dur sur les colonnes numériques
- /admin/api/logs ne renvoyait pas sa pagination
- a11y : sélecteur de langue annoncé comme liste vide (role=option manquant)

Nettoyage
- apps/web/quizz-site supprimé (sans rapport avec le projet)
- Code mort : openModal(), LANG_NAMES, double import, variables inutilisées
- .dockerignore ajoutés ; node_modules racine n'était pas gitignoré

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 10:05:40 +02:00

134 lines
6.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 ──▶ [hook pre-push : npm run check] ──▶ Forgejo ──▶ webhook ──▶ Coolify redeploy
↑ ~15 s, bloquant
```
## 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 + Ruff + tsc + tous les tests | ~15 s | — |
| `npm test` | 401 tests JS | 3,5 s | — |
| `npm run test:py` | 43 tests Python | 1 s | — |
| `npm run lint` / `lint:py` | ESLint / Ruff | 3 s / 1 s | — |
| `npm run typecheck` | `tsc --checkJs` sur l'API | 6 s | — |
| `npm run test:mutation` | Mutation, logique pure (352 mutants) | 34 s | ~5 s |
| `npm run test:mutation:full` | Mutation, toute l'API (1511 mutants) | 5 min | 13 s |
| `npm run check:full` | `check` + audits de dépendances + mutation complète | — | ~1 min |
### Où passe le temps, et pourquoi c'est acceptable
La commande de la boucle de développement, c'est `npm run check` : **17 s**,
et elle inclut déjà les 401 tests, ESLint, Ruff et tsc. C'est elle qui tourne
cent fois par jour.
La mutation n'est pas une commande de boucle courte. Son coût se lit ainsi :
- `test:mutation` (352 mutants) : 34 s à froid, ~5 s ensuite. Assez rapide pour
être lancée avant chaque commit qui touche la validation ou l'auth.
- `test:mutation:full` (1511 mutants) : 5 min **une seule fois** (clone neuf ou
après avoir modifié les quatre fichiers mutés d'un coup). Après une édition
normale d'un seul fichier : **13 s**, mesuré.
Le fichier incrémental vit dans `reports/`, qui est gitignoré : un clone neuf
paie donc les 5 minutes une fois. C'est assumé — c'est un audit, pas un test.
**Ce qui n'a délibérément pas été fait pour aller plus vite** : réduire encore
le périmètre muté. Descendre sous ~1500 mutants sur l'API reviendrait à ne plus
mesurer grand-chose, et un score de mutation flatteur obtenu en retirant les
mutants gênants est pire qu'une absence de score.
## 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 |
| **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 65 % (API complète) |
## Performance : les décisions et leurs mesures
Le principe : **aucun test ne monte de conteneur, ne compile, ni ne touche le réseau.**
Quatre optimisations, toutes mesurées (les intuitions non vérifiées se sont
révélées fausses au moins une fois) :
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 ~12 000 exécutions pendant un run de mutation.
Les tests partagent une app et reprogramment un pool via `pool.reset()`
(`test/helpers/testApp.js`).
2. **Config Vitest dédiée à la mutation.**
Stryker recharge les fichiers de test à chaque mutant. En ne déclarant que
ceux qui couvrent le code muté (et surtout pas le test a11y, qui initialise
jsdom en ~2 s), le profil pur est passé de **1 min 23 à 34 s**.
3. **Mutants `StringLiteral` exclus.**
Les handlers sont à ~80 % du SQL en template literals. Muter le contenu
d'une chaîne SQL ne mesure rien. Les retirer a fait passer le profil complet
de 7 min 50 à 5 min — et le score de 42 % à 65 %, parce que le bruit
disparaissait du dénominateur.
4. **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 vérifiées séparément dans
`rateLimit.test.js`, pour que la paramétrisation ne crée pas d'angle mort.
Ce qui a été **essayé et rejeté sur mesure** : le pool Vitest `threads` avec
`isolate: false`, réputé plus rapide, donne **18,8 s contre 3,5 s** ici (la
mise en place de jsdom est pénalisée). Le défaut (`forks`) est conservé.
## 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) : **65 %**. 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 qui n'est pas couvert
- Règles a11y exigeant un moteur de rendu (contraste, cibles tactiles) : jsdom
ne calcule pas de styles. À compléter par un audit Lighthouse manuel.
- `migrate.js` : s'exécute au démarrage du conteneur et appelle `process.exit`.
- Crawler et workers de géocodage : seuls le parseur et l'annulation sont
couverts, le reste est de l'I/O réseau et base.
- `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.