*/
private array $rowPost;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new PostRepository($this->db);
$this->rowPost = [
'id' => 1,
'title' => 'Introduction à PHP',
'content' => '
Contenu de test
',
'slug' => 'introduction-a-php',
'author_id' => 2,
'author_username' => 'alice',
'category_id' => 3,
'category_name' => 'PHP',
'category_slug' => 'php',
'created_at' => '2024-01-01 00:00:00',
'updated_at' => '2024-01-02 00:00:00',
];
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un PDOStatement mock préconfiguré pour les requêtes de lecture.
*
* @param list> $rows Lignes retournées par fetchAll()
* @param array|false $row Ligne retournée par fetch()
*/
private function stmtForRead(array $rows = [], array|false $row = false): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchAll')->willReturn($rows);
$stmt->method('fetch')->willReturn($row);
return $stmt;
}
/**
* Crée un PDOStatement mock préconfiguré pour les requêtes d'écriture.
*/
private function stmtForWrite(int $rowCount = 1): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('rowCount')->willReturn($rowCount);
return $stmt;
}
// ── findAll ────────────────────────────────────────────────────
/**
* findAll() sans filtre utilise query() (pas de paramètre à lier)
* et retourne un tableau vide si aucun article n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() retourne des instances Post hydratées.
*/
public function testFindAllReturnsPostInstances(): void
{
$stmt = $this->stmtForRead([$this->rowPost]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(Post::class, $result[0]);
$this->assertSame('Introduction à PHP', $result[0]->getTitle());
$this->assertSame('alice', $result[0]->getAuthorUsername());
}
/**
* findAll() sans filtre appelle query() et non prepare()
* (pas de paramètre à lier).
*/
public function testFindAllWithoutFilterUsesQueryNotPrepare(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())->method('query')->willReturn($stmt);
$this->db->expects($this->never())->method('prepare');
$this->repository->findAll();
}
/**
* findAll() avec categoryId prépare un SQL contenant la clause WHERE category_id
* et exécute avec le bon paramètre.
*/
public function testFindAllWithCategoryIdPassesFilterCorrectly(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('category_id'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool =>
isset($p[':category_id']) && $p[':category_id'] === 3
));
$this->repository->findAll(3);
}
// ── findRecent ─────────────────────────────────────────────────
/**
* findRecent() retourne un tableau vide si aucun article n'existe.
*/
public function testFindRecentReturnsEmptyArray(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findRecent(5));
}
/**
* findRecent() lie la limite via bindValue() avec le bon entier.
*/
public function testFindRecentPassesLimitCorrectly(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('bindValue')
->with(':limit', 10, PDO::PARAM_INT);
$this->repository->findRecent(10);
}
/**
* findRecent() retourne des instances Post hydratées.
*/
public function testFindRecentReturnsPostInstances(): void
{
$stmt = $this->stmtForRead([$this->rowPost]);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findRecent(1);
$this->assertCount(1, $result);
$this->assertInstanceOf(Post::class, $result[0]);
}
// ── findByUserId ───────────────────────────────────────────────
/**
* findByUserId() retourne un tableau vide si l'utilisateur n'a aucun article.
*/
public function testFindByUserIdReturnsEmptyArray(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame([], $this->repository->findByUserId(99));
}
/**
* findByUserId() exécute avec le bon author_id.
*/
public function testFindByUserIdPassesCorrectParameters(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool =>
isset($p[':author_id']) && $p[':author_id'] === 7
));
$this->repository->findByUserId(7);
}
/**
* findByUserId() avec categoryId exécute avec les deux filtres.
*/
public function testFindByUserIdWithCategoryIdPassesBothFilters(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $p): bool =>
isset($p[':author_id'], $p[':category_id'])
&& $p[':author_id'] === 7
&& $p[':category_id'] === 3
));
$this->repository->findByUserId(7, 3);
}
// ── findBySlug ─────────────────────────────────────────────────
/**
* findBySlug() retourne null si le slug est absent.
*/
public function testFindBySlugReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findBySlug('inexistant'));
}
/**
* findBySlug() retourne une instance Post si le slug existe.
*/
public function testFindBySlugReturnsPostWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowPost);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findBySlug('introduction-a-php');
$this->assertInstanceOf(Post::class, $result);
$this->assertSame('introduction-a-php', $result->getStoredSlug());
}
/**
* findBySlug() exécute avec le bon slug.
*/
public function testFindBySlugQueriesWithCorrectSlug(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':slug' => 'mon-article']);
$this->repository->findBySlug('mon-article');
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si l'article est absent.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(999));
}
/**
* findById() retourne une instance Post si l'article existe.
*/
public function testFindByIdReturnsPostWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowPost);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(Post::class, $result);
$this->assertSame(1, $result->getId());
}
/**
* findById() exécute avec le bon identifiant.
*/
public function testFindByIdQueriesWithCorrectId(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 12]);
$this->repository->findById(12);
}
// ── create ─────────────────────────────────────────────────────
/**
* create() prépare un INSERT avec les bonnes colonnes.
*/
public function testCreateCallsInsertWithCorrectData(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite();
$this->db->expects($this->once())->method('prepare')
->with($this->stringContains('INSERT INTO posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($post): bool {
return $data[':title'] === $post->getTitle()
&& $data[':content'] === $post->getContent()
&& $data[':slug'] === 'introduction-a-php'
&& $data[':author_id'] === 5
&& $data[':category_id'] === 3
&& isset($data[':created_at'], $data[':updated_at']);
}));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($post, 'introduction-a-php', 5, 3);
}
/**
* create() retourne l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('42');
$this->assertSame(42, $this->repository->create($post, 'slug', 1, null));
}
/**
* create() accepte un categoryId null (article sans catégorie).
*/
public function testCreateAcceptsCategoryIdNull(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $data): bool =>
array_key_exists(':category_id', $data) && $data[':category_id'] === null
));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($post, 'slug', 1, null);
}
// ── update ─────────────────────────────────────────────────────
/**
* update() prépare un UPDATE avec les bonnes colonnes et le bon identifiant.
*/
public function testUpdateCallsUpdateWithCorrectData(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite(1);
$this->db->expects($this->once())->method('prepare')
->with($this->stringContains('UPDATE posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(function (array $data) use ($post): bool {
return $data[':title'] === $post->getTitle()
&& $data[':content'] === $post->getContent()
&& $data[':slug'] === 'nouveau-slug'
&& $data[':category_id'] === 3
&& $data[':id'] === 1
&& isset($data[':updated_at']);
}));
$this->repository->update(1, $post, 'nouveau-slug', 3);
}
/**
* update() retourne le nombre de lignes affectées.
*/
public function testUpdateReturnsAffectedRowCount(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(1, $this->repository->update(1, $post, 'slug', null));
}
/**
* update() retourne 0 si aucune ligne n'est modifiée (article supprimé entre-temps).
*/
public function testUpdateReturnsZeroWhenNoRowAffected(): void
{
$post = Post::fromArray($this->rowPost);
$stmt = $this->stmtForWrite(0);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(0, $this->repository->update(1, $post, 'slug', null));
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() prépare un DELETE avec le bon identifiant.
*/
public function testDeleteCallsDeleteWithCorrectId(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('DELETE FROM posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 6]);
$this->repository->delete(6);
}
/**
* delete() retourne le nombre de lignes supprimées.
*/
public function testDeleteReturnsDeletedRowCount(): void
{
$stmt = $this->stmtForWrite(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(1, $this->repository->delete(6));
}
/**
* delete() retourne 0 si l'article n'existait plus.
*/
public function testDeleteReturnsZeroWhenNotFound(): void
{
$stmt = $this->stmtForWrite(0);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(0, $this->repository->delete(99));
}
// ── slugExists ─────────────────────────────────────────────────
/**
* slugExists() retourne false si le slug n'existe pas en base.
*/
public function testSlugExistsReturnsFalseWhenMissing(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertFalse($this->repository->slugExists('slug-libre'));
}
/**
* slugExists() retourne true si le slug est pris par un autre article.
*/
public function testSlugExistsReturnsTrueWhenTaken(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn('1');
$this->db->method('prepare')->willReturn($stmt);
$this->assertTrue($this->repository->slugExists('slug-pris'));
}
/**
* slugExists() retourne false si le slug appartient à l'article exclu (mise à jour).
*/
public function testSlugExistsReturnsFalseForExcludedPost(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn('5');
$this->db->method('prepare')->willReturn($stmt);
$this->assertFalse($this->repository->slugExists('mon-slug', 5));
}
/**
* slugExists() retourne true si le slug appartient à un article différent de l'exclu.
*/
public function testSlugExistsReturnsTrueForOtherPost(): void
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn('3');
$this->db->method('prepare')->willReturn($stmt);
$this->assertTrue($this->repository->slugExists('mon-slug', 5));
}
}