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 de baseline (001_create_schema) $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', '
Contenu
', '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', 'Contenu
', '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(); } }