Files
slim-blog/tests/Post/PostControllerTest.php
2026-03-16 16:58:54 +01:00

508 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Category\Category;
use App\Category\CategoryServiceInterface;
use App\Post\Post;
use App\Post\Http\PostController;
use App\Post\PostServiceInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginatedResult;
use PHPUnit\Framework\MockObject\MockObject;
use Slim\Exception\HttpNotFoundException;
use Tests\ControllerTestBase;
/**
* Tests unitaires pour PostController.
*
* Couvre les 7 actions publiques :
* - index() : page d'accueil, recherche FTS, filtre catégorie
* - show() : article trouvé, article introuvable (404)
* - admin() : admin/éditeur voit tout, utilisateur ordinaire voit le sien
* - form() : formulaire nouveau, formulaire édition, droits insuffisants, 404
* - create() : succès, erreur de validation, erreur inattendue
* - update() : 404, droits insuffisants, succès, erreur de validation
* - delete() : 404, droits insuffisants, succès
*/
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
final class PostControllerTest extends ControllerTestBase
{
/** @var \Slim\Views\Twig&MockObject */
private \Slim\Views\Twig $view;
/** @var PostServiceInterface&MockObject */
private PostServiceInterface $postService;
/** @var CategoryServiceInterface&MockObject */
private CategoryServiceInterface $categoryService;
/** @var FlashServiceInterface&MockObject */
private FlashServiceInterface $flash;
/** @var SessionManagerInterface&MockObject */
private SessionManagerInterface $sessionManager;
private PostController $controller;
protected function setUp(): void
{
$this->view = $this->makeTwigMock();
$this->postService = $this->createMock(PostServiceInterface::class);
$this->categoryService = $this->createMock(CategoryServiceInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->categoryService->method('findAll')->willReturn([]);
$this->controller = new PostController(
$this->view,
$this->postService,
$this->categoryService,
$this->flash,
$this->sessionManager,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit appeler getAllPosts() sans filtre par défaut.
*/
public function testIndexCallsGetAllPostsWithNoFilter(): void
{
$this->postService->expects($this->once())->method('getAllPostsPaginated')->with(1, 6, null)->willReturn(new PaginatedResult([], 0, 1, 6));
$this->postService->expects($this->never())->method('searchPostsPaginated');
$res = $this->controller->index($this->makeGet('/'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* index() doit appeler searchPosts() quand un paramètre q est fourni.
*/
public function testIndexCallsSearchPostsWhenQueryParamPresent(): void
{
$this->postService->expects($this->once())
->method('searchPostsPaginated')
->with('php', 1, 6, null)
->willReturn(new PaginatedResult([], 0, 1, 6));
$this->postService->expects($this->never())->method('getAllPostsPaginated');
$this->controller->index($this->makeGet('/', ['q' => 'php']), $this->makeResponse());
}
/**
* index() doit résoudre le slug de catégorie et filtrer si le paramètre categorie est fourni.
*/
public function testIndexFiltersByCategoryWhenSlugProvided(): void
{
$category = new Category(3, 'PHP', 'php');
$this->categoryService->expects($this->once())->method('findBySlug')->with('php')->willReturn($category);
$this->postService->expects($this->once())
->method('getAllPostsPaginated')
->with(1, 6, 3)
->willReturn(new PaginatedResult([], 0, 1, 6));
$this->controller->index(
$this->makeGet('/', ['categorie' => 'php']),
$this->makeResponse(),
);
}
// ── show ─────────────────────────────────────────────────────────
/**
* show() doit rendre la vue de détail si l'article est trouvé.
*/
public function testShowRendersDetailView(): void
{
$post = $this->buildPostEntity(1, 'Titre', 'Contenu', 'titre', 1);
$this->postService->method('getPostBySlug')->willReturn($post);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'pages/post/detail.twig', $this->anything())
->willReturnArgument(0);
$res = $this->controller->show(
$this->makeGet('/article/titre'),
$this->makeResponse(),
['slug' => 'titre'],
);
$this->assertStatus($res, 200);
}
/**
* show() doit lancer HttpNotFoundException si l'article est introuvable.
*/
public function testShowThrowsHttpNotFoundWhenPostMissing(): void
{
$this->postService->method('getPostBySlug')
->willThrowException(new NotFoundException('Article', 'missing'));
$this->expectException(HttpNotFoundException::class);
$this->controller->show(
$this->makeGet('/article/missing'),
$this->makeResponse(),
['slug' => 'missing'],
);
}
// ── admin ────────────────────────────────────────────────────────
/**
* admin() doit appeler getAllPosts() pour un administrateur.
*/
public function testAdminCallsGetAllPostsForAdmin(): void
{
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->postService->expects($this->once())->method('getAllPostsPaginated')->with(1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->postService->expects($this->never())->method('getPostsByUserIdPaginated');
$res = $this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* admin() doit appeler getPostsByUserId() pour un utilisateur ordinaire.
*/
public function testAdminCallsGetPostsByUserIdForRegularUser(): void
{
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->postService->expects($this->once())->method('getPostsByUserIdPaginated')->with(5, 1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->postService->expects($this->never())->method('getAllPostsPaginated');
$this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse());
}
/**
* admin() doit passer authorId = null à searchPosts() pour un admin (recherche globale).
*/
public function testAdminSearchPassesNullAuthorIdForAdmin(): void
{
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->postService->expects($this->once())
->method('searchPostsPaginated')
->with('php', 1, 12, null, null)
->willReturn(new PaginatedResult([], 0, 1, 12));
$this->controller->admin(
$this->makeGet('/admin/posts', ['q' => 'php']),
$this->makeResponse(),
);
}
// ── form ─────────────────────────────────────────────────────────
/**
* form() doit rendre le formulaire vide pour un nouvel article (id = 0).
*/
public function testFormRendersEmptyFormForNewPost(): void
{
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/posts/form.twig', $this->callback(
fn (array $d) => $d['post'] === null && $d['action'] === '/admin/posts/create'
))
->willReturnArgument(0);
$this->controller->form(
$this->makeGet('/admin/posts/edit/0'),
$this->makeResponse(),
['id' => '0'],
);
}
/**
* form() doit rendre le formulaire pré-rempli pour un article existant dont l'utilisateur est auteur.
*/
public function testFormRendersFilledFormWhenUserIsAuthor(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 5);
$this->postService->expects($this->once())->method('getPostById')->with(7)->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), 'admin/posts/form.twig', $this->callback(
fn (array $d) => $d['post'] === $post && $d['action'] === '/admin/posts/edit/7'
))
->willReturnArgument(0);
$this->controller->form(
$this->makeGet('/admin/posts/edit/7'),
$this->makeResponse(),
['id' => '7'],
);
}
/**
* form() doit rediriger avec une erreur si l'utilisateur n'est pas l'auteur.
*/
public function testFormRedirectsWhenUserCannotEditPost(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 10); // auteur = 10
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5); // connecté = 5
$this->flash->expects($this->once())->method('set')
->with('post_error', $this->stringContains("n'êtes pas l'auteur"));
$res = $this->controller->form(
$this->makeGet('/admin/posts/edit/7'),
$this->makeResponse(),
['id' => '7'],
);
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* form() doit lancer HttpNotFoundException si l'article est introuvable.
*/
public function testFormThrowsHttpNotFoundWhenPostMissing(): void
{
$this->postService->method('getPostById')
->willThrowException(new NotFoundException('Article', 99));
$this->expectException(HttpNotFoundException::class);
$this->controller->form(
$this->makeGet('/admin/posts/edit/99'),
$this->makeResponse(),
['id' => '99'],
);
}
// ── create ───────────────────────────────────────────────────────
/**
* create() doit flasher un succès et rediriger vers /admin/posts en cas de succès.
*/
public function testCreateRedirectsToAdminPostsOnSuccess(): void
{
$this->sessionManager->method('getUserId')->willReturn(1);
$this->postService->method('createPost')->willReturn(0);
$this->flash->expects($this->once())->method('set')
->with('post_success', $this->stringContains('créé'));
$req = $this->makePost('/admin/posts/create', ['title' => 'Mon titre', 'content' => 'Contenu']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* create() doit flasher une erreur et rediriger vers le formulaire si la validation échoue.
*/
public function testCreateRedirectsToFormOnValidationError(): void
{
$this->postService->method('createPost')
->willThrowException(new \InvalidArgumentException('Titre vide'));
$this->flash->expects($this->once())->method('set')
->with('post_error', 'Titre vide');
$req = $this->makePost('/admin/posts/create', ['title' => '', 'content' => 'x']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/posts/edit/0');
}
/**
* create() doit flasher une erreur générique et rediriger vers le formulaire en cas d'exception inattendue.
*/
public function testCreateRedirectsToFormOnUnexpectedError(): void
{
$this->postService->method('createPost')
->willThrowException(new \RuntimeException('DB error'));
$this->flash->expects($this->once())->method('set')
->with('post_error', $this->stringContains('inattendue'));
$req = $this->makePost('/admin/posts/create', ['title' => 'T', 'content' => 'C']);
$res = $this->controller->create($req, $this->makeResponse());
$this->assertRedirectTo($res, '/admin/posts/edit/0');
}
// ── update ───────────────────────────────────────────────────────
/**
* update() doit lancer HttpNotFoundException si l'article est introuvable.
*/
public function testUpdateThrowsHttpNotFoundWhenPostMissing(): void
{
$this->postService->method('getPostById')
->willThrowException(new NotFoundException('Article', 42));
$this->expectException(HttpNotFoundException::class);
$req = $this->makePost('/admin/posts/edit/42', ['title' => 'T', 'content' => 'C', 'slug' => 's']);
$this->controller->update($req, $this->makeResponse(), ['id' => '42']);
}
/**
* update() doit rediriger avec une erreur si l'utilisateur n'est pas l'auteur.
*/
public function testUpdateRedirectsWhenUserCannotEditPost(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 10);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->flash->expects($this->once())->method('set')
->with('post_error', $this->stringContains("n'êtes pas l'auteur"));
$req = $this->makePost('/admin/posts/edit/7', ['title' => 'T', 'content' => 'C', 'slug' => 's']);
$res = $this->controller->update($req, $this->makeResponse(), ['id' => '7']);
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* update() doit rediriger vers /admin/posts avec un message de succès.
*/
public function testUpdateRedirectsToAdminPostsOnSuccess(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 5);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->flash->expects($this->once())->method('set')
->with('post_success', $this->stringContains('modifié'));
$req = $this->makePost('/admin/posts/edit/7', ['title' => 'Nouveau', 'content' => 'Contenu', 'slug' => 'nouveau']);
$res = $this->controller->update($req, $this->makeResponse(), ['id' => '7']);
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* update() doit rediriger vers le formulaire avec une erreur en cas d'InvalidArgumentException.
*/
public function testUpdateRedirectsToFormOnValidationError(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 5);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->postService->method('updatePost')
->willThrowException(new \InvalidArgumentException('Titre invalide'));
$this->flash->expects($this->once())->method('set')
->with('post_error', 'Titre invalide');
$req = $this->makePost('/admin/posts/edit/7', ['title' => '', 'content' => 'C', 'slug' => 's']);
$res = $this->controller->update($req, $this->makeResponse(), ['id' => '7']);
$this->assertRedirectTo($res, '/admin/posts/edit/7');
}
// ── delete ───────────────────────────────────────────────────────
/**
* delete() doit lancer HttpNotFoundException si l'article est introuvable.
*/
public function testDeleteThrowsHttpNotFoundWhenPostMissing(): void
{
$this->postService->method('getPostById')
->willThrowException(new NotFoundException('Article', 42));
$this->expectException(HttpNotFoundException::class);
$this->controller->delete(
$this->makePost('/admin/posts/delete/42'),
$this->makeResponse(),
['id' => '42'],
);
}
/**
* delete() doit rediriger avec une erreur si l'utilisateur n'est pas l'auteur.
*/
public function testDeleteRedirectsWhenUserCannotDeletePost(): void
{
$post = $this->buildPostEntity(7, 'Titre', 'Contenu', 'titre', 10);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->flash->expects($this->once())->method('set')
->with('post_error', $this->stringContains("n'êtes pas l'auteur"));
$res = $this->controller->delete(
$this->makePost('/admin/posts/delete/7'),
$this->makeResponse(),
['id' => '7'],
);
$this->assertRedirectTo($res, '/admin/posts');
}
/**
* delete() doit appeler deletePost() et rediriger avec succès pour un auteur.
*/
public function testDeleteRedirectsWithSuccessForAuthor(): void
{
$post = $this->buildPostEntity(7, 'Mon article', 'Contenu', 'mon-article', 5);
$this->postService->method('getPostById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(false);
$this->sessionManager->method('isEditor')->willReturn(false);
$this->sessionManager->method('getUserId')->willReturn(5);
$this->postService->expects($this->once())->method('deletePost')->with(7);
$this->flash->expects($this->once())->method('set')
->with('post_success', $this->stringContains('Mon article'));
$res = $this->controller->delete(
$this->makePost('/admin/posts/delete/7'),
$this->makeResponse(),
['id' => '7'],
);
$this->assertRedirectTo($res, '/admin/posts');
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Crée une entité Post de test avec les paramètres minimaux.
*
* Nommé buildPostEntity (et non makePost) pour ne pas masquer
* ControllerTestBase::makePost() qui forge une requête HTTP.
*/
private function buildPostEntity(
int $id,
string $title,
string $content,
string $slug,
?int $authorId,
): Post {
return new Post($id, $title, $content, $slug, $authorId);
}
}