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>
6.6 KiB
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)
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) :
-
Une app Fastify par fichier de test, pas par cas.
buildServer()coûte 14 ms. À raison d'une construction parit(), 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 viapool.reset()(test/helpers/testApp.js). -
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.
-
Mutants
StringLiteralexclus. 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. -
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 branchescatchdont 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 appelleprocess.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.jsest testé mais exclu de la mutation : il est chargé viafs+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.