Corriger à la main les coordonnées d'un groupe écrit un band_locations avec
geocode_provider='admin' et confiance 1.0 : la donnée la plus autoritaire du
système, saisie par un humain, impossible à régénérer. Elle était pourtant la
moins protégée.
Le trigger de la migration 013 supprime toutes les band_locations d'un groupe
dès que son location_text change — override compris. locked_fields ne couvre
que les colonnes de `bands`, jamais les points : une correction disparaissait
donc silencieusement à la première modification du lieu, y compris quand ce
lieu venait d'être verrouillé par l'admin lui-même.
Trois routes de reset l'effaçaient également. Le code raisonnait explicitement
sur la protection des saisies manuelles pour choisir le statut écrit ('done' et
non 'manual', afin que reset-llm ne les emporte pas), mais ni reset-all ni
requeue-all(include_done) n'excluaient le provider : la protection tenait sur
un bouton et manquait sur les deux autres.
Le trigger épargne désormais les points 'admin', et les trois routes les
excluent. Trois tests d'intégration contre un vrai PostGIS couvrent ce trigger
et celui de la migration 016, qui n'en avaient aucun.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Passe critique sur les zones non vérifiées.
BUG — la correction manuelle d'un lieu n'atteignait pas la moitié du site
bands.lat/lon/geom est une dénormalisation du lieu d'origine, maintenue par
sync_bands_primary() dans geocoder/worker.py. La saisie manuelle de
coordonnées (PATCH /admin/api/locations/:id, ajoutée récemment) écrivait
band_locations sans jamais la déclencher : la correction apparaissait sur la
carte — qui lit band_locations — mais jamais dans la liste, les statistiques
ni la heatmap, qui lisent bands. Le groupe restait affiché au mauvais endroit
indéfiniment.
La synchronisation est répliquée en SQL dans la même transaction, avec la même
règle de tri (step_order ASC, id ASC) que le worker Python.
BUG — course entre réplicas au démarrage (migrate.js)
Le script s'exécute au démarrage de CHAQUE conteneur API. Deux réplicas
démarrant ensemble lisaient tous deux schema_migrations vide et appliquaient
les mêmes fichiers en parallèle : au mieux une violation de clé primaire qui
faisait échouer le démarrage, au pire deux ALTER concurrents.
Verrou consultatif pg_advisory_lock, relâché explicitement. migrate.js
n'exécute plus au chargement s'il est importé (nécessaire pour le tester).
CODE MORT — redis
Signalé au tout début, jamais retiré : un conteneur redis + un volume
persistant dans les DEUX composes, sans une seule référence dans le code.
TESTS AJOUTÉS
- Intégration migrations (7 tests) : application sur base vierge, rejouabilité,
ordre lexicographique, relâchement du verrou, échec bruyant sur migration
invalide, et surtout DEUX MIGRATIONS SIMULTANÉES sur une base vierge —
le cas qui motivait le verrou.
- Tests par PROPRIÉTÉS (fast-check, 21 tests) : batterie qui manquait.
Les tests par l'exemple ne couvrent que les cas auxquels on a pensé.
L'invariant central : toute entrée arbitraire produit soit une valeur
normalisée valide, soit une ValidationError — jamais une autre exception,
jamais NaN. C'est ce qui garantit un 400 plutôt qu'un 500. Vérifie aussi
la cohérence offset = (page-1) × pageSize, le domaine des coordonnées,
et qu'aucun caractère de contrôle ne survit à la validation.
Tests : 601 JS + 61 intégration, 63 Python, 89 Playwright
Co-Authored-By: Claude <noreply@anthropic.com>
1. Suite d'intégration SQL (npm run test:integration:full)
Ferme le dernier angle mort : la SÉMANTIQUE du SQL. Le parseur de grammaire ne
voyait pas un nom de colonne inexistant, un type incompatible ou une fonction
PostGIS mal appelée — soit exactement la classe de bugs qui n'apparaissait
qu'en production.
Technique : chaque requête émise par l'application passe par `PREPARE`.
Postgres l'analyse et la planifie entièrement — colonnes, types, opérateurs
jsonb, fonctions PostGIS — SANS l'exécuter ni nécessiter de données. Rapide, et
ça couvre aussi les requêtes que le parseur JS ne sait pas lire :
jsonb_build_object, `jsonb - text[]`, `raw ? 'clé'`, make_interval, l'opérateur
spatial && et l'agrégation en grille des clusters.
Les migrations réelles sont appliquées dans l'ordre réel : une migration
invalide échoue ici, plus au redémarrage du conteneur en production.
54 tests. Exige Docker, donc HORS de `check` et du hook pre-push — la boucle
de développement reste à 6 s. Base jetable en tmpfs (fsync off).
Deux témoins vérifient que le détecteur n'est pas inopérant : une colonne
inexistante et un type incompatible doivent être rejetés.
2. Les boutons de crawl ne mentent plus
La production ne déploie pas le service crawler (docker-compose.yml ne le
contient pas). Les boutons « Crawl incrémental », « Enrichir » et « Crawl
complet » y créaient des demandes que personne ne consommait, avec un libellé
promettant une exécution « sous ~1 min ».
Ils s'appuient désormais sur le battement de cœur : pas de crawler vivant,
boutons désactivés et raison affichée. « Alimenter le géocodage » reste actif,
puisqu'il est traité par le geocoder.
Je n'ai PAS ajouté le crawler au compose de production : ce serait déclencher
du crawl depuis la prod, décision qui n'est pas la mienne.
Régression évitée au passage : re-rendre le bloc de traitements effaçait le
message de retour (« Demande #77 enregistrée »), puisque loadPilotage() est
rappelé après un clic réussi. L'état des boutons est donc mis à jour sans
reconstruire le DOM. Détecté par un test existant.
Correctif : la suite d'intégration était happée par le glob de vitest.config.js
et allongeait `check` de 6 à 22 s en exigeant Docker.
Tests : 580 JS + 54 intégration, 63 Python, 89 Playwright
Co-Authored-By: Claude <noreply@anthropic.com>