- 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 <noreply@paperclip.ing>
408 lines
15 KiB
Python
408 lines
15 KiB
Python
#!/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()))
|