541 lines
22 KiB
PHP
541 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Post;
|
|
|
|
use App\Post\Application\PostServiceInterface;
|
|
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 PostServiceInterface&MockObject */
|
|
private PostServiceInterface $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(PostServiceInterface::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);
|
|
}
|
|
}
|