first commit
This commit is contained in:
146
tests/ControllerTestBase.php
Normal file
146
tests/ControllerTestBase.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||
use Slim\Psr7\Response as SlimResponse;
|
||||
|
||||
/**
|
||||
* Classe de base pour les tests de contrôleurs.
|
||||
*
|
||||
* Fournit des helpers pour construire des requêtes PSR-7 sans serveur,
|
||||
* des assertions sur les redirections et les réponses JSON,
|
||||
* ainsi qu'un stub de Twig qui retourne la réponse inchangée.
|
||||
*
|
||||
* Chaque test de contrôleur invoque directement l'action (méthode publique)
|
||||
* sans passer par le routeur Slim — les middlewares sont testés séparément.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
abstract class ControllerTestBase extends TestCase
|
||||
{
|
||||
// ── Factories ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée une requête GET avec des paramètres de query optionnels.
|
||||
*
|
||||
* @param string $uri URI de la requête (ex: '/admin/posts')
|
||||
* @param array<string, mixed> $queryParams Paramètres de query string
|
||||
* @param array<string, mixed> $serverParams Paramètres serveur (ex: REMOTE_ADDR)
|
||||
*/
|
||||
protected function makeGet(
|
||||
string $uri = '/',
|
||||
array $queryParams = [],
|
||||
array $serverParams = [],
|
||||
): \Psr\Http\Message\ServerRequestInterface {
|
||||
$factory = new ServerRequestFactory();
|
||||
$request = $factory->createServerRequest('GET', $uri, $serverParams);
|
||||
|
||||
if ($queryParams !== []) {
|
||||
$request = $request->withQueryParams($queryParams);
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une requête POST avec un corps parsé optionnel.
|
||||
*
|
||||
* @param string $uri URI de la requête (ex: '/auth/login')
|
||||
* @param array<string, mixed> $body Corps de la requête (form data)
|
||||
* @param array<string, mixed> $serverParams Paramètres serveur (ex: REMOTE_ADDR)
|
||||
*/
|
||||
protected function makePost(
|
||||
string $uri = '/',
|
||||
array $body = [],
|
||||
array $serverParams = [],
|
||||
): \Psr\Http\Message\ServerRequestInterface {
|
||||
$factory = new ServerRequestFactory();
|
||||
$request = $factory->createServerRequest('POST', $uri, $serverParams);
|
||||
|
||||
if ($body !== []) {
|
||||
$request = $request->withParsedBody($body);
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une réponse PSR-7 vide (status 200).
|
||||
*/
|
||||
protected function makeResponse(): SlimResponse
|
||||
{
|
||||
return new SlimResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un mock de Twig dont render() retourne la réponse reçue en premier argument.
|
||||
*
|
||||
* Cela permet aux tests de contrôleurs qui appellent $this->view->render()
|
||||
* de recevoir une réponse 200 sans instancier un environnement Twig réel.
|
||||
*
|
||||
* @return \Slim\Views\Twig&\PHPUnit\Framework\MockObject\MockObject
|
||||
*/
|
||||
protected function makeTwigMock(): \Slim\Views\Twig
|
||||
{
|
||||
$twig = $this->createMock(\Slim\Views\Twig::class);
|
||||
$twig->method('render')->willReturnArgument(0);
|
||||
|
||||
return $twig;
|
||||
}
|
||||
|
||||
// ── Assertions ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Vérifie qu'une réponse est une redirection vers l'URL attendue.
|
||||
*/
|
||||
protected function assertRedirectTo(Response $res, string $expectedLocation): void
|
||||
{
|
||||
$this->assertSame(302, $res->getStatusCode(), 'Le code HTTP devrait être 302');
|
||||
$this->assertSame(
|
||||
$expectedLocation,
|
||||
$res->getHeaderLine('Location'),
|
||||
"La redirection devrait pointer vers {$expectedLocation}",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie le code HTTP d'une réponse.
|
||||
*/
|
||||
protected function assertStatus(Response $res, int $expectedStatus): void
|
||||
{
|
||||
$this->assertSame($expectedStatus, $res->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le corps de la réponse est du JSON contenant les clés attendues.
|
||||
*
|
||||
* @param array<string, mixed> $expectedSubset Sous-ensemble de clés/valeurs attendues
|
||||
*/
|
||||
protected function assertJsonContains(Response $res, array $expectedSubset): void
|
||||
{
|
||||
$body = (string) $res->getBody();
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
$this->assertIsArray($decoded, 'Le corps de la réponse devrait être du JSON valide');
|
||||
|
||||
foreach ($expectedSubset as $key => $value) {
|
||||
$this->assertArrayHasKey($key, $decoded, "La clé JSON '{$key}' devrait être présente");
|
||||
$this->assertSame($value, $decoded[$key], "La valeur JSON de '{$key}' est incorrecte");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le Content-Type de la réponse est application/json.
|
||||
*/
|
||||
protected function assertJsonContentType(Response $res): void
|
||||
{
|
||||
$this->assertStringContainsString(
|
||||
'application/json',
|
||||
$res->getHeaderLine('Content-Type'),
|
||||
);
|
||||
}
|
||||
}
|
||||
191
tests/Post/PostConcurrentUpdateIntegrationTest.php
Normal file
191
tests/Post/PostConcurrentUpdateIntegrationTest.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Application\PostApplicationService;
|
||||
use App\Post\Application\UseCase\CreatePost;
|
||||
use App\Post\Application\UseCase\DeletePost;
|
||||
use App\Post\Application\UseCase\UpdatePost;
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
use App\Post\Domain\Service\PostSlugGenerator;
|
||||
use App\Post\Infrastructure\PdoPostRepository;
|
||||
use Netig\Netslim\Kernel\Html\Application\HtmlSanitizerInterface;
|
||||
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\Migrator;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
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);
|
||||
$this->db->exec('PRAGMA foreign_keys=ON');
|
||||
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 PdoPostRepository($this->db);
|
||||
$repo = new class ($realRepo) implements PostRepositoryInterface {
|
||||
private bool $deleted = false;
|
||||
|
||||
public function __construct(private readonly PdoPostRepository $inner) {}
|
||||
|
||||
public function findAll(?int $categoryId = null): array
|
||||
{
|
||||
return $this->inner->findAll($categoryId);
|
||||
}
|
||||
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
return $this->inner->findPage($limit, $offset, $categoryId);
|
||||
}
|
||||
|
||||
public function countAll(?int $categoryId = null): int
|
||||
{
|
||||
return $this->inner->countAll($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 findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
return $this->inner->findByUserPage($userId, $limit, $offset, $categoryId);
|
||||
}
|
||||
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int
|
||||
{
|
||||
return $this->inner->countByUserId($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 searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
return $this->inner->searchPage($query, $limit, $offset, $categoryId, $authorId);
|
||||
}
|
||||
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int
|
||||
{
|
||||
return $this->inner->countSearch($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;
|
||||
}
|
||||
};
|
||||
$transactionManager = new class () implements TransactionManagerInterface {
|
||||
public function run(callable $operation): mixed
|
||||
{
|
||||
return $operation();
|
||||
}
|
||||
};
|
||||
$referenceExtractor = new class () implements PostMediaReferenceExtractorInterface {
|
||||
public function extractMediaIds(string $html): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$usageRepository = new class () implements PostMediaUsageRepositoryInterface {
|
||||
public function countUsages(int $mediaId): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function countUsagesByMediaIds(array $mediaIds): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function findUsages(int $mediaId, int $limit = 5): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function syncPostMedia(int $postId, array $mediaIds): void {}
|
||||
};
|
||||
|
||||
$slugGenerator = new PostSlugGenerator();
|
||||
$service = new PostApplicationService(
|
||||
$repo,
|
||||
new CreatePost($repo, $sanitizer, $slugGenerator, $transactionManager, $referenceExtractor, $usageRepository),
|
||||
new UpdatePost($repo, $sanitizer, $slugGenerator, $transactionManager, $referenceExtractor, $usageRepository),
|
||||
new DeletePost($repo),
|
||||
);
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
$service->update(1, 'Titre modifié', '<p>Contenu modifié</p>');
|
||||
}
|
||||
}
|
||||
540
tests/Post/PostControllerTest.php
Normal file
540
tests/Post/PostControllerTest.php
Normal file
@@ -0,0 +1,540 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
96
tests/Post/PostExtensionTest.php
Normal file
96
tests/Post/PostExtensionTest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\UI\Twig\TwigPostExtension;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PostExtensionTest extends TestCase
|
||||
{
|
||||
/** @var array<string, TwigFunction> */
|
||||
private array $functions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$extension = new TwigPostExtension();
|
||||
$this->functions = [];
|
||||
|
||||
foreach ($extension->getFunctions() as $function) {
|
||||
$this->functions[$function->getName()] = $function;
|
||||
}
|
||||
}
|
||||
|
||||
public function testRegistersExpectedTwigFunctions(): void
|
||||
{
|
||||
self::assertSame([
|
||||
'post_excerpt',
|
||||
'post_url',
|
||||
'post_thumbnail',
|
||||
'post_initials',
|
||||
], array_keys($this->functions));
|
||||
}
|
||||
|
||||
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 testPostExcerptReturnsOriginalHtmlWhenAlreadyShortEnough(): void
|
||||
{
|
||||
$post = new Post(10, 'Titre', '<p><strong>Bonjour</strong> <em>monde</em></p>', 'titre-10');
|
||||
|
||||
self::assertSame('<strong>Bonjour</strong> <em>monde</em>', $this->call('post_excerpt', $post, 100));
|
||||
}
|
||||
|
||||
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');
|
||||
$stopWordsOnly = new Post(3, 'de la', '<p>Contenu</p>', 'slug-3');
|
||||
|
||||
self::assertSame('AB', $this->call('post_initials', $post));
|
||||
self::assertSame('A', $this->call('post_initials', $single));
|
||||
self::assertSame('D', $this->call('post_initials', $stopWordsOnly));
|
||||
}
|
||||
|
||||
private function call(string $name, mixed ...$args): mixed
|
||||
{
|
||||
$callable = $this->functions[$name]->getCallable();
|
||||
|
||||
return $callable(...$args);
|
||||
}
|
||||
}
|
||||
55
tests/Post/PostFtsUsernameSyncIntegrationTest.php
Normal file
55
tests/Post/PostFtsUsernameSyncIntegrationTest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Infrastructure\PdoPostRepository;
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\Migrator;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
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 PdoPostRepository($this->db))->search('alice_renamed');
|
||||
|
||||
self::assertCount(1, $results);
|
||||
self::assertSame('alice_renamed', $results[0]->getAuthorUsername());
|
||||
self::assertSame('Guide Slim', $results[0]->getTitle());
|
||||
}
|
||||
|
||||
public function testSearchReturnsEmptyArrayForPunctuationOnlyQuery(): void
|
||||
{
|
||||
$results = (new PdoPostRepository($this->db))->search(".'");
|
||||
|
||||
self::assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testCountSearchReturnsZeroForPunctuationOnlyQuery(): void
|
||||
{
|
||||
$count = (new PdoPostRepository($this->db))->countSearch(".'");
|
||||
|
||||
self::assertSame(0, $count);
|
||||
}
|
||||
}
|
||||
89
tests/Post/PostMediaUsageReaderTest.php
Normal file
89
tests/Post/PostMediaUsageReaderTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\ValueObject\PostMediaUsageReference;
|
||||
use App\Post\Infrastructure\PostMediaUsageReader;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReference;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PostMediaUsageReaderTest extends TestCase
|
||||
{
|
||||
/** @var PostMediaUsageRepositoryInterface&MockObject */
|
||||
private PostMediaUsageRepositoryInterface $repository;
|
||||
|
||||
private PostMediaUsageReader $reader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(PostMediaUsageRepositoryInterface::class);
|
||||
$this->reader = new PostMediaUsageReader($this->repository);
|
||||
}
|
||||
|
||||
public function testCountUsagesDelegatesToRepository(): void
|
||||
{
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('countUsages')
|
||||
->with(12)
|
||||
->willReturn(4);
|
||||
|
||||
self::assertSame(4, $this->reader->countUsages(12));
|
||||
}
|
||||
|
||||
public function testCountUsagesByMediaIdsDelegatesToRepository(): void
|
||||
{
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('countUsagesByMediaIds')
|
||||
->with([12, 18])
|
||||
->willReturn([12 => 4, 18 => 1]);
|
||||
|
||||
self::assertSame([12 => 4, 18 => 1], $this->reader->countUsagesByMediaIds([12, 18]));
|
||||
}
|
||||
|
||||
public function testFindUsagesDelegatesToRepository(): void
|
||||
{
|
||||
$repositoryReferences = [
|
||||
new PostMediaUsageReference(8, 'Titre', '/admin/posts/edit/8'),
|
||||
];
|
||||
|
||||
$expectedReferences = [
|
||||
new MediaUsageReference(8, 'Titre', '/admin/posts/edit/8'),
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('findUsages')
|
||||
->with(12, 3)
|
||||
->willReturn($repositoryReferences);
|
||||
|
||||
self::assertEquals($expectedReferences, $this->reader->findUsages(12, 3));
|
||||
}
|
||||
|
||||
public function testFindUsagesByMediaIdsDelegatesToRepository(): void
|
||||
{
|
||||
$repositoryReferences = [
|
||||
12 => [new PostMediaUsageReference(8, 'Titre', '/admin/posts/edit/8')],
|
||||
18 => [new PostMediaUsageReference(14, 'Autre', '/admin/posts/edit/14')],
|
||||
];
|
||||
|
||||
$expectedReferences = [
|
||||
12 => [new MediaUsageReference(8, 'Titre', '/admin/posts/edit/8')],
|
||||
18 => [new MediaUsageReference(14, 'Autre', '/admin/posts/edit/14')],
|
||||
];
|
||||
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('findUsagesByMediaIds')
|
||||
->with([12, 18], 3)
|
||||
->willReturn($repositoryReferences);
|
||||
|
||||
self::assertEquals($expectedReferences, $this->reader->findUsagesByMediaIds([12, 18], 3));
|
||||
}
|
||||
}
|
||||
20
tests/Post/PostMediaUsageReferenceTest.php
Normal file
20
tests/Post/PostMediaUsageReferenceTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Domain\ValueObject\PostMediaUsageReference;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PostMediaUsageReferenceTest extends TestCase
|
||||
{
|
||||
public function testExposesPostUsageData(): void
|
||||
{
|
||||
$reference = new PostMediaUsageReference(12, 'Post lié', '/admin/posts/edit/12');
|
||||
|
||||
self::assertSame(12, $reference->getPostId());
|
||||
self::assertSame('Post lié', $reference->getPostTitle());
|
||||
self::assertSame('/admin/posts/edit/12', $reference->getPostEditPath());
|
||||
}
|
||||
}
|
||||
32
tests/Post/PostModelEdgeCasesTest.php
Normal file
32
tests/Post/PostModelEdgeCasesTest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
101
tests/Post/PostModelTest.php
Normal file
101
tests/Post/PostModelTest.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use DateTime;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
585
tests/Post/PostRepositoryTest.php
Normal file
585
tests/Post/PostRepositoryTest.php
Normal file
@@ -0,0 +1,585 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Infrastructure\PdoPostRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PdoPostRepository.
|
||||
*
|
||||
* Vérifie l'intention des requêtes et les valeurs retournées
|
||||
* sans figer inutilement tous les détails d'implémentation SQL.
|
||||
*
|
||||
* PDO et PDOStatement sont mockés pour isoler complètement
|
||||
* le dépôt de la base de données.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PostRepositoryTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PdoPostRepository $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 PdoPostRepository($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): MockObject&PDOStatement
|
||||
{
|
||||
$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): MockObject&PDOStatement
|
||||
{
|
||||
$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 interroge bien la table `posts`.
|
||||
*/
|
||||
public function testFindAllWithoutFilterRequestsPostsQuery(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
$this->db->expects($this->once())
|
||||
->method('query')
|
||||
->with($this->callback(
|
||||
static fn (string $sql): bool => str_contains(
|
||||
strtolower(preg_replace('/\s+/', ' ', $sql)),
|
||||
'from posts',
|
||||
),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$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->callback(
|
||||
static fn (string $sql): bool => str_contains(
|
||||
strtolower(preg_replace('/\s+/', ' ', $sql)),
|
||||
'from posts',
|
||||
) && str_contains($sql, 'category_id'),
|
||||
))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(fn (array $params): bool => in_array(3, $params, true)));
|
||||
|
||||
$this->repository->findAll(3);
|
||||
}
|
||||
|
||||
|
||||
// ── findRecent ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findRecent() retourne un tableau vide si aucun article n'existe.
|
||||
*/
|
||||
public function testFindRecentReturnsEmptyArray(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([]);
|
||||
$this->db->expects($this->once())->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->expects($this->once())->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 $params): bool => in_array(7, $params, true)));
|
||||
|
||||
$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 $params): bool => count($params) === 2 && in_array(7, $params, true) && in_array(3, $params, true)));
|
||||
|
||||
$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($this->callback(fn (array $params): bool => in_array('mon-article', $params, true)));
|
||||
|
||||
$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($this->callback(fn (array $params): bool => in_array(12, $params, true)));
|
||||
|
||||
$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->expects($this->once())->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->expects($this->once())->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($this->callback(fn (array $params): bool => in_array(6, $params, true)));
|
||||
|
||||
$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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── search ─────────────────────────────────────────────────────
|
||||
|
||||
public function testSearchReturnsEmptyArrayWhenQueryContainsNoSearchableTerms(): void
|
||||
{
|
||||
$this->db->expects($this->never())->method('prepare');
|
||||
|
||||
$this->assertSame([], $this->repository->search(".'"));
|
||||
}
|
||||
|
||||
public function testSearchQuotesNormalizedTermsBeforeExecutingFtsQuery(): void
|
||||
{
|
||||
$stmt = $this->stmtForRead([$this->rowPost]);
|
||||
$this->db->expects($this->once())->method('prepare')->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with($this->callback(
|
||||
static fn (array $params): bool => isset($params[':q']) && $params[':q'] === '"php" "sqlite"',
|
||||
));
|
||||
|
||||
$results = $this->repository->search("php. sqlite'");
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
}
|
||||
|
||||
public function testCountSearchReturnsZeroWhenQueryContainsNoSearchableTerms(): void
|
||||
{
|
||||
$this->db->expects($this->never())->method('prepare');
|
||||
|
||||
$this->assertSame(0, $this->repository->countSearch(".'"));
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
}
|
||||
}
|
||||
115
tests/Post/PostServiceCoverageTest.php
Normal file
115
tests/Post/PostServiceCoverageTest.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Application\PostApplicationService;
|
||||
use App\Post\Application\UseCase\CreatePost;
|
||||
use App\Post\Application\UseCase\DeletePost;
|
||||
use App\Post\Application\UseCase\UpdatePost;
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
use App\Post\Domain\Service\PostSlugGenerator;
|
||||
use Netig\Netslim\Kernel\Html\Application\HtmlSanitizerInterface;
|
||||
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PostServiceCoverageTest extends TestCase
|
||||
{
|
||||
/** @var PostRepositoryInterface&MockObject */
|
||||
private PostRepositoryInterface $repository;
|
||||
|
||||
/** @var HtmlSanitizerInterface&MockObject */
|
||||
private HtmlSanitizerInterface $sanitizer;
|
||||
|
||||
private PostApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(PostRepositoryInterface::class);
|
||||
$this->sanitizer = $this->createMock(HtmlSanitizerInterface::class);
|
||||
$slugGenerator = new PostSlugGenerator();
|
||||
$transactionManager = new class () implements TransactionManagerInterface {
|
||||
public function run(callable $operation): mixed
|
||||
{
|
||||
return $operation();
|
||||
}
|
||||
};
|
||||
$referenceExtractor = new class () implements PostMediaReferenceExtractorInterface {
|
||||
public function extractMediaIds(string $html): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$usageRepository = new class () implements PostMediaUsageRepositoryInterface {
|
||||
public function countUsages(int $mediaId): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function countUsagesByMediaIds(array $mediaIds): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function findUsages(int $mediaId, int $limit = 5): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function syncPostMedia(int $postId, array $mediaIds): void {}
|
||||
};
|
||||
$this->service = new PostApplicationService(
|
||||
$this->repository,
|
||||
new CreatePost($this->repository, $this->sanitizer, $slugGenerator, $transactionManager, $referenceExtractor, $usageRepository),
|
||||
new UpdatePost($this->repository, $this->sanitizer, $slugGenerator, $transactionManager, $referenceExtractor, $usageRepository),
|
||||
new DeletePost($this->repository),
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetAllPostsPassesCategoryIdToRepository(): void
|
||||
{
|
||||
$posts = [$this->makePost(1, 'Titre', 'titre')];
|
||||
$this->repository->expects($this->once())->method('findAll')->with(9)->willReturn($posts);
|
||||
|
||||
self::assertSame($posts, $this->service->findAll(9));
|
||||
}
|
||||
|
||||
public function testGetRecentPostsPassesLimitToRepository(): void
|
||||
{
|
||||
$posts = [$this->makePost(2, 'Titre', 'titre-2')];
|
||||
$this->repository->expects($this->once())->method('findRecent')->with(7)->willReturn($posts);
|
||||
|
||||
self::assertSame($posts, $this->service->findRecent(7));
|
||||
}
|
||||
|
||||
public function testCreatePostAddsNumericSuffixWhenBaseSlugAlreadyExists(): void
|
||||
{
|
||||
$this->sanitizer->expects($this->once())->method('sanitize')->with('<p>Brut</p>')->willReturn('<p>Sur</p>');
|
||||
$this->repository->expects($this->exactly(2))
|
||||
->method('slugExists')
|
||||
->withAnyParameters()
|
||||
->willReturnOnConsecutiveCalls(true, false);
|
||||
$this->repository->expects($this->once())
|
||||
->method('create')
|
||||
->with($this->isInstanceOf(Post::class), 'mon-titre-1', 4, 8)
|
||||
->willReturn(99);
|
||||
|
||||
self::assertSame(99, $this->service->create('Mon titre', '<p>Brut</p>', 4, 8));
|
||||
}
|
||||
|
||||
private function makePost(int $id, string $title, string $slug, string $content = '<p>Contenu</p>'): Post
|
||||
{
|
||||
return new Post($id, $title, $content, $slug);
|
||||
}
|
||||
}
|
||||
268
tests/Post/PostServiceTest.php
Normal file
268
tests/Post/PostServiceTest.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Application\PostApplicationService;
|
||||
use App\Post\Application\UseCase\CreatePost;
|
||||
use App\Post\Application\UseCase\DeletePost;
|
||||
use App\Post\Application\UseCase\UpdatePost;
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
use App\Post\Domain\Service\PostSlugGenerator;
|
||||
use Netig\Netslim\Kernel\Html\Application\HtmlSanitizerInterface;
|
||||
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour PostApplicationService.
|
||||
*
|
||||
* Couvre la création, la mise à jour, la suppression et les lectures.
|
||||
* HtmlSanitizerInterface et PostRepository sont mockés pour isoler la logique métier.
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class PostServiceTest extends TestCase
|
||||
{
|
||||
/** @var PostRepositoryInterface&MockObject */
|
||||
private PostRepositoryInterface $repository;
|
||||
|
||||
/** @var HtmlSanitizerInterface&MockObject */
|
||||
private HtmlSanitizerInterface $sanitizer;
|
||||
|
||||
private PostApplicationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(PostRepositoryInterface::class);
|
||||
$this->sanitizer = $this->createMock(HtmlSanitizerInterface::class);
|
||||
$slugGenerator = new PostSlugGenerator();
|
||||
$transactionManager = new class () implements TransactionManagerInterface {
|
||||
public function run(callable $operation): mixed
|
||||
{
|
||||
return $operation();
|
||||
}
|
||||
};
|
||||
$referenceExtractor = new class () implements PostMediaReferenceExtractorInterface {
|
||||
public function extractMediaIds(string $html): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$usageRepository = new class () implements PostMediaUsageRepositoryInterface {
|
||||
public function countUsages(int $mediaId): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function countUsagesByMediaIds(array $mediaIds): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function findUsages(int $mediaId, int $limit = 5): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function syncPostMedia(int $postId, array $mediaIds): void {}
|
||||
};
|
||||
$this->service = new PostApplicationService(
|
||||
$this->repository,
|
||||
new CreatePost($this->repository, $this->sanitizer, $slugGenerator, $transactionManager, $referenceExtractor, $usageRepository),
|
||||
new UpdatePost($this->repository, $this->sanitizer, $slugGenerator, $transactionManager, $referenceExtractor, $usageRepository),
|
||||
new DeletePost($this->repository),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── Lectures déléguées ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findAll() 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->findAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* findRecent() 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->findRecent(5));
|
||||
}
|
||||
|
||||
/**
|
||||
* findByUserId() délègue au repository.
|
||||
*/
|
||||
public function testGetPostsByUserIdDelegatesToRepository(): void
|
||||
{
|
||||
$posts = [$this->makePost(1, 'Titre', 'slug-titre')];
|
||||
$this->repository->expects($this->once())->method('findByUserId')->with(3, null)->willReturn($posts);
|
||||
|
||||
$this->assertSame($posts, $this->service->findByUserId(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* findBySlug() lève NotFoundException si l'article est introuvable.
|
||||
*/
|
||||
public function testGetPostBySlugThrowsNotFoundExceptionWhenMissing(): void
|
||||
{
|
||||
$this->repository->method('findBySlug')->willReturn(null);
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
|
||||
$this->service->findBySlug('slug-inexistant');
|
||||
}
|
||||
|
||||
/**
|
||||
* findBySlug() retourne l'article tel que stocké — la sanitisation
|
||||
* se fait à l'écriture (create / update), 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->findBySlug('mon-slug');
|
||||
|
||||
$this->assertSame('<p>Contenu stocké</p>', $result->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() lève NotFoundException si l'article est introuvable.
|
||||
*/
|
||||
public function testGetPostByIdThrowsNotFoundExceptionWhenMissing(): void
|
||||
{
|
||||
$this->repository->method('findById')->willReturn(null);
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
|
||||
$this->service->findById(999);
|
||||
}
|
||||
|
||||
/**
|
||||
* findById() retourne l'article trouvé.
|
||||
*/
|
||||
public function testGetPostByIdReturnsPost(): void
|
||||
{
|
||||
$post = $this->makePost(7, 'Titre', 'slug-7');
|
||||
$this->repository->expects($this->once())->method('findById')->with(7)->willReturn($post);
|
||||
|
||||
self::assertSame($post, $this->service->findById(7));
|
||||
}
|
||||
|
||||
// ── create ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* create() lève InvalidArgumentException si le titre est vide.
|
||||
*/
|
||||
public function testCreatePostThrowsWhenTitleEmpty(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->service->create('', '<p>Contenu</p>', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() lève InvalidArgumentException si le contenu est vide.
|
||||
*/
|
||||
public function testCreatePostThrowsWhenContentEmpty(): void
|
||||
{
|
||||
$this->sanitizer->method('sanitize')->willReturn('');
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->service->create('Titre', '', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* create() 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->create('Mon Titre', '<p>Contenu brut</p>', 1);
|
||||
|
||||
$this->assertSame(42, $id);
|
||||
}
|
||||
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* delete() 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->delete(99);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete() 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->delete(5);
|
||||
}
|
||||
|
||||
|
||||
// ── search ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* search() délègue la recherche au repository.
|
||||
*/
|
||||
public function testSearchPostsDelegatesToRepository(): void
|
||||
{
|
||||
$posts = [$this->makePost(1, 'Résultat', 'resultat')];
|
||||
$this->repository->expects($this->once())->method('search')->with('mot', null, null)->willReturn($posts);
|
||||
|
||||
$this->assertSame($posts, $this->service->search('mot'));
|
||||
}
|
||||
|
||||
|
||||
// ── update ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* update() 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->update(999, 'Titre', '<p>Contenu</p>');
|
||||
}
|
||||
|
||||
private function makePost(int $id, string $title, string $slug, string $content = '<p>Contenu</p>'): Post
|
||||
{
|
||||
return new Post($id, $title, $content, $slug);
|
||||
}
|
||||
}
|
||||
184
tests/Post/RssControllerTest.php
Normal file
184
tests/Post/RssControllerTest.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Application\PostServiceInterface;
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\UI\Http\RssController;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
/**
|
||||
* 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 à findRecent() avec la constante FEED_LIMIT (20)
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class RssControllerTest extends ControllerTestBase
|
||||
{
|
||||
/** @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('findRecent')->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('findRecent')->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('findRecent')->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('findRecent')->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('findRecent')->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 findRecent() avec la limite de 20 articles.
|
||||
*/
|
||||
public function testFeedRequestsTwentyRecentPosts(): void
|
||||
{
|
||||
$this->postService->expects($this->once())
|
||||
->method('findRecent')
|
||||
->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('findRecent')->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('findRecent')->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('findRecent')->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);
|
||||
}
|
||||
}
|
||||
67
tests/Post/TaxonUsageCheckerTest.php
Normal file
67
tests/Post/TaxonUsageCheckerTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Post;
|
||||
|
||||
use App\Post\Infrastructure\PdoTaxonUsageChecker;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class TaxonUsageCheckerTest extends TestCase
|
||||
{
|
||||
/** @var PDO&MockObject */
|
||||
private PDO $db;
|
||||
|
||||
private PdoTaxonUsageChecker $checker;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->db = $this->createMock(PDO::class);
|
||||
$this->checker = new PdoTaxonUsageChecker($this->db);
|
||||
}
|
||||
|
||||
private function stmtForScalar(mixed $value): MockObject&PDOStatement
|
||||
{
|
||||
$stmt = $this->createMock(PDOStatement::class);
|
||||
$stmt->method('execute')->willReturn(true);
|
||||
$stmt->method('fetchColumn')->willReturn($value);
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function testReturnsTrueWhenTaxonIsReferencedByAtLeastOnePost(): void
|
||||
{
|
||||
$stmt = $this->stmtForScalar(1);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
self::assertTrue($this->checker->isTaxonInUse(5));
|
||||
}
|
||||
|
||||
public function testReturnsFalseWhenTaxonIsUnused(): void
|
||||
{
|
||||
$stmt = $this->stmtForScalar(0);
|
||||
$this->db->method('prepare')->willReturn($stmt);
|
||||
|
||||
self::assertFalse($this->checker->isTaxonInUse(5));
|
||||
}
|
||||
|
||||
public function testQueriesPostsTableWithTaxonIdentifier(): void
|
||||
{
|
||||
$stmt = $this->stmtForScalar(0);
|
||||
|
||||
$this->db->expects($this->once())
|
||||
->method('prepare')
|
||||
->with($this->stringContains('FROM posts'))
|
||||
->willReturn($stmt);
|
||||
|
||||
$stmt->expects($this->once())
|
||||
->method('execute')
|
||||
->with([':category_id' => 5]);
|
||||
|
||||
$this->checker->isTaxonInUse(5);
|
||||
}
|
||||
}
|
||||
176
tests/Site/SiteControllerTest.php
Normal file
176
tests/Site/SiteControllerTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Site;
|
||||
|
||||
use App\Site\UI\Http\SiteController;
|
||||
use Netig\Netslim\AuditLog\Contracts\AuditEntryView;
|
||||
use Netig\Netslim\AuditLog\Contracts\AuditLoggerInterface;
|
||||
use Netig\Netslim\AuditLog\Contracts\AuditLogReaderInterface;
|
||||
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\Notifications\Application\NotificationServiceInterface;
|
||||
use Netig\Netslim\Notifications\Contracts\NotificationDispatchView;
|
||||
use Netig\Netslim\Settings\Application\SettingsServiceInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Tests\ControllerTestBase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
final class SiteControllerTest extends ControllerTestBase
|
||||
{
|
||||
private \Slim\Views\Twig $view;
|
||||
|
||||
private MockObject&SettingsServiceInterface $settings;
|
||||
|
||||
private AuditLoggerInterface&MockObject $auditLogger;
|
||||
|
||||
private AuditLogReaderInterface&MockObject $auditLogReader;
|
||||
|
||||
private MockObject&NotificationServiceInterface $notifications;
|
||||
|
||||
private AuthorizationServiceInterface&MockObject $authorization;
|
||||
|
||||
private MockObject&SessionManagerInterface $sessionManager;
|
||||
|
||||
private FlashServiceInterface&MockObject $flash;
|
||||
|
||||
private SiteController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->view = $this->makeTwigMock();
|
||||
$this->settings = $this->createMock(SettingsServiceInterface::class);
|
||||
$this->auditLogger = $this->createMock(AuditLoggerInterface::class);
|
||||
$this->auditLogReader = $this->createMock(AuditLogReaderInterface::class);
|
||||
$this->notifications = $this->createMock(NotificationServiceInterface::class);
|
||||
$this->authorization = $this->createMock(AuthorizationServiceInterface::class);
|
||||
$this->sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
$this->flash = $this->createMock(FlashServiceInterface::class);
|
||||
|
||||
$this->authorization->method('canRole')->willReturnMap([
|
||||
['admin', Permission::SETTINGS_MANAGE, true],
|
||||
['admin', Permission::AUDIT_LOG_VIEW, true],
|
||||
['admin', Permission::NOTIFICATIONS_SEND, true],
|
||||
['admin', Permission::CONTENT_MANAGE, true],
|
||||
['user', Permission::SETTINGS_MANAGE, false],
|
||||
['user', Permission::AUDIT_LOG_VIEW, false],
|
||||
['user', Permission::NOTIFICATIONS_SEND, false],
|
||||
['user', Permission::CONTENT_MANAGE, false],
|
||||
]);
|
||||
$this->sessionManager->method('isAdmin')->willReturn(true);
|
||||
$this->sessionManager->method('isEditor')->willReturn(false);
|
||||
$this->sessionManager->method('getUserId')->willReturn(1);
|
||||
$this->settings->method('getString')->willReturnMap([
|
||||
['site.title', 'Netslim Blog', 'Netslim Blog'],
|
||||
['site.tagline', '', 'Baseline'],
|
||||
['site.meta_description', '', 'Description'],
|
||||
['blog.home_intro', '', 'Accueil'],
|
||||
['notifications.demo_recipient', '', 'demo@example.test'],
|
||||
]);
|
||||
$this->settings->method('getInt')->willReturnMap([
|
||||
['blog.public_posts_per_page', 6, 6],
|
||||
['blog.admin_posts_per_page', 12, 12],
|
||||
]);
|
||||
$this->auditLogReader->method('listRecent')->willReturn([
|
||||
new AuditEntryView(1, 'settings.updated', 'settings', 'blog', 1, [], '2026-03-20T12:00:00+00:00'),
|
||||
]);
|
||||
$this->notifications->method('recent')->willReturn([
|
||||
new NotificationDispatchView(1, 'demo@example.test', 'Sujet', '@Site/emails/demo-notification.twig', 'sent', 'site.demo', null, '2026-03-20T12:00:00+00:00', '2026-03-20T12:00:01+00:00'),
|
||||
]);
|
||||
|
||||
$this->controller = new SiteController(
|
||||
$this->view,
|
||||
$this->settings,
|
||||
$this->auditLogger,
|
||||
$this->auditLogReader,
|
||||
$this->notifications,
|
||||
$this->authorization,
|
||||
$this->sessionManager,
|
||||
$this->flash,
|
||||
);
|
||||
}
|
||||
|
||||
public function testSettingsPageRedirectsWhenPermissionIsMissing(): void
|
||||
{
|
||||
$sessionManager = $this->createMock(SessionManagerInterface::class);
|
||||
$sessionManager->method('isAdmin')->willReturn(false);
|
||||
$sessionManager->method('isEditor')->willReturn(false);
|
||||
$sessionManager->method('getUserId')->willReturn(1);
|
||||
|
||||
$authorization = $this->createMock(AuthorizationServiceInterface::class);
|
||||
$authorization->expects($this->once())
|
||||
->method('canRole')
|
||||
->with('user', Permission::SETTINGS_MANAGE)
|
||||
->willReturn(false);
|
||||
|
||||
$controller = new SiteController(
|
||||
$this->view,
|
||||
$this->settings,
|
||||
$this->auditLogger,
|
||||
$this->auditLogReader,
|
||||
$this->notifications,
|
||||
$authorization,
|
||||
$sessionManager,
|
||||
$this->flash,
|
||||
);
|
||||
|
||||
$this->flash->expects($this->once())->method('set')->with('site_error', $this->stringContains('réglages'));
|
||||
|
||||
$response = $controller->settings($this->makeGet('/admin/settings'), $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($response, '/admin');
|
||||
}
|
||||
|
||||
public function testSaveSettingsPersistsValuesAndAuditsChange(): void
|
||||
{
|
||||
$this->settings->expects($this->exactly(7))->method('set');
|
||||
$this->auditLogger->expects($this->once())->method('record')->with(
|
||||
'settings.updated',
|
||||
'settings',
|
||||
'blog',
|
||||
1,
|
||||
$this->callback(fn (array $context): bool => isset($context['keys']) && count($context['keys']) === 7),
|
||||
);
|
||||
$this->flash->expects($this->once())->method('set')->with('site_success', $this->stringContains('réglages'));
|
||||
|
||||
$response = $this->controller->saveSettings($this->makePost('/admin/settings', [
|
||||
'site_title' => 'Mon blog',
|
||||
'site_tagline' => 'Baseline',
|
||||
'site_meta_description' => 'Description',
|
||||
'home_intro' => 'Accueil',
|
||||
'public_posts_per_page' => '8',
|
||||
'admin_posts_per_page' => '16',
|
||||
'demo_recipient' => 'demo@example.test',
|
||||
]), $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($response, '/admin/settings');
|
||||
}
|
||||
|
||||
public function testSendNotificationUsesNotificationServiceAndWritesAuditEntry(): void
|
||||
{
|
||||
$this->notifications->expects($this->once())->method('sendTemplate')->with(
|
||||
'demo@example.test',
|
||||
'Sujet',
|
||||
'@Site/emails/demo-notification.twig',
|
||||
$this->callback(static fn (mixed $context): bool => is_array($context)),
|
||||
'site.demo-notification',
|
||||
);
|
||||
$this->auditLogger->expects($this->once())->method('record')->with(
|
||||
'notification.sent',
|
||||
'notification',
|
||||
'demo@example.test',
|
||||
1,
|
||||
$this->callback(fn (array $context): bool => ($context['subject'] ?? null) === 'Sujet'),
|
||||
);
|
||||
|
||||
$response = $this->controller->sendNotification($this->makePost('/admin/notifications/send', [
|
||||
'recipient' => 'demo@example.test',
|
||||
'subject' => 'Sujet',
|
||||
]), $this->makeResponse());
|
||||
|
||||
$this->assertRedirectTo($response, '/admin/notifications');
|
||||
}
|
||||
}
|
||||
31
tests/Site/SiteSettingsExtensionTest.php
Normal file
31
tests/Site/SiteSettingsExtensionTest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Site;
|
||||
|
||||
use App\Site\UI\Twig\SiteSettingsExtension;
|
||||
use Netig\Netslim\Settings\Contracts\SettingsReaderInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SiteSettingsExtensionTest extends TestCase
|
||||
{
|
||||
public function testGlobalsExposeConfiguredSiteSettings(): void
|
||||
{
|
||||
$settings = $this->createStub(SettingsReaderInterface::class);
|
||||
$settings->method('getString')->willReturnMap([
|
||||
['site.title', 'Netslim Blog', 'Mon site'],
|
||||
['site.tagline', 'Un blog éditorial construit sur netslim-core.', 'Ma baseline'],
|
||||
['site.meta_description', 'Application blog construite sur netslim-core.', 'Ma description'],
|
||||
['blog.home_intro', 'Bienvenue sur le blog.', 'Mon intro'],
|
||||
]);
|
||||
|
||||
$extension = new SiteSettingsExtension($settings);
|
||||
$globals = $extension->getGlobals();
|
||||
|
||||
$this->assertSame('Mon site', $globals['site']['title']);
|
||||
$this->assertSame('Ma baseline', $globals['site']['tagline']);
|
||||
$this->assertSame('Ma description', $globals['site']['metaDescription']);
|
||||
$this->assertSame('Mon intro', $globals['site']['homeIntro']);
|
||||
}
|
||||
}
|
||||
12
tests/bootstrap.php
Normal file
12
tests/bootstrap.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleRegistry;
|
||||
use Netig\Netslim\Kernel\Runtime\RuntimePaths;
|
||||
|
||||
RuntimePaths::setProjectRoot(dirname(__DIR__));
|
||||
RuntimePaths::setApplicationRoot(dirname(__DIR__));
|
||||
ModuleRegistry::reset();
|
||||
Reference in New Issue
Block a user