BUG — _parse_json perdait les réponses contenant un objet imbriqué
Le repli utilisé quand le modèle enrobe son JSON de prose s'appuyait sur
`\{[^}]+\}`, qui s'arrête au PREMIER `}`. Dès que la réponse contenait un objet
imbriqué (ex. {"city": "Oslo", "meta": {"confidence": 0.9}}), la capture était
tronquée donc invalide : _parse_json renvoyait None, le lieu comptait comme
« aucune ville trouvée », consommait un essai LLM et finissait par basculer en
'manual'. Aucune erreur levée — défaut silencieux, sur un appel facturé.
Le re.DOTALL passé en argument était de surcroît inopérant, `[^}]` matchant
déjà les retours à la ligne : signe que l'intention était `.`.
Corrigé en `\{.*\}` glouton, du premier `{` au dernier `}`.
TESTS (23) — helpers purs de groq_worker, jusqu'ici sans couverture
- _parse_json : JSON propre, enrobé de prose, imbriqué, multiligne, et tous
les cas inexploitables — qui doivent rendre None sans jamais lever, sous
peine d'interrompre le worker.
- _input_hash : c'est la clé du cache LLM, donc ce qui décide de rappeler Groq
ou non. Vérifié qu'elle normalise casse et espaces (sinon on repaie des
appels déjà faits) et qu'elle distingue modèle, lieu et pays.
- _cost et PRICING : un modèle absent de PRICING serait facturé au tarif de
repli sans qu'on le remarque, faussant le coût affiché dans l'admin.
- _apply_clean_query : appelée pour de vrai avec un curseur factice plutôt que
de réécrire la concaténation dans le test.
conftest reproduit le sys.path du conteneur (src/ ET racine de l'app) :
worker.py et groq_worker.py importent `parser` sans préfixe parce qu'ils sont
lancés en `python src/worker.py`.
Vérification écartée : `COUNTRY_NAMES.get(code, iso2)` retombe sur le code brut
pour un ISO2 inconnu (« Oslo, ZZ »). Mon test l'avait pris pour un bug ; c'est
volontaire — mieux vaut un code qu'aucun contexte pays. Documenté.
Deux tests tautologiques écrits puis supprimés avant commit : ils comparaient
deux f-strings identiques et ne vérifiaient rien.
Co-Authored-By: Claude <noreply@anthropic.com>
BUG trouvé par un test de propriété
parse_location_text("0/0") produisait deux lignes identiques. band_locations
impose UNIQUE (ma_id, step_order, location_raw) : le doublon était absorbé par
le ON CONFLICT DO NOTHING de l'enqueue, mais il faussait les compteurs et n'a
aucun sens métier — une étape ne se déroule pas deux fois au même endroit.
Déduplication par dict.fromkeys (ordre préservé) + tests de régression.
Contre-exemple minimal trouvé en 150 tirages, aucun test par l'exemple ne
l'aurait deviné.
SCRAPER HTML (42 tests) — module le plus exposé du crawler, aucun test
jusqu'ici. C'est lui qui extrait genre, statut, thèmes, line-up et dates. Si
Metal Archives change son HTML, il renvoie des champs vides et le crawler
enregistre des fiches creuses SANS lever d'erreur : la panne invisible, donc
la plus coûteuse.
Couvre les trois orthographes de « Lyrical themes » observées chez MA, les
variantes de « label » et « formed in », le regroupement du line-up par
section, l'audit trail, et surtout la dégradation : une page vide ou
malformée ne doit jamais lever et doit rendre une structure complète.
COUCHE RÉSEAU (57 tests) — ma_http et flaresolverr, sans une seule requête
réelle. Ce qui est testé, c'est la logique AUTOUR du réseau : quand réessayer,
quand abandonner, comment extraire les données d'une réponse enveloppée par
FlareSolverr.
Enjeu concret : ces bornes conditionnent le nombre de requêtes envoyées à
Metal Archives — une boucle de retry mal bornée nous ferait bannir. Vérifié
que retries=2 donne exactement 3 tentatives, qu'un 500 n'est PAS réessayé
(contrairement à 403/429/503, qui justifient une session Chrome neuve), et
que les appels de préchauffage Cloudflare sont comptés à part.
PROPRIÉTÉS GÉNÉRALISÉES
- Python (Hypothesis) : parseur de localisations, requêtes de repli,
centroïdes. Invariants — jamais d'exception, structure toujours complète,
aucun lieu vide, aucun doublon, nombre de requêtes facturées borné.
- Frontend (fast-check) : échappement HTML (une faille XSS, pas un défaut
cosmétique — tout le rendu passe par innerHTML), filtrage tri-état, tri
sans effet de bord ni perte d'éléments, clés de coordonnées.
max_examples fixé à 150 côté Python : ces tests étaient devenus le chemin
critique de `check` (11 s). 150 tirages suffisaient à trouver « 0/0 ».
Tests : 621 JS + 61 intégration, 162 Python, 89 Playwright. check à 9 s.
Co-Authored-By: Claude <noreply@anthropic.com>
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>