Files
netslim-blog/tests/Post/PostControllerTest.php
2026-03-22 12:51:14 +01:00

541 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Application\PostApplicationService;
use App\Post\Domain\Entity\Post;
use App\Post\UI\Http\PostController;
use Netig\Netslim\AuditLog\Contracts\AuditLoggerInterface;
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
use Netig\Netslim\Identity\Domain\Policy\Permission;
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
use Netig\Netslim\Settings\Contracts\SettingsReaderInterface;
use Netig\Netslim\Taxonomy\Contracts\TaxonomyReaderInterface;
use Netig\Netslim\Taxonomy\Contracts\TaxonView;
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 PostApplicationService&MockObject */
private PostApplicationService $postService;
/** @var TaxonomyReaderInterface&MockObject */
private TaxonomyReaderInterface $taxonomyReader;
/** @var SettingsReaderInterface&MockObject */
private SettingsReaderInterface $settings;
/** @var AuthorizationServiceInterface&MockObject */
private AuthorizationServiceInterface $authorization;
/** @var AuditLoggerInterface&MockObject */
private AuditLoggerInterface $auditLogger;
/** @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(PostApplicationService::class);
$this->taxonomyReader = $this->createMock(TaxonomyReaderInterface::class);
$this->settings = $this->createMock(SettingsReaderInterface::class);
$this->authorization = $this->createMock(AuthorizationServiceInterface::class);
$this->auditLogger = $this->createMock(AuditLoggerInterface::class);
$this->flash = $this->createMock(FlashServiceInterface::class);
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
$this->taxonomyReader->method('findAll')->willReturn([]);
$this->settings->method('getInt')->willReturnMap([
['blog.public_posts_per_page', 6, 6],
['blog.admin_posts_per_page', 12, 12],
]);
$this->authorization->method('canRole')->willReturnMap([
['admin', Permission::CONTENT_MANAGE, true],
['editor', Permission::CONTENT_MANAGE, true],
['user', Permission::CONTENT_MANAGE, false],
]);
$this->controller = new PostController(
$this->view,
$this->postService,
$this->taxonomyReader,
$this->settings,
$this->authorization,
$this->auditLogger,
$this->flash,
$this->sessionManager,
);
}
// ── index ────────────────────────────────────────────────────────
/**
* index() doit appeler findAll() sans filtre par défaut.
*/
public function testIndexCallsGetAllPostsWithNoFilter(): void
{
$this->postService->expects($this->once())->method('findPaginated')->with(1, 6, null)->willReturn(new PaginatedResult([], 0, 1, 6));
$this->postService->expects($this->never())->method('searchPaginated');
$res = $this->controller->index($this->makeGet('/'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* index() doit appeler search() quand un paramètre q est fourni.
*/
public function testIndexCallsSearchPostsWhenQueryParamPresent(): void
{
$this->postService->expects($this->once())
->method('searchPaginated')
->with('php', 1, 6, null)
->willReturn(new PaginatedResult([], 0, 1, 6));
$this->postService->expects($this->never())->method('findPaginated');
$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 TaxonView(3, 'PHP', 'php');
$this->taxonomyReader->expects($this->once())->method('findBySlug')->with('php')->willReturn($category);
$this->postService->expects($this->once())
->method('findPaginated')
->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('findBySlug')->willReturn($post);
$this->view->expects($this->once())
->method('render')
->with($this->anything(), '@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('findBySlug')
->willThrowException(new NotFoundException('Article', 'missing'));
$this->expectException(HttpNotFoundException::class);
$this->controller->show(
$this->makeGet('/article/missing'),
$this->makeResponse(),
['slug' => 'missing'],
);
}
// ── admin ────────────────────────────────────────────────────────
/**
* admin() doit appeler findAll() 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('findPaginated')->with(1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->postService->expects($this->never())->method('findByUserIdPaginated');
$res = $this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse());
$this->assertStatus($res, 200);
}
/**
* admin() doit appeler findByUserId() 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('findByUserIdPaginated')->with(5, 1, 12, null)->willReturn(new PaginatedResult([], 0, 1, 12));
$this->postService->expects($this->never())->method('findPaginated');
$this->controller->admin($this->makeGet('/admin/posts'), $this->makeResponse());
}
/**
* admin() doit passer authorId = null à search() 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('searchPaginated')
->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(), '@Post/admin/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('findById')->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(), '@Post/admin/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('findById')->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('findById')
->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('create')->willReturn(42);
$this->auditLogger->expects($this->once())->method('record')->with('post.created', 'post', '42', 1, ['title' => 'Mon titre']);
$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('create')
->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('create')
->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('findById')
->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('findById')->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('findById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->sessionManager->method('getUserId')->willReturn(1);
$this->auditLogger->expects($this->once())->method('record')->with('post.updated', 'post', '7', 1, ['title' => 'Nouveau']);
$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('findById')->willReturn($post);
$this->sessionManager->method('isAdmin')->willReturn(true);
$this->postService->method('update')
->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('findById')
->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('findById')->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 delete() 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('findById')->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('delete')->with(7);
$this->auditLogger->expects($this->once())->method('record')->with('post.deleted', 'post', '7', 5, ['title' => 'Mon article']);
$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);
}
}