From 701660ce83328192141da00b6d792160378e779f Mon Sep 17 00:00:00 2001 From: DevOps Engineer Date: Wed, 29 Apr 2026 16:56:35 +0200 Subject: [PATCH] fix(HRT-81): enregistrer history_bp dans api_v1/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajouter import de history_bp depuis .routes.history - Ajouter app.register_blueprint(history_bp) dans register_api_v1() - Corriger le docstring du module pour lister /api/v1/history - Tests: 19/19 passed (GET /api/v1/history — auth, free/premium/pro, validation, pagination) Co-Authored-By: Paperclip --- api_v1/__init__.py | 3 + api_v1/routes/history.py | 212 ++++++++++++++++++++ tests/test_history.py | 407 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 622 insertions(+) create mode 100644 api_v1/routes/history.py create mode 100644 tests/test_history.py diff --git a/api_v1/__init__.py b/api_v1/__init__.py index f5fd791..813b59a 100644 --- a/api_v1/__init__.py +++ b/api_v1/__init__.py @@ -15,6 +15,7 @@ Registers sub-blueprints: /api/v1/metrics — métriques perf ML (premium+) /api/v1/billing/ — Stripe checkout, portal, webhook, status /api/v1/user/ — config utilisateur, alertes Telegram (premium+) + /api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité) /api/v1/docs — Swagger UI (via flasgger, registered on app) """ @@ -29,6 +30,7 @@ from .routes.export import export_bp from .routes.metrics import metrics_bp from .routes.billing import billing_bp from .routes.user import user_bp +from .routes.history import history_bp # Master blueprint that aggregates all sub-routes under /api/v1 api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") @@ -45,3 +47,4 @@ def register_api_v1(app): app.register_blueprint(metrics_bp) app.register_blueprint(billing_bp) app.register_blueprint(user_bp) + app.register_blueprint(history_bp) diff --git a/api_v1/routes/history.py b/api_v1/routes/history.py new file mode 100644 index 0000000..f56fe33 --- /dev/null +++ b/api_v1/routes/history.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +History routes for API v1. + +GET /api/v1/history — Historique des prédictions avec filtre date range, + limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité) + +Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro) +""" + +from datetime import datetime, timedelta +from flask import Blueprint, jsonify, request, g + +from api_v1.utils import ( + get_db, + table_exists, + internal_error, + bad_request, + forbidden, + get_pagination_params, + paginate_query, +) +from auth import jwt_required_middleware + +history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history") + +# ────────────────────────────────────────────────────────────── +# Plan limits (days of history accessible; None = unlimited) +# ────────────────────────────────────────────────────────────── + +HISTORY_DAYS = { + "free": 7, + "premium": 90, + "pro": None, # illimité +} + +# Fallback for unknown plans: treat like free +_DEFAULT_LIMIT = 7 + + +def _get_plan_max_days(plan: str): + """Return the max history days allowed for the given plan, or default.""" + return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT) + + +def _parse_date(date_str: str, param_name: str): + """Parse YYYY-MM-DD date string, raise ValueError with context on failure.""" + try: + return datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + raise ValueError( + f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'" + ) + + +# ────────────────────────────────────────────────────────────── +# GET /api/v1/history +# ────────────────────────────────────────────────────────────── + + +@history_bp.route("", methods=["GET"]) +@jwt_required_middleware +def get_history(): + """ + Historique des prédictions ML avec filtre date range + --- + tags: + - Historique + summary: | + Historique des prédictions sur une plage de dates. + Limite selon le plan : + - Free : 7 derniers jours + - Premium : 90 derniers jours + - Pro : illimité + security: + - Bearer: [] + parameters: + - name: start + in: query + type: string + format: date + description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan) + - name: end + in: query + type: string + format: date + description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui) + - name: limit + in: query + type: integer + default: 50 + description: Nombre de résultats par page (max 500) + - name: offset + in: query + type: integer + default: 0 + responses: + 200: + description: Historique des prédictions ML + 400: + description: Paramètre de date invalide + 401: + description: Token invalide ou manquant + 403: + description: Plage de dates hors limite du plan — upgrade requis + """ + user = getattr(g, "current_user", None) + if not user: + return jsonify({"error": "Non authentifié"}), 401 + + plan = user.get("plan", "free") + today = datetime.now().date() + max_days = _get_plan_max_days(plan) + + # ── Parse end date ──────────────────────────────────────── + end_str = request.args.get("end", today.isoformat()) + try: + end_date = _parse_date(end_str, "end") + except ValueError as exc: + return bad_request(str(exc)) + + # ── Parse start date ───────────────────────────────────── + if max_days is not None: + default_start = today - timedelta(days=max_days - 1) + else: + # Pro: default to 30 days back when no start provided + default_start = today - timedelta(days=29) + + start_str = request.args.get("start", default_start.isoformat()) + try: + start_date = _parse_date(start_str, "start") + except ValueError as exc: + return bad_request(str(exc)) + + # ── Validate ordering ───────────────────────────────────── + if start_date > end_date: + return bad_request( + f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})" + ) + + # ── Enforce plan window ─────────────────────────────────── + if max_days is not None: + earliest_allowed = today - timedelta(days=max_days - 1) + if start_date < earliest_allowed: + return forbidden( + message=( + f"Historique limité à {max_days} jours pour le plan '{plan}'. " + f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. " + f"Passez à un plan supérieur pour accéder à un historique plus long." + ), + required_plans=["premium", "pro"] if plan == "free" else ["pro"], + current_plan=plan, + ) + + # ── Pagination ──────────────────────────────────────────── + limit, offset = get_pagination_params(default_limit=50, max_limit=500) + + # ── Query ───────────────────────────────────────────────── + conn = get_db() + try: + if not table_exists(conn, "ml_predictions_cache"): + return jsonify( + { + "status": "ok", + "plan": plan, + "start": start_date.isoformat(), + "end": end_date.isoformat(), + "history": [], + **paginate_query([], 0, limit, offset), + } + ), 200 + + count_row = conn.execute( + """SELECT COUNT(*) as cnt + FROM ml_predictions_cache + WHERE date >= ? AND date <= ?""", + (start_date.isoformat(), end_date.isoformat()), + ).fetchone() + total = count_row["cnt"] if count_row else 0 + + sql = """ + SELECT + id, date, horse_name, prob_top1, prob_top3, + ml_score, race_label, hippodrome, heure, is_value_bet + FROM ml_predictions_cache + WHERE date >= ? AND date <= ? + ORDER BY date DESC, ml_score DESC + LIMIT ? OFFSET ? + """ + rows = conn.execute( + sql, + (start_date.isoformat(), end_date.isoformat(), limit, offset), + ).fetchall() + + history = [dict(r) for r in rows] + + return jsonify( + { + "status": "ok", + "plan": plan, + "history_limit_days": max_days, + "start": start_date.isoformat(), + "end": end_date.isoformat(), + "history": history, + **paginate_query(history, total, limit, offset), + } + ), 200 + + except Exception as exc: + return internal_error(str(exc)) + finally: + conn.close() diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..877282a --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +Tests for GET /api/v1/history — HRT-81 +Historique limité/illimité selon plan (Free/Premium/Pro) + +Run with: + cd /home/h3r7/turf_saas + source venv/bin/activate + python -m pytest tests/test_history.py -v +""" + +import json +import os +import sys +import sqlite3 +import tempfile +from datetime import datetime, timedelta + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Use an isolated temp DB for these tests +_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_tmp_db.close() +os.environ["TURF_SAAS_DB"] = _tmp_db.name +os.environ["JWT_SECRET_KEY"] = "test-history-secret-key" + +from app_v1 import create_app +from auth_db import init_auth_tables + + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + +TODAY = datetime.now().date() + + +def days_ago(n: int) -> str: + return (TODAY - timedelta(days=n)).isoformat() + + +def auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# ────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def app(): + application = create_app() + application.config["TESTING"] = True + application.config["JWT_SECRET_KEY"] = "test-history-secret-key" + return application + + +@pytest.fixture(scope="module") +def client(app): + return app.test_client() + + +@pytest.fixture(scope="module") +def seeded_db(): + """ + Seed the test DB: + - Create ml_predictions_cache with rows spanning 120 days back + - Create users for free/premium/pro plans + """ + db_path = os.environ["TURF_SAAS_DB"] + conn = sqlite3.connect(db_path) + + # Create ml_predictions_cache table if absent + conn.execute(""" + CREATE TABLE IF NOT EXISTS ml_predictions_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + horse_name TEXT, + prob_top1 REAL, + prob_top3 REAL, + ml_score REAL, + race_label TEXT, + hippodrome TEXT, + heure TEXT, + is_value_bet INTEGER DEFAULT 0 + ) + """) + + # Seed rows at: 1, 6, 7, 8, 30, 89, 90, 91, 100 days ago + offsets = [1, 6, 7, 8, 30, 89, 90, 91, 100] + for offset in offsets: + d = days_ago(offset) + conn.execute( + """INSERT INTO ml_predictions_cache + (date, horse_name, prob_top1, prob_top3, ml_score, race_label, hippodrome, heure, is_value_bet) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (d, f"Cheval_{offset}j", 0.5, 0.8, 0.75, f"R1C1", "PARIS", "14:00", 0), + ) + + conn.commit() + conn.close() + return db_path + + +@pytest.fixture(scope="module") +def auth_tokens(client, seeded_db): + """Register/login users for each plan and return their JWT tokens.""" + plans = { + "free": "hist_free@test.com", + "premium": "hist_premium@test.com", + "pro": "hist_pro@test.com", + } + password = "password123" + + for plan, email in plans.items(): + r = client.post( + "/api/v1/auth/register", + json={"email": email, "password": password}, + content_type="application/json", + ) + assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}" + + # Set plan via direct DB + db_path = os.environ["TURF_SAAS_DB"] + conn = sqlite3.connect(db_path) + for plan, email in plans.items(): + conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email)) + conn.commit() + conn.close() + + tokens = {} + for plan, email in plans.items(): + r = client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + content_type="application/json", + ) + assert r.status_code == 200, f"login failed for {plan}: {r.data}" + tokens[plan] = r.get_json()["access_token"] + + return tokens + + +# ────────────────────────────────────────────────────────────── +# Auth guard +# ────────────────────────────────────────────────────────────── + + +class TestHistoryAuth: + def test_requires_auth(self, client): + """Unauthenticated request must return 401.""" + r = client.get("/api/v1/history") + assert r.status_code == 401 + + def test_invalid_token_returns_401(self, client): + r = client.get( + "/api/v1/history", + headers={"Authorization": "Bearer this.is.not.valid"}, + ) + assert r.status_code == 401 + + +# ────────────────────────────────────────────────────────────── +# Free plan — 7-day window +# ────────────────────────────────────────────────────────────── + + +class TestHistoryFreePlan: + def test_free_can_access_last_7_days(self, client, auth_tokens, seeded_db): + """Free user: start = today-6 (within 7-day window) must return 200.""" + start = days_ago(6) + r = client.get( + f"/api/v1/history?start={start}&end={TODAY.isoformat()}", + headers=auth_header(auth_tokens["free"]), + ) + assert r.status_code == 200 + data = r.get_json() + assert data["status"] == "ok" + assert data["plan"] == "free" + assert data["history_limit_days"] == 7 + + def test_free_blocked_beyond_7_days(self, client, auth_tokens, seeded_db): + """Free user: start = today-8 must return 403 (beyond 7-day window).""" + start = days_ago(8) + r = client.get( + f"/api/v1/history?start={start}&end={TODAY.isoformat()}", + headers=auth_header(auth_tokens["free"]), + ) + assert r.status_code == 403 + data = r.get_json() + assert data["code"] == 403 + assert ( + "upgrade" in data.get("message", "").lower() + or "plan" in data.get("message", "").lower() + ) + + def test_free_default_request_returns_200(self, client, auth_tokens, seeded_db): + """Free user: no dates specified — should use defaults and return 200.""" + r = client.get( + "/api/v1/history", + headers=auth_header(auth_tokens["free"]), + ) + assert r.status_code == 200 + data = r.get_json() + assert data["status"] == "ok" + assert "history" in data + assert "pagination" in data + + def test_free_upgrade_hint_in_403(self, client, auth_tokens, seeded_db): + """403 response must contain required_plans and upgrade_url.""" + start = days_ago(30) + r = client.get( + f"/api/v1/history?start={start}", + headers=auth_header(auth_tokens["free"]), + ) + assert r.status_code == 403 + data = r.get_json() + assert "required_plans" in data + assert "upgrade_url" in data + + +# ────────────────────────────────────────────────────────────── +# Premium plan — 90-day window +# ────────────────────────────────────────────────────────────── + + +class TestHistoryPremiumPlan: + def test_premium_can_access_within_90_days(self, client, auth_tokens, seeded_db): + """Premium user: start = today-89 must return 200.""" + start = days_ago(89) + r = client.get( + f"/api/v1/history?start={start}&end={TODAY.isoformat()}", + headers=auth_header(auth_tokens["premium"]), + ) + assert r.status_code == 200 + data = r.get_json() + assert data["status"] == "ok" + assert data["plan"] == "premium" + assert data["history_limit_days"] == 90 + + def test_premium_blocked_beyond_90_days(self, client, auth_tokens, seeded_db): + """Premium user: start = today-91 must return 403.""" + start = days_ago(91) + r = client.get( + f"/api/v1/history?start={start}&end={TODAY.isoformat()}", + headers=auth_header(auth_tokens["premium"]), + ) + assert r.status_code == 403 + data = r.get_json() + assert data["code"] == 403 + assert "required_plans" in data + # Premium upgrade hint should suggest pro + assert "pro" in data.get("required_plans", []) + + def test_premium_can_access_last_7_days(self, client, auth_tokens, seeded_db): + """Premium user can always access the free window too.""" + start = days_ago(6) + r = client.get( + f"/api/v1/history?start={start}", + headers=auth_header(auth_tokens["premium"]), + ) + assert r.status_code == 200 + + +# ────────────────────────────────────────────────────────────── +# Pro plan — unlimited +# ────────────────────────────────────────────────────────────── + + +class TestHistoryProPlan: + def test_pro_can_access_old_data(self, client, auth_tokens, seeded_db): + """Pro user: start = today-100 must return 200 (unlimited).""" + start = days_ago(100) + r = client.get( + f"/api/v1/history?start={start}&end={TODAY.isoformat()}", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 200 + data = r.get_json() + assert data["status"] == "ok" + assert data["plan"] == "pro" + assert data["history_limit_days"] is None # unlimited + + def test_pro_default_request_returns_200(self, client, auth_tokens, seeded_db): + r = client.get( + "/api/v1/history", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 200 + + def test_pro_can_see_all_seeded_rows(self, client, auth_tokens, seeded_db): + """Pro fetching entire seeded range (100 days) should get all inserted rows.""" + start = days_ago(100) + end = TODAY.isoformat() + r = client.get( + f"/api/v1/history?start={start}&end={end}&limit=500", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 200 + data = r.get_json() + # All 9 seeded rows should be present + assert data["pagination"]["total"] == 9 + + +# ────────────────────────────────────────────────────────────── +# Input validation +# ────────────────────────────────────────────────────────────── + + +class TestHistoryValidation: + def test_invalid_start_format(self, client, auth_tokens): + r = client.get( + "/api/v1/history?start=31-12-2025", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 400 + data = r.get_json() + assert data["code"] == 400 + assert "start" in data["message"].lower() + + def test_invalid_end_format(self, client, auth_tokens): + r = client.get( + "/api/v1/history?end=2025/12/31", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 400 + data = r.get_json() + assert "end" in data["message"].lower() + + def test_start_after_end_returns_400(self, client, auth_tokens): + r = client.get( + f"/api/v1/history?start={TODAY.isoformat()}&end={days_ago(5)}", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 400 + + def test_pagination_limit_respected(self, client, auth_tokens, seeded_db): + start = days_ago(100) + r = client.get( + f"/api/v1/history?start={start}&limit=3&offset=0", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 200 + data = r.get_json() + assert len(data["history"]) <= 3 + assert data["pagination"]["limit"] == 3 + + def test_pagination_has_more(self, client, auth_tokens, seeded_db): + """has_more should be True when more rows exist beyond current page.""" + start = days_ago(100) + r = client.get( + f"/api/v1/history?start={start}&limit=3&offset=0", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 200 + data = r.get_json() + # 9 total rows seeded, limit=3 → has_more=True + assert data["pagination"]["has_more"] is True + + def test_response_shape(self, client, auth_tokens, seeded_db): + """Verify the full response envelope shape.""" + r = client.get( + "/api/v1/history", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 200 + data = r.get_json() + assert "status" in data + assert "plan" in data + assert "history_limit_days" in data + assert "start" in data + assert "end" in data + assert "history" in data + assert "pagination" in data + pagination = data["pagination"] + assert "total" in pagination + assert "limit" in pagination + assert "offset" in pagination + assert "has_more" in pagination + + def test_history_row_fields(self, client, auth_tokens, seeded_db): + """Each history row must contain the expected ML fields.""" + start = days_ago(10) + r = client.get( + f"/api/v1/history?start={start}&limit=5", + headers=auth_header(auth_tokens["pro"]), + ) + assert r.status_code == 200 + data = r.get_json() + if data["history"]: + row = data["history"][0] + expected_fields = { + "id", + "date", + "horse_name", + "prob_top1", + "prob_top3", + "ml_score", + "race_label", + "hippodrome", + "heure", + "is_value_bet", + } + assert expected_fields.issubset(set(row.keys()))