HRT-43 — Test intégration ml_predictions_cache : zéro NULL hippodrome
- Ajout tests/test_ml_cache_integrity.py : 7 tests integration vérifiant que hippodrome, race_label et heure ne sont pas NULL pour la date courante - Ajout marqueur 'integration' dans pytest.ini - Connexion DB en lecture seule (mode=ro) pour protection prod - Support variable d'env TEST_DATE et TURF_DB_PATH - Tests skippés proprement si job 19h30 n'a pas encore tourné - Validé sur les données 2026-04-26 : 7/7 PASSED (1005 lignes, 0 NULL) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -10,3 +10,4 @@ markers =
|
|||||||
load: Tests de charge Locust
|
load: Tests de charge Locust
|
||||||
security: Tests de sécurité
|
security: Tests de sécurité
|
||||||
smoke: Tests rapides de smoke
|
smoke: Tests rapides de smoke
|
||||||
|
integration: Tests d'intégration DB et pipeline ML
|
||||||
|
|||||||
300
tests/test_ml_cache_integrity.py
Normal file
300
tests/test_ml_cache_integrity.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
test_ml_cache_integrity.py — Test d'intégration : zéro NULL dans ml_predictions_cache
|
||||||
|
SaaS Turf Prédictions IA
|
||||||
|
Ticket: HRT-43 (suite au fix HRT-41 — métadonnées manquantes dans le cache ML)
|
||||||
|
|
||||||
|
Ces tests vérifient que la table ml_predictions_cache ne contient aucune ligne
|
||||||
|
avec des métadonnées NULL (hippodrome, race_label, heure) pour la date courante,
|
||||||
|
après le job ML de 19h30.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pytest tests/test_ml_cache_integrity.py -v -m integration
|
||||||
|
pytest tests/test_ml_cache_integrity.py -v -m integration --date 2026-04-26
|
||||||
|
|
||||||
|
Variables d'environnement:
|
||||||
|
TURF_DB_PATH : chemin vers turf.db (défaut: /home/h3r7/turf_scraper/turf.db)
|
||||||
|
TEST_DATE : date cible au format YYYY-MM-DD (défaut: date du jour)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
DEFAULT_DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||||
|
DB_PATH = os.environ.get("TURF_DB_PATH", DEFAULT_DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_test_date() -> str:
|
||||||
|
"""Retourne la date cible pour les tests (env TEST_DATE ou date du jour)."""
|
||||||
|
env_date = os.environ.get("TEST_DATE", "")
|
||||||
|
if env_date:
|
||||||
|
try:
|
||||||
|
datetime.strptime(env_date, "%Y-%m-%d")
|
||||||
|
return env_date
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"TEST_DATE invalide : '{env_date}'. Format attendu : YYYY-MM-DD"
|
||||||
|
)
|
||||||
|
return date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fixture : connexion DB en lecture seule
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def db_connection():
|
||||||
|
"""
|
||||||
|
Connexion SQLite en mode lecture seule (uri=True + ?mode=ro).
|
||||||
|
Garantit qu'aucune modification accidentelle de la DB de prod n'est possible.
|
||||||
|
"""
|
||||||
|
db_path = Path(DB_PATH)
|
||||||
|
if not db_path.exists():
|
||||||
|
pytest.skip(
|
||||||
|
f"Base de données introuvable : {DB_PATH}. "
|
||||||
|
"Définir TURF_DB_PATH ou vérifier le chemin."
|
||||||
|
)
|
||||||
|
|
||||||
|
uri = f"file:{db_path.as_posix()}?mode=ro"
|
||||||
|
conn = sqlite3.connect(uri, uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def target_date():
|
||||||
|
"""Date cible pour les tests (date du jour ou TEST_DATE)."""
|
||||||
|
return _get_test_date()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Tests d'intégration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestMlCacheNullIntegrity:
|
||||||
|
"""
|
||||||
|
Vérifie qu'après le job ML de 19h30, la table ml_predictions_cache
|
||||||
|
ne contient aucune métadonnée NULL pour la date courante.
|
||||||
|
|
||||||
|
Régression testée : HRT-41 (Fix #17 — métadonnées manquantes dans le cache ML)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_table_exists(self, db_connection):
|
||||||
|
"""Vérifie que la table ml_predictions_cache existe dans la DB."""
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT name FROM sqlite_master "
|
||||||
|
"WHERE type='table' AND name='ml_predictions_cache'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
assert row is not None, (
|
||||||
|
"La table ml_predictions_cache est introuvable dans la base de données. "
|
||||||
|
"Vérifier que le job ML a bien créé la table."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rows_exist_for_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Vérifie que des prédictions existent pour la date cible.
|
||||||
|
|
||||||
|
Ce test passe en skip si aucune ligne n'existe (ex: avant le job 19h30).
|
||||||
|
Il échoue uniquement si le job a manifestement tourné mais a laissé 0 lignes.
|
||||||
|
"""
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
count = cursor.fetchone()["cnt"]
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Ce test doit être exécuté après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zero_null_hippodrome_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
CRITÈRE D'ACCEPTATION PRINCIPAL (HRT-43) :
|
||||||
|
Vérifie que COUNT(*) WHERE date = today AND hippodrome IS NULL = 0.
|
||||||
|
|
||||||
|
Régression directe du bug HRT-41 : le champ hippodrome était NULL
|
||||||
|
pour toutes les prédictions du cache ML.
|
||||||
|
"""
|
||||||
|
# Vérifier si des données existent avant de tester les NULLs
|
||||||
|
cursor_total = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
total = cursor_total.fetchone()["cnt"]
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||||
|
"WHERE date = ? AND hippodrome IS NULL",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
null_count = cursor.fetchone()["cnt"]
|
||||||
|
|
||||||
|
assert null_count == 0, (
|
||||||
|
f"RÉGRESSION HRT-41 DÉTECTÉE : {null_count} ligne(s) avec hippodrome IS NULL "
|
||||||
|
f"dans ml_predictions_cache pour le {target_date}. "
|
||||||
|
"Le patch de métadonnées n'a pas été appliqué correctement."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zero_null_race_label_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Vérifie que COUNT(*) WHERE date = today AND race_label IS NULL = 0.
|
||||||
|
|
||||||
|
Complément du test hippodrome : vérifie que le libellé de course
|
||||||
|
est bien renseigné pour toutes les prédictions.
|
||||||
|
"""
|
||||||
|
cursor_total = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
total = cursor_total.fetchone()["cnt"]
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||||
|
"WHERE date = ? AND race_label IS NULL",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
null_count = cursor.fetchone()["cnt"]
|
||||||
|
|
||||||
|
assert null_count == 0, (
|
||||||
|
f"ANOMALIE : {null_count} ligne(s) avec race_label IS NULL "
|
||||||
|
f"dans ml_predictions_cache pour le {target_date}. "
|
||||||
|
"Vérifier le pipeline de patch de métadonnées."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zero_null_heure_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Vérifie que COUNT(*) WHERE date = today AND heure IS NULL = 0.
|
||||||
|
|
||||||
|
Vérifie que l'heure de course est bien renseignée pour toutes les prédictions.
|
||||||
|
"""
|
||||||
|
cursor_total = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
total = cursor_total.fetchone()["cnt"]
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||||
|
"WHERE date = ? AND heure IS NULL",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
null_count = cursor.fetchone()["cnt"]
|
||||||
|
|
||||||
|
assert null_count == 0, (
|
||||||
|
f"ANOMALIE : {null_count} ligne(s) avec heure IS NULL "
|
||||||
|
f"dans ml_predictions_cache pour le {target_date}. "
|
||||||
|
"Vérifier le pipeline de patch de métadonnées."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_metadata_coverage_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Test de couverture globale : aucune des trois colonnes critiques
|
||||||
|
(hippodrome, race_label, heure) n'est NULL pour une même ligne.
|
||||||
|
|
||||||
|
Retourne les 5 premières lignes problématiques pour faciliter le débogage.
|
||||||
|
"""
|
||||||
|
cursor_total = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
total = cursor_total.fetchone()["cnt"]
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT id, num_reunion, num_course, horse_name, hippodrome, race_label, heure "
|
||||||
|
"FROM ml_predictions_cache "
|
||||||
|
"WHERE date = ? "
|
||||||
|
"AND (hippodrome IS NULL OR race_label IS NULL OR heure IS NULL) "
|
||||||
|
"LIMIT 5",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
bad_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
assert len(bad_rows) == 0, (
|
||||||
|
f"ANOMALIE : {len(bad_rows)} ligne(s) avec au moins une métadonnée NULL "
|
||||||
|
f"(hippodrome, race_label ou heure) pour le {target_date}.\n"
|
||||||
|
"Exemples de lignes affectées :\n"
|
||||||
|
+ "\n".join(
|
||||||
|
f" - id={r['id']} R{r['num_reunion']}C{r['num_course']} "
|
||||||
|
f"{r['horse_name']} | hippodrome={r['hippodrome']!r} "
|
||||||
|
f"race_label={r['race_label']!r} heure={r['heure']!r}"
|
||||||
|
for r in bad_rows
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_metadata_completeness_summary(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Résumé diagnostique : affiche les statistiques de complétude des métadonnées
|
||||||
|
pour la date cible. Toujours en mode informatif (pas de assertion stricte),
|
||||||
|
utile pour le monitoring et les logs CI.
|
||||||
|
"""
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN hippodrome IS NULL THEN 1 ELSE 0 END) as null_hippodrome,
|
||||||
|
SUM(CASE WHEN race_label IS NULL THEN 1 ELSE 0 END) as null_race_label,
|
||||||
|
SUM(CASE WHEN heure IS NULL THEN 1 ELSE 0 END) as null_heure,
|
||||||
|
COUNT(DISTINCT hippodrome) as distinct_hippodromes,
|
||||||
|
COUNT(DISTINCT race_label) as distinct_race_labels
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ?
|
||||||
|
""",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
total = row["total"]
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Afficher les statistiques (visibles avec pytest -v -s)
|
||||||
|
print(f"\n=== Statistiques ml_predictions_cache pour le {target_date} ===")
|
||||||
|
print(f" Total lignes : {total}")
|
||||||
|
print(f" NULL hippodrome : {row['null_hippodrome']}")
|
||||||
|
print(f" NULL race_label : {row['null_race_label']}")
|
||||||
|
print(f" NULL heure : {row['null_heure']}")
|
||||||
|
print(f" Hippodromes distincts: {row['distinct_hippodromes']}")
|
||||||
|
print(f" Race labels distincts: {row['distinct_race_labels']}")
|
||||||
|
|
||||||
|
# L'assertion ici reste stricte pour hippodrome (bug HRT-41 critique)
|
||||||
|
assert row["null_hippodrome"] == 0, (
|
||||||
|
f"RÉGRESSION HRT-41 : {row['null_hippodrome']}/{total} lignes "
|
||||||
|
f"avec hippodrome IS NULL pour le {target_date}."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user