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,66 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostRepository;
use App\Post\PostRepositoryInterface;
use App\Post\PostService;
use App\Shared\Database\Migrator;
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;
use PDO;
use PHPUnit\Framework\TestCase;
final class PostConcurrentUpdateIntegrationTest extends TestCase
{
private PDO $db;
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
Migrator::run($this->db);
$this->db->exec("INSERT INTO users (id, username, email, password_hash, role, created_at) VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')");
$this->db->exec("INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at) VALUES (1, 'Titre', '<p>Contenu</p>', 'titre', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')");
}
public function testUpdatePostThrowsWhenRowDisappearsBetweenReadAndWrite(): void
{
$realRepo = new PostRepository($this->db);
$repo = new class($realRepo) implements PostRepositoryInterface {
private bool $deleted = false;
public function __construct(private readonly PostRepository $inner) {}
public function findAll(?int $categoryId = null): array { return $this->inner->findAll($categoryId); }
public function findRecent(int $limit): array { return $this->inner->findRecent($limit); }
public function findByUserId(int $userId, ?int $categoryId = null): array { return $this->inner->findByUserId($userId, $categoryId); }
public function findBySlug(string $slug): ?Post { return $this->inner->findBySlug($slug); }
public function findById(int $id): ?Post { return $this->inner->findById($id); }
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int { return $this->inner->create($post, $slug, $authorId, $categoryId); }
public function update(int $id, Post $post, string $slug, ?int $categoryId): int {
if (!$this->deleted) {
$this->deleted = true;
$this->inner->delete($id);
}
return $this->inner->update($id, $post, $slug, $categoryId);
}
public function delete(int $id): int { return $this->inner->delete($id); }
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array { return $this->inner->search($query, $categoryId, $authorId); }
public function slugExists(string $slug, ?int $excludeId = null): bool { return $this->inner->slugExists($slug, $excludeId); }
};
$sanitizer = new class implements HtmlSanitizerInterface {
public function sanitize(string $html): string { return $html; }
};
$service = new PostService($repo, $sanitizer);
$this->expectException(NotFoundException::class);
$service->updatePost(1, 'Titre modifié', '<p>Contenu modifié</p>');
}
}

View File

@@ -0,0 +1,505 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Category\Category;
use App\Category\CategoryServiceInterface;
use App\Post\Post;
use App\Post\PostController;
use App\Post\PostServiceInterface;
use App\Shared\Exception\NotFoundException;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Slim\Exception\HttpNotFoundException;
use Tests\ControllerTestCase;
/**
* 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
*/
final class PostControllerTest extends ControllerTestCase
{
/** @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('getAllPosts')->with(null)->willReturn([]);
$this->postService->expects($this->never())->method('searchPosts');
$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('searchPosts')
->with('php', null)
->willReturn([]);
$this->postService->expects($this->never())->method('getAllPosts');
$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->method('findBySlug')->with('php')->willReturn($category);
$this->postService->expects($this->once())
->method('getAllPosts')
->with(3)
->willReturn([]);
$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('getAllPosts')->willReturn([]);
$this->postService->expects($this->never())->method('getPostsByUserId');
$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('getPostsByUserId')->with(5, null)->willReturn([]);
$this->postService->expects($this->never())->method('getAllPosts');
$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('searchPosts')
->with('php', null, null)
->willReturn([]);
$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->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
* ControllerTestCase::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);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostExtension;
use PHPUnit\Framework\TestCase;
use Twig\TwigFunction;
final class PostExtensionTest extends TestCase
{
/** @var array<string, TwigFunction> */
private array $functions;
protected function setUp(): void
{
$extension = new PostExtension();
$this->functions = [];
foreach ($extension->getFunctions() as $function) {
$this->functions[$function->getName()] = $function;
}
}
public function testPostUrlUsesStoredSlug(): void
{
$post = new Post(1, 'Mon article', '<p>Contenu</p>', 'mon-article-2');
self::assertSame('/article/mon-article-2', $this->call('post_url', $post));
}
public function testPostExcerptKeepsSafeTagsAndTruncatesHtml(): void
{
$html = '<p><strong>Bonjour</strong> <script>alert(1)</script><em>monde</em> ' . str_repeat('x', 30) . '</p>';
$post = new Post(1, 'Titre', $html, 'titre');
$excerpt = $this->call('post_excerpt', $post, 20);
self::assertStringContainsString('<strong>Bonjour</strong>', $excerpt);
self::assertStringContainsString('<em>', $excerpt);
self::assertStringNotContainsString('<script>', $excerpt);
self::assertStringEndsWith('…', $excerpt);
}
public function testPostThumbnailReturnsFirstImageSource(): void
{
$post = new Post(1, 'Titre', '<p><img src="/media/a.webp" alt="a"><img src="/media/b.webp"></p>', 'titre');
self::assertSame('/media/a.webp', $this->call('post_thumbnail', $post));
}
public function testPostThumbnailReturnsNullWhenMissing(): void
{
$post = new Post(1, 'Titre', '<p>Sans image</p>', 'titre');
self::assertNull($this->call('post_thumbnail', $post));
}
public function testPostInitialsUseMeaningfulWordsAndFallback(): void
{
$post = new Post(1, 'Article de Blog', '<p>Contenu</p>', 'slug');
$single = new Post(2, 'A B', '<p>Contenu</p>', 'slug-2');
$emptyLike = new Post(3, 'A', '<p>Contenu</p>', 'slug-3');
self::assertSame('AB', $this->call('post_initials', $post));
self::assertSame('A', $this->call('post_initials', $single));
self::assertSame('A', $this->call('post_initials', $emptyLike));
}
private function call(string $name, mixed ...$args): mixed
{
$callable = $this->functions[$name]->getCallable();
return $callable(...$args);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\PostRepository;
use App\Shared\Database\Migrator;
use PDO;
use PHPUnit\Framework\TestCase;
final class PostFtsUsernameSyncIntegrationTest extends TestCase
{
private PDO $db;
protected function setUp(): void
{
$this->db = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->db->sqliteCreateFunction('strip_tags', 'strip_tags', 1);
Migrator::run($this->db);
$this->db->exec("INSERT INTO users (id, username, email, password_hash, role, created_at) VALUES (1, 'alice', 'alice@example.com', 'hash', 'user', '2024-01-01 00:00:00')");
$this->db->exec("INSERT INTO posts (id, title, content, slug, author_id, created_at, updated_at) VALUES (1, 'Guide Slim', '<p>Contenu</p>', 'guide-slim', 1, '2024-01-01 00:00:00', '2024-01-01 00:00:00')");
}
public function testSearchReflectsUpdatedAuthorUsernameInFtsIndex(): void
{
$this->db->exec("UPDATE users SET username = 'alice_renamed' WHERE id = 1");
$results = (new PostRepository($this->db))->search('alice_renamed');
self::assertCount(1, $results);
self::assertSame('alice_renamed', $results[0]->getAuthorUsername());
self::assertSame('Guide Slim', $results[0]->getTitle());
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use PHPUnit\Framework\TestCase;
final class PostModelEdgeCasesTest extends TestCase
{
public function testFromArrayKeepsMissingOptionalFieldsNull(): void
{
$post = Post::fromArray([
'id' => 3,
'title' => 'Titre',
'content' => '<p>Texte</p>',
'slug' => 'titre',
]);
self::assertSame(3, $post->getId());
self::assertNull($post->getAuthorId());
self::assertNull($post->getAuthorUsername());
self::assertNull($post->getCategoryId());
self::assertNull($post->getCategoryName());
self::assertNull($post->getCategorySlug());
self::assertInstanceOf(\DateTime::class, $post->getCreatedAt());
self::assertInstanceOf(\DateTime::class, $post->getUpdatedAt());
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use DateTime;
use PHPUnit\Framework\TestCase;
final class PostModelTest extends TestCase
{
public function testConstructAndGettersExposePostData(): void
{
$createdAt = new DateTime('2026-01-01 10:00:00');
$updatedAt = new DateTime('2026-01-02 11:00:00');
$post = new Post(
10,
'Été en forêt',
'<p>Contenu</p>',
'ete-en-foret-2',
5,
'julien',
3,
'Tech',
'tech',
$createdAt,
$updatedAt,
);
self::assertSame(10, $post->getId());
self::assertSame('Été en forêt', $post->getTitle());
self::assertSame('<p>Contenu</p>', $post->getContent());
self::assertSame('ete-en-foret-2', $post->getStoredSlug());
self::assertSame('ete-en-foret', $post->generateSlug());
self::assertSame(5, $post->getAuthorId());
self::assertSame('julien', $post->getAuthorUsername());
self::assertSame(3, $post->getCategoryId());
self::assertSame('Tech', $post->getCategoryName());
self::assertSame('tech', $post->getCategorySlug());
self::assertSame($createdAt, $post->getCreatedAt());
self::assertSame($updatedAt, $post->getUpdatedAt());
}
public function testFromArrayHydratesOptionalFields(): void
{
$post = Post::fromArray([
'id' => '7',
'title' => 'Titre',
'content' => '<p>Texte</p>',
'slug' => 'titre',
'author_id' => '9',
'author_username' => 'alice',
'category_id' => '2',
'category_name' => 'Actualités',
'category_slug' => 'actualites',
'created_at' => '2026-02-01 10:00:00',
'updated_at' => '2026-02-02 11:00:00',
]);
self::assertSame(7, $post->getId());
self::assertSame('alice', $post->getAuthorUsername());
self::assertSame('Actualités', $post->getCategoryName());
self::assertSame('2026-02-01 10:00:00', $post->getCreatedAt()->format('Y-m-d H:i:s'));
}
public function testValidationRejectsEmptyTitle(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le titre ne peut pas être vide');
new Post(1, '', '<p>Contenu</p>');
}
public function testValidationRejectsTooLongTitle(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le titre ne peut pas dépasser 255 caractères');
new Post(1, str_repeat('a', 256), '<p>Contenu</p>');
}
public function testValidationRejectsEmptyContent(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le contenu ne peut pas être vide');
new Post(1, 'Titre', '');
}
public function testValidationRejectsTooLongContent(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Le contenu ne peut pas dépasser 65 535 caractères');
new Post(1, 'Titre', str_repeat('a', 65536));
}
}

View File

@@ -0,0 +1,546 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostRepository;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PostRepository.
*
* 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 PostRepositoryTest extends TestCase
{
/** @var PDO&MockObject */
private PDO $db;
private PostRepository $repository;
/**
* Données représentant une ligne article en base de données (avec JOINs).
*
* @var array<string, mixed>
*/
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' => '<p>Contenu de test</p>',
'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<array<string,mixed>> $rows Lignes retournées par fetchAll()
* @param array<string,mixed>|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));
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostRepositoryInterface;
use App\Post\PostService;
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires pour PostService.
*
* Couvre la création, la mise à jour, la suppression et les lectures.
* HtmlSanitizerInterface et PostRepository sont mockés pour isoler la logique métier.
*/
final class PostServiceTest extends TestCase
{
/** @var PostRepositoryInterface&MockObject */
private PostRepositoryInterface $repository;
/** @var HtmlSanitizerInterface&MockObject */
private HtmlSanitizerInterface $sanitizer;
private PostService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(PostRepositoryInterface::class);
$this->sanitizer = $this->createMock(HtmlSanitizerInterface::class);
$this->service = new PostService($this->repository, $this->sanitizer);
}
// ── Lectures déléguées ─────────────────────────────────────────
/**
* getAllPosts() délègue au repository.
*/
public function testGetAllPostsDelegatesToRepository(): void
{
$posts = [$this->makePost(1, 'Titre', 'slug-titre')];
$this->repository->method('findAll')->willReturn($posts);
$this->assertSame($posts, $this->service->getAllPosts());
}
/**
* getRecentPosts() délègue au repository.
*/
public function testGetRecentPostsDelegatesToRepository(): void
{
$posts = [$this->makePost(1, 'Titre', 'slug-titre')];
$this->repository->method('findRecent')->willReturn($posts);
$this->assertSame($posts, $this->service->getRecentPosts(5));
}
/**
* getPostsByUserId() délègue au repository.
*/
public function testGetPostsByUserIdDelegatesToRepository(): void
{
$posts = [$this->makePost(1, 'Titre', 'slug-titre')];
$this->repository->method('findByUserId')->with(3, null)->willReturn($posts);
$this->assertSame($posts, $this->service->getPostsByUserId(3));
}
/**
* getPostBySlug() lève NotFoundException si l'article est introuvable.
*/
public function testGetPostBySlugThrowsNotFoundExceptionWhenMissing(): void
{
$this->repository->method('findBySlug')->willReturn(null);
$this->expectException(NotFoundException::class);
$this->service->getPostBySlug('slug-inexistant');
}
/**
* getPostBySlug() retourne l'article tel que stocké — la sanitisation
* se fait à l'écriture (createPost / updatePost), pas à la lecture.
*/
public function testGetPostBySlugReturnsPost(): void
{
$post = $this->makePost(1, 'Titre', 'mon-slug', '<p>Contenu stocké</p>');
$this->repository->method('findBySlug')->willReturn($post);
$result = $this->service->getPostBySlug('mon-slug');
$this->assertSame('<p>Contenu stocké</p>', $result->getContent());
}
/**
* getPostById() lève NotFoundException si l'article est introuvable.
*/
public function testGetPostByIdThrowsNotFoundExceptionWhenMissing(): void
{
$this->repository->method('findById')->willReturn(null);
$this->expectException(NotFoundException::class);
$this->service->getPostById(999);
}
// ── createPost ─────────────────────────────────────────────────
/**
* createPost() lève InvalidArgumentException si le titre est vide.
*/
public function testCreatePostThrowsWhenTitleEmpty(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->service->createPost('', '<p>Contenu</p>', 1);
}
/**
* createPost() lève InvalidArgumentException si le contenu est vide.
*/
public function testCreatePostThrowsWhenContentEmpty(): void
{
$this->sanitizer->method('sanitize')->willReturn('');
$this->expectException(\InvalidArgumentException::class);
$this->service->createPost('Titre', '', 1);
}
/**
* createPost() sanitise le contenu et délègue la persistance.
*/
public function testCreatePostSanitizesAndPersists(): void
{
$this->sanitizer->method('sanitize')->willReturn('<p>Contenu sûr</p>');
$this->repository->method('findBySlug')->willReturn(null);
$this->repository->expects($this->once())->method('create')->willReturn(42);
$id = $this->service->createPost('Mon Titre', '<p>Contenu brut</p>', 1);
$this->assertSame(42, $id);
}
// ── deletePost ─────────────────────────────────────────────────
/**
* deletePost() throws NotFoundException when the repository returns 0 affected rows.
*/
public function testDeletePostThrowsNotFoundExceptionWhenMissing(): void
{
$this->repository->method('delete')->willReturn(0);
$this->expectException(NotFoundException::class);
$this->service->deletePost(99);
}
/**
* deletePost() délègue la suppression au repository si l'article existe.
*/
public function testDeletePostDelegatesToRepository(): void
{
$this->repository->expects($this->once())->method('delete')->with(5)->willReturn(1);
$this->service->deletePost(5);
}
// ── searchPosts ────────────────────────────────────────────────
/**
* searchPosts() délègue la recherche au repository.
*/
public function testSearchPostsDelegatesToRepository(): void
{
$posts = [$this->makePost(1, 'Résultat', 'resultat')];
$this->repository->method('search')->with('mot', null, null)->willReturn($posts);
$this->assertSame($posts, $this->service->searchPosts('mot'));
}
// ── updatePost ─────────────────────────────────────────────────
/**
* updatePost() lève NotFoundException si l'article n'existe plus.
*/
public function testUpdatePostThrowsNotFoundExceptionWhenMissing(): void
{
$this->repository->method('findById')->willReturn(null);
$this->expectException(NotFoundException::class);
$this->service->updatePost(99, 'Titre', '<p>Contenu</p>');
}
/**
* updatePost() sanitise le contenu et met à jour l'article.
*/
public function testUpdatePostSanitizesAndUpdates(): void
{
$post = $this->makePost(1, 'Ancien titre', 'ancien-titre', '<p>Ancien contenu</p>');
$this->repository->method('findById')->willReturn($post);
$this->sanitizer->method('sanitize')->willReturn('<p>Nouveau contenu sûr</p>');
$this->repository->method('findBySlug')->willReturn(null);
$this->repository->expects($this->once())->method('update')->willReturn(1);
$this->service->updatePost(1, 'Nouveau titre', '<p>Nouveau contenu</p>');
}
// ── Helpers ────────────────────────────────────────────────────
/**
* Crée un Post de test.
*/
private function makePost(int $id, string $title, string $slug, string $content = '<p>Contenu</p>'): Post
{
return new Post($id, $title, $content, $slug);
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Tests\Post;
use App\Post\Post;
use App\Post\PostServiceInterface;
use App\Post\RssController;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\ControllerTestCase;
/**
* Tests unitaires pour RssController.
*
* Couvre feed() :
* - Content-Type application/rss+xml
* - Structure XML valide (balises channel obligatoires)
* - Articles inclus dans le flux (titre, lien, guid)
* - Flux vide : XML minimal valide
* - Appel à getRecentPosts() avec la constante FEED_LIMIT (20)
*/
final class RssControllerTest extends ControllerTestCase
{
/** @var PostServiceInterface&MockObject */
private PostServiceInterface $postService;
private RssController $controller;
private const APP_URL = 'https://example.com';
private const APP_NAME = 'Mon Blog';
protected function setUp(): void
{
$this->postService = $this->createMock(PostServiceInterface::class);
$this->controller = new RssController(
$this->postService,
self::APP_URL,
self::APP_NAME,
);
}
/**
* feed() doit retourner un Content-Type application/rss+xml.
*/
public function testFeedReturnsRssContentType(): void
{
$this->postService->method('getRecentPosts')->willReturn([]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$this->assertStringContainsString('application/rss+xml', $res->getHeaderLine('Content-Type'));
}
/**
* feed() doit retourner un XML valide même si aucun article n'existe.
*/
public function testFeedReturnsValidXmlWhenNoPostsExist(): void
{
$this->postService->method('getRecentPosts')->willReturn([]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$body = (string) $res->getBody();
$xml = simplexml_load_string($body);
$this->assertNotFalse($xml, 'Le corps de la réponse doit être du XML valide');
$this->assertSame('2.0', (string) $xml['version']);
}
/**
* feed() doit inclure les balises channel obligatoires (title, link, description).
*/
public function testFeedIncludesRequiredChannelElements(): void
{
$this->postService->method('getRecentPosts')->willReturn([]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$channel = $xml->channel;
$this->assertSame(self::APP_NAME, (string) $channel->title);
$this->assertNotEmpty((string) $channel->link);
$this->assertNotEmpty((string) $channel->description);
}
/**
* feed() doit inclure un item par article avec title, link et guid.
*/
public function testFeedIncludesOneItemPerPost(): void
{
$post = new Post(1, 'Titre test', 'Contenu de test', 'titre-test', 1, 'alice');
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$items = $xml->channel->item;
$this->assertCount(1, $items);
$this->assertSame('Titre test', (string) $items[0]->title);
$this->assertStringContainsString('titre-test', (string) $items[0]->link);
$this->assertSame((string) $items[0]->link, (string) $items[0]->guid);
}
/**
* feed() doit tronquer le contenu à 300 caractères dans la description.
*/
public function testFeedTruncatesLongContentTo300Chars(): void
{
$longContent = str_repeat('a', 400);
$post = new Post(1, 'Titre', $longContent, 'titre', 1);
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$description = (string) $xml->channel->item[0]->description;
// 300 caractères + '…' = 301 octets UTF-8 pour le contenu visible
$this->assertLessThanOrEqual(302, mb_strlen($description));
$this->assertStringEndsWith('…', $description);
}
/**
* feed() doit appeler getRecentPosts() avec la limite de 20 articles.
*/
public function testFeedRequestsTwentyRecentPosts(): void
{
$this->postService->expects($this->once())
->method('getRecentPosts')
->with(20)
->willReturn([]);
$this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
}
/**
* feed() doit inclure la balise author si l'article a un auteur.
*/
public function testFeedIncludesAuthorWhenPresent(): void
{
$post = new Post(1, 'Titre', 'Contenu', 'titre', 1, 'alice');
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$this->assertSame('alice', (string) $xml->channel->item[0]->author);
}
/**
* feed() ne doit pas inclure la balise author si l'auteur est null.
*/
public function testFeedOmitsAuthorWhenNull(): void
{
$post = new Post(1, 'Titre', 'Contenu', 'titre', null, null);
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$this->assertCount(0, $xml->channel->item[0]->author);
}
/**
* feed() doit construire les URLs des articles en utilisant APP_URL.
*/
public function testFeedBuildsPostUrlsWithAppUrl(): void
{
$post = new Post(1, 'Titre', 'Contenu', 'mon-slug', 1);
$this->postService->method('getRecentPosts')->willReturn([$post]);
$res = $this->controller->feed($this->makeGet('/rss.xml'), $this->makeResponse());
$xml = simplexml_load_string((string) $res->getBody());
$this->assertNotFalse($xml);
$this->assertStringStartsWith(self::APP_URL, (string) $xml->channel->item[0]->link);
}
}