#!/usr/bin/env python3 """ Integration tests for API v1 — HRT-29 Sprint 3-4: Refacto API /v1/ Run with: cd /home/h3r7/turf_saas source venv/bin/activate python -m pytest tests/test_api_v1.py -v """ import json import os import sys import tempfile import pytest # Ensure local modules are importable sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Use a temp file DB for tests (in-memory fails with multiple connections) _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-secret-key" from app_v1 import create_app from auth_db import init_auth_tables # ────────────────────────────────────────────────────────────── # Fixtures # ────────────────────────────────────────────────────────────── @pytest.fixture(scope="module") def app(): application = create_app() application.config["TESTING"] = True application.config["JWT_SECRET_KEY"] = "test-secret-key" yield application @pytest.fixture(scope="module") def client(app): return app.test_client() @pytest.fixture(scope="module") def auth_tokens(client): """Register a user and return tokens for each plan.""" tokens = {} plans = { "free": ("free@test.com", "password123"), "premium": ("premium@test.com", "password123"), "pro": ("pro@test.com", "password123"), } # Register users for plan, (email, pw) in plans.items(): r = client.post( "/api/v1/auth/register", json={"email": email, "password": pw}, content_type="application/json", ) assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}" # Manually set plans in DB using direct sqlite (bypass app context issues) import sqlite3 db_path = os.environ.get("TURF_SAAS_DB", "/tmp/test_turf.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() # Login and collect tokens for plan, (email, pw) in plans.items(): r = client.post( "/api/v1/auth/login", json={"email": email, "password": pw}, content_type="application/json", ) assert r.status_code == 200, f"login failed for {plan}: {r.data}" data = r.get_json() tokens[plan] = data["access_token"] return tokens def auth_header(token: str) -> dict: return {"Authorization": f"Bearer {token}"} # ────────────────────────────────────────────────────────────── # Health # ────────────────────────────────────────────────────────────── class TestHealth: def test_health_public(self, client): """GET /api/v1/health — no auth required""" r = client.get("/api/v1/health") assert r.status_code == 200 data = r.get_json() assert data["status"] == "ok" assert data["version"] == "1.0" assert "timestamp" in data def test_health_returns_json(self, client): r = client.get("/api/v1/health") assert r.content_type.startswith("application/json") # ────────────────────────────────────────────────────────────── # Auth # ────────────────────────────────────────────────────────────── class TestAuth: def test_register_new_user(self, client): r = client.post( "/api/v1/auth/register", json={"email": "new_test@example.com", "password": "strongpass123"}, ) assert r.status_code in (201, 409) def test_register_short_password(self, client): r = client.post( "/api/v1/auth/register", json={"email": "bad@example.com", "password": "123"}, ) assert r.status_code == 400 def test_register_invalid_email(self, client): r = client.post( "/api/v1/auth/register", json={"email": "notemail", "password": "password123"}, ) assert r.status_code == 400 def test_login_valid(self, client, auth_tokens): assert "free" in auth_tokens def test_login_wrong_password(self, client): r = client.post( "/api/v1/auth/login", json={"email": "free@test.com", "password": "wrongpassword"}, ) assert r.status_code == 401 def test_protected_without_token(self, client): r = client.get("/api/v1/courses/today") assert r.status_code == 401 # ────────────────────────────────────────────────────────────── # Courses # ────────────────────────────────────────────────────────────── class TestCourses: def test_today_requires_auth(self, client): r = client.get("/api/v1/courses/today") assert r.status_code == 401 def test_today_with_auth(self, client, auth_tokens): r = client.get( "/api/v1/courses/today", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 200 data = r.get_json() assert data["status"] == "ok" assert "courses" in data assert "pagination" in data assert "date" in data def test_today_pagination(self, client, auth_tokens): r = client.get( "/api/v1/courses/today?limit=5&offset=0", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 200 data = r.get_json() assert data["pagination"]["limit"] == 5 assert data["pagination"]["offset"] == 0 def test_today_filter_all(self, client, auth_tokens): r = client.get( "/api/v1/courses/today?filter=all", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 200 def test_course_predictions_requires_auth(self, client): r = client.get("/api/v1/courses/1-1/predictions") assert r.status_code == 401 def test_course_predictions_invalid_id(self, client, auth_tokens): r = client.get( "/api/v1/courses/invalid/predictions", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 400 def test_course_predictions_not_found(self, client, auth_tokens): r = client.get( "/api/v1/courses/99-99/predictions", headers=auth_header(auth_tokens["free"]), ) # 404 expected since DB is empty; 429 if free daily limit already reached in this session assert r.status_code in (404, 200, 429) # 200 if gracefully returns empty # ────────────────────────────────────────────────────────────── # Predictions # ────────────────────────────────────────────────────────────── class TestPredictions: def test_top3_requires_auth(self, client): r = client.get("/api/v1/predictions/top3") assert r.status_code == 401 def test_top3_free_allowed(self, client, auth_tokens): # Reset daily usage for free user before testing rate-limited endpoint import sqlite3 db_path = os.environ.get("TURF_SAAS_DB", "/tmp/test_turf.db") conn = sqlite3.connect(db_path) conn.execute( "UPDATE users SET daily_usage=0, last_usage_date=NULL WHERE email='free@test.com'" ) conn.commit() conn.close() r = client.get( "/api/v1/predictions/top3", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 200 data = r.get_json() assert data["status"] == "ok" assert "top3" in data def test_all_requires_premium(self, client, auth_tokens): r = client.get( "/api/v1/predictions/all", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 403 def test_all_premium_allowed(self, client, auth_tokens): r = client.get( "/api/v1/predictions/all", headers=auth_header(auth_tokens["premium"]), ) assert r.status_code == 200 data = r.get_json() assert data["status"] == "ok" assert "predictions" in data assert "pagination" in data def test_all_pro_allowed(self, client, auth_tokens): r = client.get( "/api/v1/predictions/all", headers=auth_header(auth_tokens["pro"]), ) assert r.status_code == 200 # ────────────────────────────────────────────────────────────── # Value Bets # ────────────────────────────────────────────────────────────── class TestValueBets: def test_requires_auth(self, client): r = client.get("/api/v1/valuebets") assert r.status_code == 401 def test_free_forbidden(self, client, auth_tokens): r = client.get( "/api/v1/valuebets", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 403 def test_premium_allowed(self, client, auth_tokens): r = client.get( "/api/v1/valuebets", headers=auth_header(auth_tokens["premium"]), ) assert r.status_code == 200 data = r.get_json() assert data["status"] == "ok" assert "valuebets" in data assert "pagination" in data def test_min_odds_filter(self, client, auth_tokens): r = client.get( "/api/v1/valuebets?min_odds=3.0", headers=auth_header(auth_tokens["premium"]), ) assert r.status_code == 200 data = r.get_json() assert data["min_odds"] == 3.0 # ────────────────────────────────────────────────────────────── # Backtest # ────────────────────────────────────────────────────────────── class TestBacktest: def test_requires_auth(self, client): r = client.get("/api/v1/backtest") assert r.status_code == 401 def test_premium_forbidden(self, client, auth_tokens): r = client.get( "/api/v1/backtest", headers=auth_header(auth_tokens["premium"]), ) assert r.status_code == 403 def test_pro_allowed(self, client, auth_tokens): r = client.get( "/api/v1/backtest", headers=auth_header(auth_tokens["pro"]), ) assert r.status_code == 200 data = r.get_json() assert data["status"] == "ok" assert "summary" in data assert "period" in data def test_invalid_date_format(self, client, auth_tokens): r = client.get( "/api/v1/backtest?start=31-12-2025", headers=auth_header(auth_tokens["pro"]), ) assert r.status_code == 400 # ────────────────────────────────────────────────────────────── # Export # ────────────────────────────────────────────────────────────── class TestExport: def test_requires_auth(self, client): r = client.get("/api/v1/export/csv") assert r.status_code == 401 def test_free_forbidden(self, client, auth_tokens): r = client.get( "/api/v1/export/csv", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 403 def test_premium_forbidden(self, client, auth_tokens): r = client.get( "/api/v1/export/csv", headers=auth_header(auth_tokens["premium"]), ) assert r.status_code == 403 def test_pro_allowed_predictions(self, client, auth_tokens): r = client.get( "/api/v1/export/csv?type=predictions", headers=auth_header(auth_tokens["pro"]), ) # 200 (CSV) or 400 if table doesn't exist in test DB assert r.status_code in (200, 400) if r.status_code == 200: assert "text/csv" in r.content_type def test_invalid_type(self, client, auth_tokens): r = client.get( "/api/v1/export/csv?type=invalid", headers=auth_header(auth_tokens["pro"]), ) assert r.status_code == 400 # ────────────────────────────────────────────────────────────── # Metrics # ────────────────────────────────────────────────────────────── class TestMetrics: def test_requires_auth(self, client): r = client.get("/api/v1/metrics") assert r.status_code == 401 def test_free_forbidden(self, client, auth_tokens): r = client.get( "/api/v1/metrics", headers=auth_header(auth_tokens["free"]), ) assert r.status_code == 403 def test_premium_allowed(self, client, auth_tokens): r = client.get( "/api/v1/metrics", headers=auth_header(auth_tokens["premium"]), ) assert r.status_code == 200 data = r.get_json() assert data["status"] == "ok" assert "bet_metrics" in data assert "ml_metrics" in data assert "period" in data def test_days_parameter(self, client, auth_tokens): r = client.get( "/api/v1/metrics?days=7", headers=auth_header(auth_tokens["premium"]), ) assert r.status_code == 200 data = r.get_json() assert data["period"]["days"] == 7 def test_invalid_days(self, client, auth_tokens): r = client.get( "/api/v1/metrics?days=abc", headers=auth_header(auth_tokens["premium"]), ) assert r.status_code == 400 # ────────────────────────────────────────────────────────────── # Global error handlers # ────────────────────────────────────────────────────────────── class TestErrorHandlers: def test_404_returns_json(self, client): r = client.get("/api/v1/this-does-not-exist") assert r.status_code == 404 data = r.get_json() assert data["code"] == 404 def test_uniform_error_shape(self, client): """All error responses must have {status, message, code}.""" r = client.get("/api/v1/this-does-not-exist") data = r.get_json() assert "status" in data assert "message" in data assert "code" in data # ────────────────────────────────────────────────────────────── # Swagger docs # ────────────────────────────────────────────────────────────── class TestDocs: def test_docs_accessible(self, client): r = client.get("/api/v1/docs") # flasgger returns a redirect or the UI page assert r.status_code in (200, 301, 302) def test_apispec_json(self, client): r = client.get("/api/v1/apispec.json") assert r.status_code == 200 spec = r.get_json() assert spec["swagger"] == "2.0" assert "paths" in spec