first commit
This commit is contained in:
212
tests/Shared/MigratorTest.php
Normal file
212
tests/Shared/MigratorTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user