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