diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php
new file mode 100644
index 0000000..c0f6d06
--- /dev/null
+++ b/tests/php/bootstrap.php
@@ -0,0 +1,34 @@
+setAccessible(true);
+
+$tmpDir = sys_get_temp_dir() . '/annu-kute-cache-test-' . getmypid();
+if (!is_dir($tmpDir)) {
+ mkdir($tmpDir, 0755, true);
+}
+$cacheDirProperty->setValue($cache, $tmpDir);
+
+// --- get sur un cache vide --------------------------------------------------
+
+assertNull($cache->get('videos'), 'get retourne null quand aucune entrée n\'existe');
+
+// --- set puis get -----------------------------------------------------------
+
+$content = ['data' => ['video1', 'video2']];
+$cache->set('videos', ['count' => 6], $content);
+assertEquals($content, $cache->get('videos', ['count' => 6]), 'get retourne le contenu après set');
+
+// Le fichier stocké contient bien content / expires / created
+$files = glob($tmpDir . '/cache_*.json');
+assertEquals(1, count($files), 'set crée exactement un fichier de cache');
+$stored = json_decode(file_get_contents($files[0]), true);
+assertEquals($content, $stored['content'], 'le fichier stocke le contenu sous la clé "content"');
+assertTrue(isset($stored['expires']) && $stored['expires'] > time(), 'le fichier stocke une expiration future');
+assertTrue(isset($stored['created']), 'le fichier stocke la date de création');
+
+// --- Ordre des paramètres ---------------------------------------------------
+
+// ksort() dans getCacheKey : l'ordre des paramètres ne change pas la clé
+$cache->set('videos', ['b' => 2, 'a' => 1], 'ordonné');
+assertEquals(
+ 'ordonné',
+ $cache->get('videos', ['a' => 1, 'b' => 2]),
+ 'get retrouve l\'entrée malgré un ordre de paramètres différent'
+);
+assertNull(
+ $cache->get('videos', ['a' => 9]),
+ 'get retourne null pour des paramètres différents'
+);
+
+// --- Expiration -------------------------------------------------------------
+
+$cache->set('videos', ['old' => 1], 'périmé', -1); // TTL négatif : déjà expiré
+$filesBefore = count(glob($tmpDir . '/cache_*.json'));
+assertNull($cache->get('videos', ['old' => 1]), 'get retourne null pour une entrée expirée');
+assertEquals(
+ $filesBefore - 1,
+ count(glob($tmpDir . '/cache_*.json')),
+ 'get supprime le fichier expiré'
+);
+
+// --- cleanup ----------------------------------------------------------------
+
+$cache->set('cle-valide', [], 'valide', 300);
+$cache->set('cle-perimee', [], 'périmée', -10);
+$cleaned = $cache->cleanup();
+assertEquals(1, $cleaned, 'cleanup supprime uniquement les entrées expirées');
+assertEquals('valide', $cache->get('cle-valide'), 'cleanup conserve les entrées valides');
+assertNull($cache->get('cle-perimee'), 'cleanup a bien supprimé l\'entrée expirée');
+
+// --- Nettoyage du dossier temporaire ----------------------------------------
+
+foreach (glob($tmpDir . '/cache_*.json') as $file) {
+ unlink($file);
+}
+rmdir($tmpDir);
diff --git a/tests/php/format-test.php b/tests/php/format-test.php
new file mode 100644
index 0000000..191ad81
--- /dev/null
+++ b/tests/php/format-test.php
@@ -0,0 +1,145 @@
+ 'abc-123',
+ 'name' => 'Ma vidéo',
+ 'previewPath' => '/lazy/abc.jpg',
+ 'duration' => 125,
+ 'channel' => [
+ 'displayName' => 'Chaîne',
+ 'avatars' => [['path' => '/avatars/a.png']]
+ ],
+ 'views' => 42,
+ 'publishedAt' => '2024-01-01T00:00:00.000Z',
+ 'aspectRatio' => 1.78,
+ 'description' => 'Une description',
+ 'tags' => ['musique'],
+ 'isLive' => true,
+ ],
+ [
+ // Vidéo minimale : sans vignette, avatar, description, tags ni isLive
+ 'uuid' => 'def-456',
+ 'name' => 'Minimale',
+ 'duration' => 5,
+ 'channel' => ['displayName' => 'Autre chaîne'],
+ 'views' => 0,
+ 'publishedAt' => '2024-01-02T00:00:00.000Z',
+ 'aspectRatio' => 1.78,
+ ],
+];
+
+$videos = formatVideosData($rawVideos);
+
+assertEquals(2, count($videos), 'formatVideosData retourne une entrée par vidéo');
+
+assertEquals('abc-123', $videos[0]['id'], 'formatVideosData mappe uuid vers id');
+assertEquals('Ma vidéo', $videos[0]['title'], 'formatVideosData mappe name vers title');
+assertEquals(
+ PEERTUBE_URL . '/lazy/abc.jpg',
+ $videos[0]['thumbnail'],
+ 'formatVideosData préfixe la vignette avec PEERTUBE_URL'
+);
+assertEquals(
+ PEERTUBE_URL . '/avatars/a.png',
+ $videos[0]['channelAvatar'],
+ 'formatVideosData préfixe l\'avatar avec PEERTUBE_URL'
+);
+assertEquals(125, $videos[0]['duration'], 'formatVideosData conserve la durée');
+assertEquals('Chaîne', $videos[0]['channel'], 'formatVideosData mappe le nom de la chaîne');
+assertEquals(42, $videos[0]['views'], 'formatVideosData conserve les vues');
+assertEquals('2024-01-01T00:00:00.000Z', $videos[0]['date'], 'formatVideosData mappe publishedAt vers date');
+assertEquals('Une description', $videos[0]['description'], 'formatVideosData conserve la description');
+assertEquals(['musique'], $videos[0]['tags'], 'formatVideosData conserve les tags');
+assertTrue($videos[0]['isLive'], 'formatVideosData conserve isLive');
+
+assertEquals(
+ 'img/default-thumbnail.jpg',
+ $videos[1]['thumbnail'],
+ 'formatVideosData utilise une vignette par défaut si absente'
+);
+assertEquals(
+ 'img/default-avatar.png',
+ $videos[1]['channelAvatar'],
+ 'formatVideosData utilise un avatar par défaut si absent'
+);
+assertEquals('', $videos[1]['description'], 'formatVideosData met une description vide par défaut');
+assertEquals([], $videos[1]['tags'], 'formatVideosData met des tags vides par défaut');
+assertFalse($videos[1]['isLive'], 'formatVideosData met isLive à false par défaut');
+
+// --- truncateText (includes/structured-data.php) ----------------------------
+
+assertEquals('court', truncateText('court', 200), 'truncateText laisse un texte court intact');
+assertEquals('exact', truncateText('exact', 5), 'truncateText laisse un texte à la limite exacte intact');
+
+$textWithSpace = str_repeat('a', 150) . ' ' . str_repeat('b', 100); // 251 caractères
+assertEquals(
+ str_repeat('a', 150) . '...',
+ truncateText($textWithSpace, 200),
+ 'truncateText coupe au dernier espace avant la limite'
+);
+
+$textNoSpace = str_repeat('a', 250);
+assertEquals(
+ str_repeat('a', 200) . '...',
+ truncateText($textNoSpace, 200),
+ 'truncateText coupe à la limite quand il n\'y a pas d\'espace'
+);
diff --git a/tests/php/markdown-test.php b/tests/php/markdown-test.php
new file mode 100644
index 0000000..7f790a2
--- /dev/null
+++ b/tests/php/markdown-test.php
@@ -0,0 +1,88 @@
+alert("x")'),
+ 'markdown_to_html échappe le HTML brut'
+);
+assertNotContains(
+ ''),
+ 'markdown_to_html ne laisse passer aucune balise script'
+);
+
+// --- Gras et italique --------------------------------------------------------
+
+assertEquals(
+ 'gras',
+ markdown_to_html('**gras**'),
+ 'markdown_to_html convertit **texte** en '
+);
+assertEquals(
+ 'ital',
+ markdown_to_html('*ital*'),
+ 'markdown_to_html convertit *texte* en '
+);
+
+// --- Liens Markdown ----------------------------------------------------------
+
+assertEquals(
+ 'exemple',
+ markdown_to_html('[exemple](https://exemple.com)'),
+ 'markdown_to_html convertit un lien Markdown avec attributs de sécurité'
+);
+assertEquals(
+ 'site',
+ markdown_to_html('[site](exemple.com)'),
+ 'markdown_to_html ajoute http:// aux liens sans protocole'
+);
+
+// --- URLs brutes -------------------------------------------------------------
+
+assertEquals(
+ 'Visite https://exemple.com/page pour info',
+ markdown_to_html('Visite https://exemple.com/page pour info'),
+ 'markdown_to_html rend les URLs http(s) brutes cliquables'
+);
+assertEquals(
+ 'va sur o-k-i.net maintenant',
+ markdown_to_html('va sur o-k-i.net maintenant'),
+ 'markdown_to_html rend les domaines nus cliquables (avec http://)'
+);
+assertEquals(
+ 'version v1.2 dispo',
+ markdown_to_html('version v1.2 dispo'),
+ 'markdown_to_html ne transforme pas un numéro de version en lien'
+);
+
+// --- Listes ------------------------------------------------------------------
+
+// Note : la passe des listes numérotées s'applique aussi aux déjà
+// produits par la passe des puces, d'où un double enveloppement .
+// C'est le comportement actuel, verrouillé ici contre toute régression.
+assertEquals(
+ "",
+ markdown_to_html("- a\n- b"),
+ 'markdown_to_html convertit les listes à puces (double enveloppement actuel)'
+);
+assertEquals(
+ "- a
\n- b
",
+ markdown_to_html("1. a\n2. b"),
+ 'markdown_to_html convertit les listes numérotées en '
+);
+
+// --- Retours à la ligne ------------------------------------------------------
+
+assertEquals(
+ "ligne1
\nligne2",
+ markdown_to_html("ligne1\nligne2"),
+ 'markdown_to_html convertit les sauts de ligne en
'
+);
diff --git a/tests/php/run.php b/tests/php/run.php
new file mode 100644
index 0000000..8ab91c5
--- /dev/null
+++ b/tests/php/run.php
@@ -0,0 +1,116 @@
+getMessage());
+ }
+}
+
+// --- Résumé ----------------------------------------------------------------
+
+$passed = $GLOBALS['tests_passed'];
+$failed = $GLOBALS['tests_failed'];
+
+echo "\n----------------------------------------\n";
+echo "Résumé : $passed réussi(s), $failed échoué(s).\n";
+
+if ($failed > 0) {
+ echo "Tests en échec :\n";
+ foreach ($GLOBALS['tests_failures'] as $failure) {
+ echo " - $failure\n";
+ }
+ exit(1);
+}
+
+exit(0);
diff --git a/tests/php/security-test.php b/tests/php/security-test.php
new file mode 100644
index 0000000..1798e85
--- /dev/null
+++ b/tests/php/security-test.php
@@ -0,0 +1,157 @@
+'),
+ 'validateSearchQuery supprime les chevrons'
+);
+assertEquals(
+ 'recherche btest/b',
+ validateSearchQuery('recherche test'),
+ 'validateSearchQuery supprime les caractères dangereux <>"\''
+);
+assertEquals('terme', validateSearchQuery(' terme '), 'validateSearchQuery nettoie les espaces');
+assertFalse(validateSearchQuery(''), 'validateSearchQuery refuse une chaîne vide');
+assertFalse(
+ validateSearchQuery(str_repeat('a', 201)),
+ 'validateSearchQuery refuse plus de 200 caractères'
+);
+assertEquals(
+ str_repeat('a', 200),
+ validateSearchQuery(str_repeat('a', 200)),
+ 'validateSearchQuery accepte exactement 200 caractères'
+);
+
+// --- validateCategoryId -----------------------------------------------------
+
+assertEquals(1, validateCategoryId(1), 'validateCategoryId accepte la borne basse 1');
+assertEquals(20, validateCategoryId(20), 'validateCategoryId accepte la borne haute 20');
+assertEquals(5, validateCategoryId('5'), 'validateCategoryId convertit une chaîne numérique');
+assertFalse(validateCategoryId(0), 'validateCategoryId refuse 0');
+assertFalse(validateCategoryId(21), 'validateCategoryId refuse 21');
+assertFalse(validateCategoryId(-3), 'validateCategoryId refuse un nombre négatif');
+assertFalse(validateCategoryId('abc'), 'validateCategoryId refuse une chaîne non numérique');
+
+// --- validatePageNumber -----------------------------------------------------
+
+assertEquals(1, validatePageNumber(0), 'validatePageNumber remonte 0 à 1');
+assertEquals(1, validatePageNumber(-5), 'validatePageNumber remonte un négatif à 1');
+assertEquals(3, validatePageNumber(3), 'validatePageNumber conserve une page valide');
+assertEquals(2, validatePageNumber('2'), 'validatePageNumber convertit une chaîne numérique');
+assertEquals(1, validatePageNumber('abc'), 'validatePageNumber ramène une chaîne non numérique à 1');
+
+// --- generateCSRFToken / validateCSRFToken ----------------------------------
+
+$token = generateCSRFToken();
+assertContains(':', $token, 'generateCSRFToken produit le format "timestamp:hash"');
+
+[$timestamp, $hash] = explode(':', $token, 2);
+assertTrue(ctype_digit($timestamp), 'generateCSRFToken : la partie timestamp est numérique');
+assertEquals(
+ hash_hmac('sha256', $timestamp, CSRF_SECRET),
+ $hash,
+ 'generateCSRFToken : le hash est un HMAC-SHA256 du timestamp avec CSRF_SECRET'
+);
+assertTrue(validateCSRFToken($token), 'validateCSRFToken accepte un token fraîchement généré');
+
+assertFalse(validateCSRFToken(''), 'validateCSRFToken refuse une chaîne vide');
+assertFalse(validateCSRFToken('sans-deux-points'), 'validateCSRFToken refuse un token sans séparateur');
+assertFalse(validateCSRFToken('abc:' . $hash), 'validateCSRFToken refuse un timestamp non numérique');
+assertFalse(
+ validateCSRFToken($timestamp . ':' . hash_hmac('sha256', $timestamp, 'autre-secret')),
+ 'validateCSRFToken refuse un hash signé avec un autre secret'
+);
+assertFalse(
+ validateCSRFToken($timestamp . ':0' . $hash),
+ 'validateCSRFToken refuse un hash modifié'
+);
+
+// Token expiré : horodatage d'il y a 2 heures (limite : 1 heure)
+$oldTimestamp = (string) (time() - 7200);
+$oldToken = $oldTimestamp . ':' . hash_hmac('sha256', $oldTimestamp, CSRF_SECRET);
+assertFalse(validateCSRFToken($oldToken), 'validateCSRFToken refuse un token expiré (> 1 heure)');
+
+// Timestamp trop loin dans le futur (l'écart absolu est également plafonné)
+$futureTimestamp = (string) (time() + 7200);
+$futureToken = $futureTimestamp . ':' . hash_hmac('sha256', $futureTimestamp, CSRF_SECRET);
+assertFalse(validateCSRFToken($futureToken), 'validateCSRFToken refuse un timestamp trop futur');
+
+// --- isValidPeerTubeUrl (includes/config.php) -------------------------------
+
+assertTrue(isValidPeerTubeUrl('https://peertube.example.com'), 'isValidPeerTubeUrl accepte une URL HTTPS publique');
+assertTrue(isValidPeerTubeUrl('http://peertube.example.com'), 'isValidPeerTubeUrl accepte HTTP (développement)');
+assertTrue(isValidPeerTubeUrl('https://8.8.8.8'), 'isValidPeerTubeUrl accepte une IP publique');
+assertFalse(isValidPeerTubeUrl('ftp://example.com'), 'isValidPeerTubeUrl refuse un schéma non HTTP(S)');
+assertFalse(isValidPeerTubeUrl('pas-une-url'), 'isValidPeerTubeUrl refuse une chaîne mal formée');
+assertFalse(isValidPeerTubeUrl('https://localhost'), 'isValidPeerTubeUrl refuse localhost');
+assertFalse(isValidPeerTubeUrl('http://127.0.0.1'), 'isValidPeerTubeUrl refuse 127.0.0.1');
+assertFalse(isValidPeerTubeUrl('http://192.168.1.1'), 'isValidPeerTubeUrl refuse une IP privée');
+assertFalse(isValidPeerTubeUrl('http://10.0.0.5'), 'isValidPeerTubeUrl refuse une IP privée (10.x)');
+assertFalse(
+ isValidPeerTubeUrl('https://metadata.google.internal'),
+ 'isValidPeerTubeUrl refuse metadata.google.internal'
+);
+
+// --- isValidApiEndpoint (includes/config.php) -------------------------------
+
+assertTrue(isValidApiEndpoint('videos'), 'isValidApiEndpoint accepte "videos"');
+assertTrue(isValidApiEndpoint('videos/categories'), 'isValidApiEndpoint accepte "videos/categories"');
+assertTrue(isValidApiEndpoint('search/videos'), 'isValidApiEndpoint accepte "search/videos"');
+assertTrue(isValidApiEndpoint('accounts'), 'isValidApiEndpoint accepte "accounts"');
+assertTrue(
+ isValidApiEndpoint('videos/9c5de94d-8e1a-4b3c-9d2e-1234567890ab'),
+ 'isValidApiEndpoint accepte un endpoint vidéo dynamique'
+);
+assertTrue(
+ isValidApiEndpoint('videos/abc/comment-threads'),
+ 'isValidApiEndpoint accepte les fils de commentaires'
+);
+assertTrue(
+ isValidApiEndpoint('accounts/annu_kute_ced/videos'),
+ 'isValidApiEndpoint accepte les vidéos d\'un compte'
+);
+assertFalse(isValidApiEndpoint('../config'), 'isValidApiEndpoint refuse le path traversal');
+assertFalse(isValidApiEndpoint('videos/../x'), 'isValidApiEndpoint refuse ".." dans le chemin');
+assertFalse(isValidApiEndpoint('videos//categories'), 'isValidApiEndpoint refuse un double slash');
+assertFalse(isValidApiEndpoint('wp-admin'), 'isValidApiEndpoint refuse un endpoint hors liste blanche');
+assertFalse(isValidApiEndpoint('videos.json'), 'isValidApiEndpoint refuse les caractères non autorisés (.)');
+assertFalse(isValidApiEndpoint('videos?count=1'), 'isValidApiEndpoint refuse une query string');
+assertFalse(isValidApiEndpoint(''), 'isValidApiEndpoint refuse une chaîne vide');
+
+// --- getCspNonce ------------------------------------------------------------
+
+$nonce = getCspNonce();
+assertTrue(is_string($nonce) && $nonce !== '', 'getCspNonce retourne une chaîne non vide');
+assertEquals(16, strlen(base64_decode($nonce, true)), 'getCspNonce est un base64 de 16 octets aléatoires');
+assertEquals($nonce, getCspNonce(), 'getCspNonce retourne le même nonce durant toute la requête');