Compare commits
2 Commits
feature/HR
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
701660ce83 | ||
| b7ed82418f |
@@ -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)
|
||||
|
||||
212
api_v1/routes/history.py
Normal file
212
api_v1/routes/history.py
Normal file
@@ -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()
|
||||
407
tests/test_history.py
Normal file
407
tests/test_history.py
Normal file
@@ -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()))
|
||||
Reference in New Issue
Block a user