first commit

This commit is contained in:
julien
2026-03-16 01:47:07 +01:00
commit 8f7e61bda0
185 changed files with 27731 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use App\Category\CategoryController;
use App\Category\CategoryServiceInterface;
use App\Shared\Http\FlashServiceInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour CategoryController.
*
* Couvre index(), create() et delete() :
* rendu de la liste, création réussie, erreur de création,
* suppression avec catégorie introuvable, succès et erreur métier.
*/
final class CategoryControllerTest extends ControllerTestCase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var CategoryServiceInterface&MockObject */
private CategoryServiceInterface $categoryService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
private CategoryController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->categoryService = $this->createMock(CategoryServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->controller = new CategoryController(
$this->view,
$this->categoryService,
$this->flash,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit rendre la vue avec la liste des catégories.
*/
public function testIndexRendersWithCategories(): void
{
$this->categoryService->method('findAll')->willReturn([]);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/categories/index.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->index($this->makeGet('/admin/categories'), $this->makeResponse());
$this->assertStatus($res, 200);
}
// ── create ───────────────────────────────────────────────────────
/**
* create() doit flasher un succès et rediriger en cas de création réussie.
*/
public function testCreateRedirectsWithSuccessFlash(): void
{
$this->categoryService->method('create')->willReturn(1);
$this->flash->expects($this->once())->method('set')
->with('category_success', $this->stringContains('PHP'));
$req = $this->makePost('/admin/categories/create', ['name' => 'PHP']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* create() doit flasher une erreur si le service lève une InvalidArgumentException.
*/
public function testCreateRedirectsWithErrorOnInvalidArgument(): void
{
$this->categoryService->method('create')
->willThrowException(new \InvalidArgumentException('Catégorie déjà existante'));
$this->flash->expects($this->once())->method('set')
->with('category_error', 'Catégorie déjà existante');
$req = $this->makePost('/admin/categories/create', ['name' => 'Duplicate']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* create() doit flasher une erreur générique pour toute autre exception.
*/
public function testCreateRedirectsWithGenericErrorOnUnexpectedException(): void
{
$this->categoryService->method('create')
->willThrowException(new \RuntimeException('DB error'));
$this->flash->expects($this->once())->method('set')
->with('category_error', $this->stringContains('inattendue'));
$req = $this->makePost('/admin/categories/create', ['name' => 'PHP']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/categories');
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit flasher une erreur et rediriger si la catégorie est introuvable.
*/
public function testDeleteRedirectsWithErrorWhenNotFound(): void
{
$this->categoryService->method('findById')->willReturn(null);
$this->flash->expects($this->once())->method('set')
->with('category_error', 'Catégorie introuvable');
$res = $this->controller->delete(
$this->makePost('/admin/categories/delete/99'),
$this->makeResponse(),
['id' => '99'],
);
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* delete() doit flasher un succès et rediriger en cas de suppression réussie.
*/
public function testDeleteRedirectsWithSuccessFlash(): void
{
$category = new Category(3, 'PHP', 'php');
$this->categoryService->method('findById')->willReturn($category);
$this->flash->expects($this->once())->method('set')
->with('category_success', $this->stringContains('PHP'));
$res = $this->controller->delete(
$this->makePost('/admin/categories/delete/3'),
$this->makeResponse(),
['id' => '3'],
);
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* delete() doit flasher une erreur si le service refuse la suppression
* (ex: des articles sont rattachés à la catégorie).
*/
public function testDeleteRedirectsWithErrorWhenServiceRefuses(): void
{
$category = new Category(3, 'PHP', 'php');
$this->categoryService->method('findById')->willReturn($category);
$this->categoryService->method('delete')
->willThrowException(new \InvalidArgumentException('Des articles utilisent cette catégorie'));
$this->flash->expects($this->once())->method('set')
->with('category_error', 'Des articles utilisent cette catégorie');
$res = $this->controller->delete(
$this->makePost('/admin/categories/delete/3'),
$this->makeResponse(),
['id' => '3'],
);
$this->assertRedirectTo($res, '/admin/categories');
}
/**
* delete() doit passer l'identifiant de route au service findById().
*/
public function testDeletePassesCorrectIdToService(): void
{
$this->categoryService->expects($this->once())
->method('findById')
->with(7)
->willReturn(null);
$this->flash->method('set');
$this->controller->delete(
$this->makePost('/admin/categories/delete/7'),
$this->makeResponse(),
['id' => '7'],
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use PHPUnit\Framework\TestCase;
final class CategoryModelTest extends TestCase
{
public function testConstructAndGettersExposeCategoryData(): void
{
$category = new Category(4, 'PHP', 'php');
self::assertSame(4, $category->getId());
self::assertSame('PHP', $category->getName());
self::assertSame('php', $category->getSlug());
}
public function testFromArrayHydratesCategory(): void
{
$category = Category::fromArray([
'id' => '6',
'name' => 'Tests',
'slug' => 'tests',
]);
self::assertSame(6, $category->getId());
self::assertSame('Tests', $category->getName());
self::assertSame('tests', $category->getSlug());
}
public function testValidationRejectsEmptyName(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le nom de la catégorie ne peut pas être vide');
new Category(1, '', 'slug');
}
public function testValidationRejectsTooLongName(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le nom de la catégorie ne peut pas dépasser 100 caractères');
new Category(1, str_repeat('a', 101), 'slug');
}
}

View File

@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use App\Category\CategoryRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour CategoryRepository.
*
* 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.
*/
final class CategoryRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private CategoryRepository $repository;
/**
* Données représentant une ligne catégorie en base de données.
*
* @var array<string, mixed>
*/
private array $rowPhp;
protected function setUp(): void
{
$this->db = $this->createMock(PDO::class);
$this->repository = new CategoryRepository($this->db);
$this->rowPhp = [
'id' => 1,
'name' => 'PHP',
'slug' => 'php',
];
}
// ── Helpers ────────────────────────────────────────────────────
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;
}
private function stmtForScalar(mixed $value): PDOStatement&MockObject
{
$stmt = $this->createMock(PDOStatement::class);
$stmt->method('execute')->willReturn(true);
$stmt->method('fetchColumn')->willReturn($value);
return $stmt;
}
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() retourne un tableau vide si aucune catégorie n'existe.
*/
public function testFindAllReturnsEmptyArrayWhenNone(): void
{
$stmt = $this->stmtForRead([]);
$this->db->method('query')->willReturn($stmt);
$this->assertSame([], $this->repository->findAll());
}
/**
* findAll() retourne des instances Category hydratées.
*/
public function testFindAllReturnsCategoryInstances(): void
{
$stmt = $this->stmtForRead([$this->rowPhp]);
$this->db->method('query')->willReturn($stmt);
$result = $this->repository->findAll();
$this->assertCount(1, $result);
$this->assertInstanceOf(Category::class, $result[0]);
$this->assertSame('PHP', $result[0]->getName());
$this->assertSame('php', $result[0]->getSlug());
}
/**
* findAll() interroge la table 'categories' triée par name ASC.
*/
public function testFindAllQueriesWithAlphabeticOrder(): void
{
$stmt = $this->stmtForRead([]);
$this->db->expects($this->once())
->method('query')
->with($this->logicalAnd(
$this->stringContains('categories'),
$this->stringContains('name ASC'),
))
->willReturn($stmt);
$this->repository->findAll();
}
// ── findById ───────────────────────────────────────────────────
/**
* findById() retourne null si la catégorie est absente.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$stmt = $this->stmtForRead(row: false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertNull($this->repository->findById(99));
}
/**
* findById() retourne une instance Category si la catégorie existe.
*/
public function testFindByIdReturnsCategoryWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowPhp);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findById(1);
$this->assertInstanceOf(Category::class, $result);
$this->assertSame(1, $result->getId());
$this->assertSame('PHP', $result->getName());
}
/**
* 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' => 42]);
$this->repository->findById(42);
}
// ── 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('inconnu'));
}
/**
* findBySlug() retourne une instance Category si le slug existe.
*/
public function testFindBySlugReturnsCategoryWhenFound(): void
{
$stmt = $this->stmtForRead(row: $this->rowPhp);
$this->db->method('prepare')->willReturn($stmt);
$result = $this->repository->findBySlug('php');
$this->assertInstanceOf(Category::class, $result);
$this->assertSame('php', $result->getSlug());
}
/**
* 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' => 'php']);
$this->repository->findBySlug('php');
}
// ── create ─────────────────────────────────────────────────────
/**
* create() prépare un INSERT avec le nom et le slug de la catégorie.
*/
public function testCreateCallsInsertWithNameAndSlug(): void
{
$category = Category::fromArray($this->rowPhp);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')
->with($this->stringContains('INSERT INTO categories'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with($this->callback(fn (array $data): bool =>
$data[':name'] === $category->getName()
&& $data[':slug'] === $category->getSlug()
));
$this->db->method('lastInsertId')->willReturn('1');
$this->repository->create($category);
}
/**
* create() retourne l'identifiant généré par la base de données.
*/
public function testCreateReturnsGeneratedId(): void
{
$category = Category::fromArray($this->rowPhp);
$stmt = $this->stmtForWrite();
$this->db->method('prepare')->willReturn($stmt);
$this->db->method('lastInsertId')->willReturn('7');
$this->assertSame(7, $this->repository->create($category));
}
// ── 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 categories'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 3]);
$this->repository->delete(3);
}
/**
* 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(3));
}
/**
* delete() retourne 0 si la catégorie n'existait plus.
*/
public function testDeleteReturnsZeroWhenNotFound(): void
{
$stmt = $this->stmtForWrite(0);
$this->db->method('prepare')->willReturn($stmt);
$this->assertSame(0, $this->repository->delete(99));
}
// ── nameExists ─────────────────────────────────────────────────
/**
* nameExists() retourne true si le nom existe déjà.
*/
public function testNameExistsReturnsTrueWhenTaken(): void
{
$stmt = $this->stmtForScalar(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertTrue($this->repository->nameExists('PHP'));
}
/**
* nameExists() retourne false si le nom est disponible.
*/
public function testNameExistsReturnsFalseWhenFree(): void
{
$stmt = $this->stmtForScalar(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertFalse($this->repository->nameExists('Nouveau'));
}
/**
* nameExists() exécute avec le bon nom.
*/
public function testNameExistsQueriesWithCorrectName(): void
{
$stmt = $this->stmtForScalar(false);
$this->db->method('prepare')->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':name' => 'PHP']);
$this->repository->nameExists('PHP');
}
// ── hasPost ────────────────────────────────────────────────────
/**
* hasPost() retourne true si au moins un article référence la catégorie.
*/
public function testHasPostReturnsTrueWhenPostAttached(): void
{
$stmt = $this->stmtForScalar(1);
$this->db->method('prepare')->willReturn($stmt);
$this->assertTrue($this->repository->hasPost(1));
}
/**
* hasPost() retourne false si aucun article ne référence la catégorie.
*/
public function testHasPostReturnsFalseWhenNoPost(): void
{
$stmt = $this->stmtForScalar(false);
$this->db->method('prepare')->willReturn($stmt);
$this->assertFalse($this->repository->hasPost(1));
}
/**
* hasPost() interroge la table 'posts' avec le bon category_id.
*/
public function testHasPostQueriesPostsTableWithCorrectId(): void
{
$stmt = $this->stmtForScalar(false);
$this->db->expects($this->once())
->method('prepare')
->with($this->stringContains('posts'))
->willReturn($stmt);
$stmt->expects($this->once())
->method('execute')
->with([':id' => 5]);
$this->repository->hasPost(5);
}
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Tests\Category;
use App\Category\Category;
use App\Category\CategoryRepositoryInterface;
use App\Category\CategoryService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour CategoryService.
*
* Vérifie la création (génération de slug, unicité du nom, validation du modèle)
* et la suppression (blocage si articles rattachés).
* Le repository est remplacé par un mock pour isoler le service.
*/
final class CategoryServiceTest extends TestCase
{
/** @var CategoryRepositoryInterface&MockObject */
private CategoryRepositoryInterface $repository;
private CategoryService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(CategoryRepositoryInterface::class);
$this->service = new CategoryService($this->repository);
}
// ── create ─────────────────────────────────────────────────────
/**
* create() doit générer le slug depuis le nom et persister la catégorie.
*/
public function testCreateGeneratesSlugAndPersists(): void
{
$this->repository->method('nameExists')->willReturn(false);
$this->repository->expects($this->once())
->method('create')
->with($this->callback(fn (Category $c) =>
$c->getName() === 'Développement web'
&& $c->getSlug() === 'developpement-web'
))
->willReturn(1);
$id = $this->service->create('Développement web');
$this->assertSame(1, $id);
}
/**
* create() doit trimmer le nom avant de générer le slug.
*/
public function testCreateTrimsName(): void
{
$this->repository->method('nameExists')->willReturn(false);
$this->repository->expects($this->once())
->method('create')
->with($this->callback(fn (Category $c) => $c->getName() === 'PHP'))
->willReturn(2);
$this->service->create(' PHP ');
}
/**
* create() doit lever InvalidArgumentException si le slug généré est vide.
*/
public function testCreateNonAsciiNameThrowsException(): void
{
$this->repository->expects($this->never())->method('create');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('slug URL valide');
$this->service->create('日本語');
}
/**
* create() doit lever InvalidArgumentException si le nom est déjà utilisé.
*/
public function testCreateDuplicateNameThrowsException(): void
{
$this->repository->method('nameExists')->willReturn(true);
$this->repository->expects($this->never())->method('create');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('déjà utilisé');
$this->service->create('PHP');
}
/**
* create() doit lever InvalidArgumentException si le nom est vide.
*/
public function testCreateEmptyNameThrowsException(): void
{
$this->repository->method('nameExists')->willReturn(false);
$this->repository->expects($this->never())->method('create');
$this->expectException(\InvalidArgumentException::class);
$this->service->create('');
}
/**
* create() doit lever InvalidArgumentException si le nom dépasse 100 caractères.
*/
public function testCreateNameTooLongThrowsException(): void
{
$longName = str_repeat('a', 101);
$this->repository->method('nameExists')->willReturn(false);
$this->repository->expects($this->never())->method('create');
$this->expectException(\InvalidArgumentException::class);
$this->service->create($longName);
}
// ── delete ─────────────────────────────────────────────────────
/**
* delete() doit supprimer la catégorie si elle ne contient aucun article.
*/
public function testDeleteSucceedsWhenNoPosts(): void
{
$category = new Category(5, 'PHP', 'php');
$this->repository->method('hasPost')->with(5)->willReturn(false);
$this->repository->expects($this->once())->method('delete')->with(5);
$this->service->delete($category);
}
/**
* delete() doit lever InvalidArgumentException si des articles sont rattachés.
*/
public function testDeleteBlockedWhenPostsAttached(): void
{
$category = new Category(5, 'PHP', 'php');
$this->repository->method('hasPost')->with(5)->willReturn(true);
$this->repository->expects($this->never())->method('delete');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('contient des articles');
$this->service->delete($category);
}
// ── Lectures déléguées ─────────────────────────────────────────
/**
* findAll() doit déléguer au repository et retourner son résultat.
*/
public function testFindAllDelegatesToRepository(): void
{
$cats = [new Category(1, 'PHP', 'php'), new Category(2, 'CSS', 'css')];
$this->repository->method('findAll')->willReturn($cats);
$this->assertSame($cats, $this->service->findAll());
}
/**
* findById() doit retourner null si la catégorie n'existe pas.
*/
public function testFindByIdReturnsNullWhenMissing(): void
{
$this->repository->method('findById')->willReturn(null);
$this->assertNull($this->service->findById(99));
}
/**
* findBySlug() doit retourner la catégorie correspondante.
*/
public function testFindBySlugReturnsCategoryWhenFound(): void
{
$cat = new Category(3, 'PHP', 'php');
$this->repository->method('findBySlug')->with('php')->willReturn($cat);
$this->assertSame($cat, $this->service->findBySlug('php'));
}
}