#!/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()))