*/ 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->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->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->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)); } }