Files
turf_saas/tests/e2e/test_scenarios.py
2026-04-25 17:18:43 +02:00

464 lines
16 KiB
Python

"""
Tests E2E Playwright — SaaS Turf Prédictions IA
Sprint 8 — QA, Beta Fermee, Go/No-Go
Ticket: HRT-34
Scénarios couverts :
1. Inscription → choix plan free → voir top 3
2. Upgrade premium → Stripe checkout → accès toutes courses
3. Abonnement pro → export CSV → accès API
4. Annulation abonnement → downgrade free
Navigateurs : Chrome, Firefox, Safari mobile (via Playwright)
Screenshots automatiques sur échec
"""
import asyncio
import os
from pathlib import Path
from datetime import datetime
import pytest
from playwright.async_api import async_playwright, Page, Browser, BrowserContext
# === Configuration ===
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
SCREENSHOT_DIR = Path("tests/screenshots")
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
# Stripe test card (mode test uniquement)
STRIPE_TEST_CARD = "4242 4242 4242 4242"
STRIPE_EXPIRY = "12/30"
STRIPE_CVC = "123"
STRIPE_ZIP = "75001"
# Credentials beta
BETA_PROMO_CODE = "BETA2026"
TEST_USER_EMAIL_BASE = "testqa+{}@h3r7.tech"
TEST_USER_PASSWORD = "TestQA_2026!"
def screenshot_on_fail(test_name: str, page: Page):
"""Decorator/helper to take screenshot on test failure."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
path = SCREENSHOT_DIR / f"FAIL_{test_name}_{timestamp}.png"
return str(path)
# === Fixtures ===
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="module")
async def playwright_instance():
async with async_playwright() as p:
yield p
@pytest.fixture(params=["chromium", "firefox", "webkit"])
async def browser(playwright_instance, request):
browser_type = getattr(playwright_instance, request.param)
# Webkit simule Safari mobile
if request.param == "webkit":
b = await browser_type.launch(headless=True)
yield b, "safari_mobile"
else:
b = await browser_type.launch(headless=True)
yield b, request.param
await b.close()
@pytest.fixture
async def context_page(browser):
b, browser_name = browser
if browser_name == "safari_mobile":
# Simule iPhone 13
ctx = await b.new_context(
viewport={"width": 390, "height": 844},
user_agent=(
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1"
),
)
else:
ctx = await b.new_context(viewport={"width": 1280, "height": 800})
page = await ctx.new_page()
yield page, browser_name
await ctx.close()
# === Helper functions ===
async def register_user(page: Page, email: str, password: str, promo: str = None):
"""Inscrit un nouvel utilisateur."""
await page.goto(f"{BASE_URL}/register")
await page.fill('[name="email"]', email)
await page.fill('[name="password"]', password)
await page.fill('[name="confirm_password"]', password)
if promo:
promo_field = page.locator('[name="promo_code"]')
if await promo_field.count() > 0:
await promo_field.fill(promo)
await page.click('[type="submit"]')
await page.wait_for_url(f"{BASE_URL}/**", timeout=10_000)
async def login_user(page: Page, email: str, password: str):
"""Connecte un utilisateur existant."""
await page.goto(f"{BASE_URL}/login")
await page.fill('[name="email"]', email)
await page.fill('[name="password"]', password)
await page.click('[type="submit"]')
await page.wait_for_url(f"{BASE_URL}/**", timeout=10_000)
async def fill_stripe_form(page: Page):
"""Remplit le formulaire Stripe Checkout (mode test)."""
# Stripe embeds iframe — on attend le frame
stripe_frame = page.frame_locator('iframe[name^="__privateStripeFrame"]').first
await stripe_frame.locator('[placeholder="Card number"]').fill(STRIPE_TEST_CARD)
await stripe_frame.locator('[placeholder="MM / YY"]').fill(STRIPE_EXPIRY)
await stripe_frame.locator('[placeholder="CVC"]').fill(STRIPE_CVC)
await stripe_frame.locator('[placeholder="ZIP"]').fill(STRIPE_ZIP)
# === Test Scénario 1 : Inscription → plan free → voir top 3 ===
@pytest.mark.asyncio
async def test_inscription_plan_free_top3(context_page):
"""
Scénario 1 : Inscription → choix plan free → voir top 3 prédictions
"""
page, browser_name = context_page
test_name = f"test_inscription_plan_free_top3_{browser_name}"
unique_email = TEST_USER_EMAIL_BASE.format(
f"free_{datetime.now().strftime('%H%M%S')}"
)
try:
# 1. Inscription
await register_user(page, unique_email, TEST_USER_PASSWORD)
# 2. Choix du plan free (si une page de choix de plan existe)
if "plan" in page.url or "pricing" in page.url:
free_plan_btn = page.locator(
'[data-plan="free"], [data-testid="plan-free"], text=Free, text=Gratuit'
)
await free_plan_btn.first.click()
await page.wait_for_timeout(1000)
# 3. Accès au dashboard
await page.goto(f"{BASE_URL}/dashboard")
await page.wait_for_load_state("networkidle", timeout=15_000)
# 4. Vérification : le top 3 est visible
top3_section = page.locator(
'[data-testid="top3"], .top3, #top3, .predictions-top3'
)
if await top3_section.count() > 0:
await top3_section.first.wait_for(state="visible", timeout=5_000)
assert await top3_section.first.is_visible(), "Top 3 section non visible"
else:
# Fallback : chercher des cards de chevaux
prediction_cards = page.locator(
".prediction-card, .horse-card, [data-horse]"
)
count = await prediction_cards.count()
assert count >= 1, (
f"Aucune prédiction visible pour plan free (browser: {browser_name})"
)
# 5. Vérification : pas d'accès aux routes premium
await page.goto(f"{BASE_URL}/api/races?all=true")
content = await page.content()
# Un plan free ne devrait pas voir toutes les courses sans restriction
# Le check exact dépend de l'implémentation — ici on vérifie juste la réponse 200 ou 403
assert page.url is not None
print(f"✅ [{browser_name}] Scénario 1 PASS — {unique_email}")
except Exception as e:
await page.screenshot(path=screenshot_on_fail(test_name, page))
raise AssertionError(f"[{browser_name}] Scénario 1 FAIL: {e}") from e
# === Test Scénario 2 : Upgrade premium → Stripe → accès toutes courses ===
@pytest.mark.asyncio
async def test_upgrade_premium_stripe(context_page):
"""
Scénario 2 : Upgrade premium → Stripe checkout → accès toutes courses
"""
page, browser_name = context_page
test_name = f"test_upgrade_premium_{browser_name}"
unique_email = TEST_USER_EMAIL_BASE.format(
f"premium_{datetime.now().strftime('%H%M%S')}"
)
try:
# 1. Inscription free
await register_user(page, unique_email, TEST_USER_PASSWORD)
await page.goto(f"{BASE_URL}/dashboard")
await page.wait_for_load_state("networkidle", timeout=15_000)
# 2. Aller à la page upgrade/pricing
upgrade_btn = page.locator(
'[data-testid="upgrade"], [href*="pricing"], [href*="upgrade"], text=Upgrade, text=Premium'
)
await upgrade_btn.first.click()
await page.wait_for_timeout(1000)
# 3. Sélectionner plan premium
premium_btn = page.locator(
'[data-plan="premium"], [data-testid="plan-premium"], text=Premium, text=Pro'
)
await premium_btn.first.click()
await page.wait_for_timeout(1000)
# 4. Stripe Checkout
# Attendre la redirection vers Stripe ou l'ouverture du formulaire
try:
await page.wait_for_url("**/stripe.com/**", timeout=8_000)
# Mode redirection Stripe
await page.fill(
'[placeholder="Card number"]', STRIPE_TEST_CARD.replace(" ", "")
)
await page.fill('[placeholder="MM / YY"]', STRIPE_EXPIRY)
await page.fill('[placeholder="CVC"]', STRIPE_CVC)
except Exception:
# Mode embedded Stripe
await fill_stripe_form(page)
await page.click(
'[type="submit"], [data-testid="submit"], text=Pay, text=Payer'
)
# Attendre le retour sur l'app
await page.wait_for_url(f"{BASE_URL}/**", timeout=30_000)
# 5. Vérification : accès à toutes les courses
await page.goto(f"{BASE_URL}/dashboard")
await page.wait_for_load_state("networkidle", timeout=15_000)
# Le badge premium doit être visible
premium_badge = page.locator(
'[data-testid="premium-badge"], .premium-badge, .badge-premium, text=Premium'
)
assert (
await premium_badge.count() > 0
or "premium" in (await page.content()).lower()
), f"Badge premium non détecté après upgrade (browser: {browser_name})"
print(f"✅ [{browser_name}] Scénario 2 PASS — {unique_email}")
except Exception as e:
await page.screenshot(path=screenshot_on_fail(test_name, page))
raise AssertionError(f"[{browser_name}] Scénario 2 FAIL: {e}") from e
# === Test Scénario 3 : Abonnement pro → export CSV → accès API ===
@pytest.mark.asyncio
async def test_pro_export_csv_api_access(context_page):
"""
Scénario 3 : Abonnement pro → export CSV → accès API
"""
page, browser_name = context_page
test_name = f"test_pro_export_csv_{browser_name}"
unique_email = TEST_USER_EMAIL_BASE.format(
f"pro_{datetime.now().strftime('%H%M%S')}"
)
try:
# 1. Inscription + upgrade pro (via API admin pour les tests)
await register_user(page, unique_email, TEST_USER_PASSWORD)
# Promotion directe via API de test (si disponible)
api_resp = await page.request.post(
f"{BASE_URL}/api/test/set-plan",
data={"email": unique_email, "plan": "pro"},
)
if api_resp.status != 200:
pytest.skip("API de promotion de plan non disponible — nécessite HRT-31")
# 2. Se connecter
await login_user(page, unique_email, TEST_USER_PASSWORD)
await page.goto(f"{BASE_URL}/dashboard")
await page.wait_for_load_state("networkidle", timeout=15_000)
# 3. Export CSV
async with page.expect_download() as download_info:
export_btn = page.locator(
'[data-testid="export-csv"], [href*="csv"], text=Export CSV, text=Exporter CSV'
)
await export_btn.first.click()
download = await download_info.value
assert download.suggested_filename.endswith(".csv"), (
f"Fichier téléchargé n'est pas un CSV: {download.suggested_filename}"
)
# Sauvegarder pour vérification
await download.save_as(f"/tmp/qa_export_{browser_name}.csv")
# 4. Accès API avec clé
api_key_section = page.locator('[data-testid="api-key"], .api-key, #api-key')
if await api_key_section.count() > 0:
api_key = await api_key_section.first.inner_text()
api_key = api_key.strip()
# Tester l'API directement
api_resp = await page.request.get(
f"{BASE_URL}/api/races",
headers={"X-API-Key": api_key},
)
assert api_resp.status == 200, (
f"API access refusé avec clé valide (status: {api_resp.status})"
)
print(f"✅ [{browser_name}] Scénario 3 PASS — {unique_email}")
except Exception as e:
await page.screenshot(path=screenshot_on_fail(test_name, page))
raise AssertionError(f"[{browser_name}] Scénario 3 FAIL: {e}") from e
# === Test Scénario 4 : Annulation abonnement → downgrade free ===
@pytest.mark.asyncio
async def test_annulation_downgrade_free(context_page):
"""
Scénario 4 : Annulation abonnement → downgrade free
"""
page, browser_name = context_page
test_name = f"test_annulation_downgrade_{browser_name}"
unique_email = TEST_USER_EMAIL_BASE.format(
f"cancel_{datetime.now().strftime('%H%M%S')}"
)
try:
# 1. Inscription + set plan premium via API test
await register_user(page, unique_email, TEST_USER_PASSWORD)
api_resp = await page.request.post(
f"{BASE_URL}/api/test/set-plan",
data={"email": unique_email, "plan": "premium"},
)
if api_resp.status != 200:
pytest.skip("API de promotion de plan non disponible — nécessite HRT-31")
await login_user(page, unique_email, TEST_USER_PASSWORD)
# 2. Aller à la page de gestion d'abonnement
await page.goto(f"{BASE_URL}/account/subscription")
await page.wait_for_load_state("networkidle", timeout=10_000)
# 3. Annuler l'abonnement
cancel_btn = page.locator(
'[data-testid="cancel-subscription"], text=Annuler, text=Cancel subscription, '
"text=Résilier"
)
await cancel_btn.first.click()
# Confirmation modal
confirm_btn = page.locator(
'[data-testid="confirm-cancel"], text=Confirmer, text=Confirm, text=Oui'
)
if await confirm_btn.count() > 0:
await confirm_btn.first.click()
await page.wait_for_timeout(2000)
# 4. Vérification : retour au plan free
await page.goto(f"{BASE_URL}/dashboard")
await page.wait_for_load_state("networkidle", timeout=15_000)
content = await page.content()
# Le badge premium ne doit plus être actif
premium_active = page.locator(
'[data-testid="premium-badge"].active, .premium-active'
)
assert await premium_active.count() == 0, (
f"Badge premium encore actif après annulation (browser: {browser_name})"
)
# Le plan free doit être indiqué
free_indicator = page.locator(
'[data-plan="free"], .badge-free, text=Plan Free, text=Gratuit'
)
# Ou simplement vérifier l'absence de mention "premium actif"
assert (
"annulé" in content.lower()
or "cancelled" in content.lower()
or "free" in content.lower()
or await free_indicator.count() > 0
), f"Downgrade vers free non confirmé (browser: {browser_name})"
print(f"✅ [{browser_name}] Scénario 4 PASS — {unique_email}")
except Exception as e:
await page.screenshot(path=screenshot_on_fail(test_name, page))
raise AssertionError(f"[{browser_name}] Scénario 4 FAIL: {e}") from e
# === Test Sécurité : Plan free ne peut pas accéder routes premium ===
@pytest.mark.asyncio
async def test_autorisation_plan_free_acces_premium_refuse(context_page):
"""
Test d'autorisation : un utilisateur free ne peut pas accéder aux routes premium.
"""
page, browser_name = context_page
test_name = f"test_auth_plan_free_acces_bloque_{browser_name}"
unique_email = TEST_USER_EMAIL_BASE.format(
f"authtest_{datetime.now().strftime('%H%M%S')}"
)
try:
await register_user(page, unique_email, TEST_USER_PASSWORD)
await login_user(page, unique_email, TEST_USER_PASSWORD)
# Tenter d'accéder à une route premium
premium_routes = [
"/api/races?all=true",
"/api/export/csv",
"/api/predictions/all",
]
for route in premium_routes:
resp = await page.request.get(f"{BASE_URL}{route}")
assert resp.status in (403, 401, 402), (
f"Route premium accessible par plan free: {route} → HTTP {resp.status} (browser: {browser_name})"
)
print(f"✅ [{browser_name}] Test autorisation PASS — {unique_email}")
except Exception as e:
await page.screenshot(path=screenshot_on_fail(test_name, page))
raise AssertionError(f"[{browser_name}] Test autorisation FAIL: {e}") from e
if __name__ == "__main__":
# Exécution directe pour debug
import subprocess
subprocess.run(
[
"python",
"-m",
"pytest",
__file__,
"-v",
"--tb=short",
"--html=tests/reports/e2e_report.html",
"--self-contained-html",
]
)