548 lines
18 KiB
PHP
548 lines
18 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Post;
|
|
|
|
use App\Post\Post;
|
|
use App\Post\PostRepository;
|
|
use PDO;
|
|
use PDOStatement;
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Tests unitaires pour PostRepository.
|
|
*
|
|
* Vérifie que chaque méthode du dépôt construit le bon SQL,
|
|
* lie les bons paramètres et retourne les bonnes valeurs.
|
|
*
|
|
* PDO et PDOStatement sont mockés pour isoler complètement
|
|
* le dépôt de la base de données.
|
|
*/
|
|
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
|
final class PostRepositoryTest extends TestCase
|
|
{
|
|
/** @var PDO&MockObject */
|
|
private PDO $db;
|
|
|
|
private PostRepository $repository;
|
|
|
|
/**
|
|
* Données représentant une ligne article en base de données (avec JOINs).
|
|
*
|
|
* @var array<string, mixed>
|
|
*/
|
|
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' => '<p>Contenu de test</p>',
|
|
'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<array<string,mixed>> $rows Lignes retournées par fetchAll()
|
|
* @param array<string,mixed>|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->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->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));
|
|
}
|
|
}
|