From 606175d718523c530b371a76c85a7a5201608b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Sun, 26 Jul 2026 20:22:20 +0400 Subject: [PATCH] test(e2e): add Playwright tests for homepage, video page and load-more --- tests/e2e/conftest.py | 160 +++++++++++++++++++++++++++++++++++ tests/e2e/test_homepage.py | 62 ++++++++++++++ tests/e2e/test_load_more.py | 106 +++++++++++++++++++++++ tests/e2e/test_video_page.py | 90 ++++++++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_homepage.py create mode 100644 tests/e2e/test_load_more.py create mode 100644 tests/e2e/test_video_page.py diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..59dc1df --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,160 @@ +""" +Fixtures pytest pour les tests E2E : serveur PHP local + page Playwright. + +Le serveur de développement PHP (`php -S`) est démarré une fois par session +de test sur 127.0.0.1:8000 (ou sur un port libre si 8000 est déjà pris). +Les appels aux API distantes (PeerTube, Castopod) passent par le vrai réseau : +le premier chargement remplit le cache du site (cache/api), ce qui peut +prendre un certain temps. + +Lancement : /tmp/test-venv/bin/python -m pytest tests/e2e/ +""" +import os +import re +import socket +import subprocess +import tempfile +import time +import urllib.request +from pathlib import Path + +import pytest +from playwright.sync_api import sync_playwright + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SERVER_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 +STARTUP_TIMEOUT = 30 # secondes pour que php -S accepte des connexions +WARMUP_TIMEOUT = 90 # premier hit : remplit les caches API (Castopod/PeerTube) +PAGE_TIMEOUT_MS = 60000 + + +def _port_libre(port): + """Retourne True si aucun service n'écoute sur le port donné.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex((SERVER_HOST, port)) != 0 + + +def _trouver_port(): + """Utilise le port 8000 si possible, sinon un port libre quelconque.""" + if _port_libre(DEFAULT_PORT): + return DEFAULT_PORT + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind((SERVER_HOST, 0)) + return s.getsockname()[1] + + +def lire_constante_config(nom, defaut=None): + """ + Lit une constante define('NOM', 'valeur') dans la configuration PHP. + + Cherche dans config.default.php puis dans config.local.php (qui a le + dernier mot, comme dans includes/config.php). Ne gère que les valeurs + chaînes simples — suffisant pour SITE_NAME, PEERTUBE_URL, etc. + """ + valeur = defaut + for fichier in ("config.default.php", "config.local.php"): + chemin = PROJECT_ROOT / "includes" / fichier + if not chemin.is_file(): + continue + texte = chemin.read_text(encoding="utf-8") + motif = r"define\(\s*'" + re.escape(nom) + r"'\s*,\s*'((?:[^'\\]|\\.)*)'\s*\)" + m = re.search(motif, texte) + if m: + valeur = m.group(1).replace("\\'", "'").replace("\\\\", "\\") + return valeur + + +@pytest.fixture(scope="session") +def base_url(): + """Démarre `php -S` à la racine du projet et fournit l'URL de base.""" + port = _trouver_port() + url = f"http://{SERVER_HOST}:{port}" + + log = tempfile.NamedTemporaryFile( + mode="w+", prefix="php-e2e-", suffix=".log", delete=False + ) + # PHP_CLI_SERVER_WORKERS : le serveur intégré est mono-thread par défaut, + # ce qui bloque quand le navigateur demande plusieurs PHP en parallèle. + env = dict(os.environ, PHP_CLI_SERVER_WORKERS="4") + proc = subprocess.Popen( + ["php", "-S", f"{SERVER_HOST}:{port}", "-t", str(PROJECT_ROOT)], + cwd=str(PROJECT_ROOT), + stdout=log, + stderr=subprocess.STDOUT, + env=env, + ) + + # Attendre que le serveur accepte des connexions + deadline = time.monotonic() + STARTUP_TIMEOUT + pret = False + while time.monotonic() < deadline: + if proc.poll() is not None: + log.seek(0) + raise RuntimeError( + "Le serveur PHP s'est arrêté au démarrage :\n" + log.read() + ) + try: + with socket.create_connection((SERVER_HOST, port), timeout=1): + pret = True + break + except OSError: + time.sleep(0.2) + + if not pret: + proc.terminate() + raise RuntimeError( + f"Le serveur PHP ne répond pas après {STARTUP_TIMEOUT} s" + ) + + # Pré-chauffe les caches (initCategories + appels API distants) + try: + with urllib.request.urlopen(url + "/index.php", timeout=WARMUP_TIMEOUT) as rep: + if rep.status != 200: + raise RuntimeError(f"index.php a répondu HTTP {rep.status}") + except Exception: + proc.terminate() + log.seek(0) + raise RuntimeError( + "Impossible de charger la page d'accueil :\n" + log.read() + ) + + yield url + + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + log.close() + + +@pytest.fixture(scope="session") +def navigateur(): + """Instance Chromium partagée pour toute la session de test.""" + with sync_playwright() as p: + browser = p.chromium.launch() + yield browser + browser.close() + + +@pytest.fixture() +def page(navigateur): + """Nouvelle page vierge pour chaque test (contexte isolé).""" + context = navigateur.new_context() + context.set_default_timeout(PAGE_TIMEOUT_MS) + pg = context.new_page() + yield pg + context.close() + + +@pytest.fixture(scope="session") +def site_name(): + """Valeur de la constante SITE_NAME de la configuration du site.""" + return lire_constante_config("SITE_NAME") + + +@pytest.fixture(scope="session") +def peertube_url(): + """Valeur de la constante PEERTUBE_URL de la configuration du site.""" + return lire_constante_config("PEERTUBE_URL") diff --git a/tests/e2e/test_homepage.py b/tests/e2e/test_homepage.py new file mode 100644 index 0000000..ec5ab51 --- /dev/null +++ b/tests/e2e/test_homepage.py @@ -0,0 +1,62 @@ +"""Tests E2E de la page d'accueil (index.php).""" +import re + +import pytest + + +def test_homepage_title(page, base_url, site_name): + """Le titre de la page correspond au SITE_NAME configuré.""" + page.goto(base_url + "/index.php", wait_until="domcontentloaded") + assert page.title() == site_name + + +def test_homepage_csrf_meta_token(page, base_url): + """La balise meta csrf-token est présente au format 'timestamp:hmac'.""" + page.goto(base_url + "/index.php", wait_until="domcontentloaded") + token = page.locator('meta[name="csrf-token"]').get_attribute("content") + assert token, "La balise meta csrf-token est absente ou vide" + assert re.fullmatch(r"\d+:[0-9a-f]{64}", token), ( + f"Format de token CSRF inattendu : {token!r}" + ) + + +def test_homepage_main_sections(page, base_url): + """Les sections principales sont rendues côté serveur.""" + page.goto(base_url + "/index.php", wait_until="domcontentloaded") + + # Titres de sections vidéo (identifiants stables du markup) + assert page.locator("#shorts-heading").inner_text() == "Shorts" + assert page.locator("#recent-videos-heading").inner_text() == "Dernières vidéos" + assert page.locator("#trending-videos-heading").inner_text() == "Tendances" + + # Section du fil d'actualités Mastodon + assert page.locator(".mastodon-section .mt-title").inner_text() != "" + + # Au moins une grille de vidéos + assert page.locator(".video-section").count() >= 3 + + +def test_homepage_view_more_buttons(page, base_url): + """Chaque section vidéo propose un bouton 'Voir plus'.""" + page.goto(base_url + "/index.php", wait_until="domcontentloaded") + + boutons = page.locator("button.view-more") + # Sections 'Dernières vidéos' et 'Tendances' toujours présentes + # (+ une par catégorie contenant des vidéos) + assert boutons.count() >= 2 + for i in range(boutons.count()): + # text_content : le texte DOM brut (inner_text subit text-transform: uppercase) + assert boutons.nth(i).text_content() == "Voir plus" + + +def test_homepage_video_cards(page, base_url): + """Des cartes vidéo remontent de l'API PeerTube (nécessite le réseau).""" + page.goto(base_url + "/index.php", wait_until="domcontentloaded") + + cartes = page.locator(".video-card") + if cartes.count() == 0: + pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)") + + # Chaque carte expose un identifiant vidéo exploitable + for i in range(cartes.count()): + assert cartes.nth(i).get_attribute("data-video-id") diff --git a/tests/e2e/test_load_more.py b/tests/e2e/test_load_more.py new file mode 100644 index 0000000..249bd04 --- /dev/null +++ b/tests/e2e/test_load_more.py @@ -0,0 +1,106 @@ +"""Tests E2E de l'endpoint AJAX ajax/load-more-videos.php. + +L'endpoint exige trois protections cumulées (dans l'ordre) : +1. l'en-tête X-Requested-With: XMLHttpRequest ; +2. une origine (Origin, ou Referer à défaut) strictement identique au site ; +3. un token CSRF stateless valide (champ POST csrf_token). +""" +import pytest + +ENDPOINT = "/ajax/load-more-videos.php" + + +@pytest.fixture() +def csrf_token(page, base_url): + """Récupère un token CSRF frais depuis la balise meta de l'accueil.""" + page.goto(base_url + "/index.php", wait_until="domcontentloaded") + token = page.locator('meta[name="csrf-token"]').get_attribute("content") + assert token, "La balise meta csrf-token est absente de l'accueil" + return token + + +def _post(page, base_url, query="", token=None, ajax=True, origin=None): + """POST sur l'endpoint avec les protections activables une à une.""" + headers = {} + if ajax: + headers["X-Requested-With"] = "XMLHttpRequest" + if origin is not None: + headers["Origin"] = origin + form = {} + if token is not None: + form["csrf_token"] = token + return page.request.post( + base_url + ENDPOINT + query, form=form, headers=headers + ) + + +def test_load_more_rejects_non_ajax(page, base_url, csrf_token): + """Sans X-Requested-With, la requête est refusée (403).""" + rep = _post(page, base_url, "?type=recent&page=1", + token=csrf_token, ajax=False, origin=base_url) + assert rep.status == 403 + assert rep.json() == {"error": "Accès non autorisé"} + + +def test_load_more_rejects_bad_origin(page, base_url, csrf_token): + """Une origine étrangère est refusée (403).""" + rep = _post(page, base_url, "?type=recent&page=1", + token=csrf_token, origin="https://example.com") + assert rep.status == 403 + assert rep.json() == {"error": "Origine non autorisée"} + + +def test_load_more_rejects_bad_csrf(page, base_url): + """Un token CSRF forgé est refusé (403).""" + rep = _post(page, base_url, "?type=recent&page=1", + token="1234567890:tokenforge", origin=base_url) + assert rep.status == 403 + assert rep.json() == {"error": "Token CSRF invalide"} + + +def test_load_more_rejects_invalid_type(page, base_url, csrf_token): + """Un type de vidéos inconnu est refusé (400).""" + rep = _post(page, base_url, "?type=inconnu&page=1", + token=csrf_token, origin=base_url) + assert rep.status == 400 + assert rep.json() == {"error": "Type de vidéos non valide"} + + +def test_load_more_category_requires_id(page, base_url, csrf_token): + """Le type 'category' sans ID de catégorie est refusé (400).""" + rep = _post(page, base_url, "?type=category&page=1", + token=csrf_token, origin=base_url) + assert rep.status == 400 + assert rep.json() == {"error": "ID de catégorie manquant ou invalide"} + + +def test_load_more_recent_structure(page, base_url, csrf_token): + """Une requête valide renvoie la structure JSON attendue.""" + rep = _post(page, base_url, "?type=recent&page=1", + token=csrf_token, origin=base_url) + assert rep.status == 200 + + payload = rep.json() + assert payload["success"] is True + assert payload["page"] == 1 + assert isinstance(payload["hasMore"], bool) + assert isinstance(payload["html"], str) + + +def test_load_more_recent_returns_videos(page, base_url, csrf_token): + """Le HTML retourné contient des cartes vidéo (nécessite le réseau).""" + rep = _post(page, base_url, "?type=recent&page=1", + token=csrf_token, origin=base_url) + assert rep.status == 200 + + payload = rep.json() + if not payload["html"]: + pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)") + + assert 'class="video-card"' in payload["html"] + assert "data-video-id=" in payload["html"] + # L'endpoint n'a plus rien à servir bien au-delà du catalogue + rep_fin = _post(page, base_url, "?type=recent&page=10000", + token=csrf_token, origin=base_url) + assert rep_fin.status == 200 + assert rep_fin.json()["hasMore"] is False diff --git a/tests/e2e/test_video_page.py b/tests/e2e/test_video_page.py new file mode 100644 index 0000000..6324ffb --- /dev/null +++ b/tests/e2e/test_video_page.py @@ -0,0 +1,90 @@ +"""Tests E2E de la page vidéo (video.php).""" +import json + +import pytest + + +def _premier_video_id(page, base_url): + """Récupère l'identifiant de la première carte vidéo de l'accueil.""" + page.goto(base_url + "/index.php", wait_until="domcontentloaded") + ids = page.locator(".video-card").evaluate_all( + "(els) => els.map(e => e.dataset.videoId).filter(Boolean)" + ) + # Déduplique en conservant l'ordre (la même vidéo peut être en short et en grille) + uniques = list(dict.fromkeys(ids)) + if not uniques: + pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)") + return uniques[0] + + +def _jsonld_blocks(page): + """Parse tous les blocs