From 82d6bdafbaac9dcd98aefcaa1287996e1570b45b Mon Sep 17 00:00:00 2001 From: DevOps Engineer Date: Mon, 27 Apr 2026 14:26:46 +0200 Subject: [PATCH] =?UTF-8?q?HRT-43=20=E2=80=94=20Test=20int=C3=A9gration=20?= =?UTF-8?q?ml=5Fpredictions=5Fcache=20:=20z=C3=A9ro=20NULL=20hippodrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pytest.ini | 1 + tests/test_ml_cache_integrity.py | 300 +++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 tests/test_ml_cache_integrity.py diff --git a/pytest.ini b/pytest.ini index f5cd4bf..64ad661 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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 diff --git a/tests/test_ml_cache_integrity.py b/tests/test_ml_cache_integrity.py new file mode 100644 index 0000000..8b13711 --- /dev/null +++ b/tests/test_ml_cache_integrity.py @@ -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}." + )