first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace Tests\Shared;
use App\Shared\Database\Migrator;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour Migrator.
*
* Vérifie que run() crée la table de suivi, exécute uniquement les migrations
* en attente (pas celles déjà appliquées), et appelle syncFtsIndex().
*
* Les fichiers de migration réels ne sont pas chargés : Migrator::run() est
* testé via une base SQLite en mémoire, ce qui est plus fiable qu'un mock
* de exec() et permet de vérifier l'état réel de la base après exécution.
*
* syncFtsIndex() requiert les tables posts, users et posts_fts — elles sont
* créées minimalement avant chaque test qui en a besoin.
*/
final class MigratorTest extends TestCase
{
private PDO $db;
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// strip_tags() doit être disponible comme fonction SQLite
// (enregistrée dans container.php en production)
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
}
// ── createMigrationTable ───────────────────────────────────────
/**
* run() doit créer la table 'migrations' si elle n'existe pas.
*/
public function testRunCreatesMigrationsTable(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$stmt = $this->db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'");
$this->assertNotFalse($stmt->fetchColumn(), 'La table migrations doit exister après run()');
}
/**
* run() est idempotent : appeler run() deux fois ne génère pas d'erreur.
*/
public function testRunIsIdempotent(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
Migrator::run($this->db); // deuxième appel — ne doit pas planter
$this->addToAssertionCount(1);
}
// ── runPendingMigrations ───────────────────────────────────────
/**
* Une migration déjà enregistrée dans la table migrations
* ne doit pas être rejouée.
*/
public function testAlreadyAppliedMigrationIsSkipped(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$before = $this->countMigrations();
// Simuler une migration future déjà appliquée (version fictive
// qui ne correspond à aucun fichier réel — ne génère pas de conflit UNIQUE)
$stmt = $this->db->prepare("INSERT INTO migrations (version, run_at) VALUES (:v, :r)");
$stmt->execute([':v' => '999_future_migration', ':r' => date('Y-m-d H:i:s')]);
Migrator::run($this->db);
$after = $this->countMigrations();
// Le nombre de migrations enregistrées ne doit pas avoir changé
$this->assertSame($before + 1, $after);
}
/**
* La table migrations doit contenir une entrée par migration exécutée.
*/
public function testRunRecordsMigrationsInTable(): void
{
$this->createMinimalSchema();
Migrator::run($this->db);
$count = $this->countMigrations();
// Le projet a au moins une migration (001_create_users)
$this->assertGreaterThan(0, $count, 'Au moins une migration doit être enregistrée');
}
// ── syncFtsIndex ───────────────────────────────────────────────
/**
* syncFtsIndex() doit insérer dans posts_fts les articles
* absents de l'index après run().
*/
public function testSyncFtsIndexInsertsUnindexedPosts(): void
{
// Exécuter les vraies migrations pour avoir le schéma complet
Migrator::run($this->db);
// Insérer un article directement en base (bypass des triggers FTS)
$this->db->exec("
INSERT INTO users (id, username, email, password_hash, role, created_at)
VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')
");
$this->db->exec("
INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at)
VALUES (1, 'Test', '<p>Contenu</p>', 'test', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')
");
// Supprimer l'entrée FTS pour simuler un article non indexé
$this->db->exec("DELETE FROM posts_fts WHERE rowid = 1");
// run() doit réindexer cet article via syncFtsIndex
Migrator::run($this->db);
$stmt = $this->db->query('SELECT rowid FROM posts_fts WHERE rowid = 1');
$this->assertNotFalse($stmt->fetchColumn(), "L'article doit être présent dans posts_fts après run()");
}
/**
* syncFtsIndex() ne doit pas créer de doublon pour un article
* déjà présent dans l'index.
*/
public function testSyncFtsIndexDoesNotDuplicateIndexedPosts(): void
{
Migrator::run($this->db);
// Insérer un article — le trigger FTS l'indexe automatiquement
$this->db->exec("
INSERT INTO users (id, username, email, password_hash, role, created_at)
VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')
");
$this->db->exec("
INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at)
VALUES (1, 'Test', '<p>Contenu</p>', 'test', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')
");
$before = (int) $this->db->query('SELECT COUNT(*) FROM posts_fts')->fetchColumn();
// Deuxième run() — ne doit pas dupliquer l'entrée FTS
Migrator::run($this->db);
$after = (int) $this->db->query('SELECT COUNT(*) FROM posts_fts')->fetchColumn();
$this->assertSame($before, $after);
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée le schéma minimal requis par run() quand les vraies migrations
* ne sont pas chargées (posts, users, posts_fts pour syncFtsIndex).
*
* Utilisé uniquement pour les tests qui ne veulent pas dépendre
* des fichiers de migration réels.
*/
private function createMinimalSchema(): void
{
$this->db->exec('
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT "user",
created_at DATETIME NOT NULL
)
');
$this->db->exec('
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT "",
slug TEXT NOT NULL UNIQUE,
author_id INTEGER,
category_id INTEGER,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
');
$this->db->exec("
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts
USING fts5(title, content, author_username, content='posts', content_rowid='id')
");
}
private function countMigrations(): int
{
return (int) $this->db->query('SELECT COUNT(*) FROM migrations')->fetchColumn();
}
}