Merge pull request 'HRT-43 — Test intégration ml_predictions_cache : zéro NULL hippodrome' (#5) from feature/HRT-43-ml-cache-null-test into master
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled

This commit was merged in pull request #5.
This commit is contained in:
2026-04-27 15:36:48 +02:00
2 changed files with 301 additions and 0 deletions

View File

@@ -10,3 +10,4 @@ markers =
load: Tests de charge Locust
security: Tests de sécurité
smoke: Tests rapides de smoke
integration: Tests d'intégration DB et pipeline ML

View 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}."
)