From d51c7f861ac78a71aa9f94fd8c516553dc63a3a3 Mon Sep 17 00:00:00 2001 From: bidi47 Date: Fri, 16 Oct 2026 16:50:27 +0300 Subject: [PATCH] updated category filters Signed-off-by: bidi47 --- src/Blog/src/Entity/Category.php | 8 + .../Handler/GetCategoryCollectionHandler.php | 2 +- .../Handler/GetCategoryResourceHandler.php | 2 +- .../src/Repository/CategoryRepository.php | 4 +- .../GetCategoryCollectionHandlerTest.php | 65 ++++++++ .../GetCategoryResourceHandlerTest.php | 75 ++++++++- .../Repository/CategoryRepositoryTest.php | 151 ++++++++++++++++++ 7 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 test/Unit/Blog/Handler/GetCategoryCollectionHandlerTest.php create mode 100644 test/Unit/Blog/Repository/CategoryRepositoryTest.php diff --git a/src/Blog/src/Entity/Category.php b/src/Blog/src/Entity/Category.php index 62233a0a..63bbb820 100644 --- a/src/Blog/src/Entity/Category.php +++ b/src/Blog/src/Entity/Category.php @@ -58,6 +58,14 @@ public function setSlug(string $slug): void $this->slug = $slug; } + /** + * Visibility only controls the category sidebar widget (and the 404/410 pages, post, tag, author + * and page views that render it), via CategoryRepository::getCategories(). + * + * It does not hide a category that has published posts: those are still listed on /categories/ + * and in the sitemap (CategoryRepository::getCategoriesWithPublishedPosts()), and their + * /category/{slug}/ page is served. + */ public function isVisible(): bool { return $this->isVisible; diff --git a/src/Blog/src/Handler/GetCategoryCollectionHandler.php b/src/Blog/src/Handler/GetCategoryCollectionHandler.php index 3b5bfed4..95b083cf 100644 --- a/src/Blog/src/Handler/GetCategoryCollectionHandler.php +++ b/src/Blog/src/Handler/GetCategoryCollectionHandler.php @@ -21,7 +21,7 @@ public function __construct( public function handle(ServerRequestInterface $request): ResponseInterface { - $categories = $this->categoryRepository->getCategories(); + $categories = $this->categoryRepository->getCategoriesWithPublishedPosts(); return new HtmlResponse( $this->template->render('page::categories', [ diff --git a/src/Blog/src/Handler/GetCategoryResourceHandler.php b/src/Blog/src/Handler/GetCategoryResourceHandler.php index 8133028a..926f15a0 100644 --- a/src/Blog/src/Handler/GetCategoryResourceHandler.php +++ b/src/Blog/src/Handler/GetCategoryResourceHandler.php @@ -31,7 +31,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface if ($category === null) { return $this->blogService->notFound($categories); } - if (! $category->isVisible()) { + if ($category->getPublishedPostsCount() === 0) { return $this->blogService->gone($categories); } $meta = $category; diff --git a/src/Blog/src/Repository/CategoryRepository.php b/src/Blog/src/Repository/CategoryRepository.php index e9944c5b..542a2bb4 100644 --- a/src/Blog/src/Repository/CategoryRepository.php +++ b/src/Blog/src/Repository/CategoryRepository.php @@ -42,9 +42,7 @@ public function getCategoriesWithPublishedPosts(): array ->from(Category::class, 'categories'); $qb->where($qb->expr()->in('categories.id', $publishedCategoryIds->getDQL())) - ->andWhere('categories.isVisible = :visible') - ->setParameter('published', PostStatusEnum::Published) - ->setParameter('visible', true); + ->setParameter('published', PostStatusEnum::Published); return $qb->getQuery()->getResult(); } diff --git a/test/Unit/Blog/Handler/GetCategoryCollectionHandlerTest.php b/test/Unit/Blog/Handler/GetCategoryCollectionHandlerTest.php new file mode 100644 index 00000000..f5d7e7f6 --- /dev/null +++ b/test/Unit/Blog/Handler/GetCategoryCollectionHandlerTest.php @@ -0,0 +1,65 @@ +setVisibility(false); + $categories = [$hiddenWithPosts]; + + $categoryRepository = $this->createMock(CategoryRepository::class); + $categoryRepository->expects($this->once()) + ->method('getCategoriesWithPublishedPosts') + ->willReturn($categories); + $categoryRepository->expects($this->never())->method('getCategories'); + + $template = $this->createMock(TemplateRendererInterface::class); + $template->expects($this->once()) + ->method('render') + ->with('page::categories', ['categories' => $categories]) + ->willReturn(''); + + $response = (new GetCategoryCollectionHandler($template, $categoryRepository)) + ->handle(new ServerRequest()); + + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * @throws Exception + */ + public function testHandleRendersEmptyListWhenNoCategoryHasPublishedPosts(): void + { + $categoryRepository = $this->createStub(CategoryRepository::class); + $categoryRepository->method('getCategoriesWithPublishedPosts')->willReturn([]); + + $template = $this->createMock(TemplateRendererInterface::class); + $template->expects($this->once()) + ->method('render') + ->with('page::categories', ['categories' => []]) + ->willReturn(''); + + $response = (new GetCategoryCollectionHandler($template, $categoryRepository)) + ->handle(new ServerRequest()); + + $this->assertSame(200, $response->getStatusCode()); + } +} diff --git a/test/Unit/Blog/Handler/GetCategoryResourceHandlerTest.php b/test/Unit/Blog/Handler/GetCategoryResourceHandlerTest.php index 67ce8642..5525e9f4 100644 --- a/test/Unit/Blog/Handler/GetCategoryResourceHandlerTest.php +++ b/test/Unit/Blog/Handler/GetCategoryResourceHandlerTest.php @@ -4,6 +4,8 @@ namespace LightTest\Unit\Blog\Handler; +use Doctrine\ORM\Query; +use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\ServerRequest; @@ -31,16 +33,87 @@ public function testHandleReturnsNotFoundWhenCategoryDoesNotExist(): void /** * @throws Exception */ - public function testHandleReturnsGoneWhenCategoryIsNotVisible(): void + public function testHandleReturnsGoneWhenCategoryIsNotVisibleAndHasNoPublishedPosts(): void { $category = $this->createStub(Category::class); $category->method('isVisible')->willReturn(false); + $category->method('getPublishedPostsCount')->willReturn(0); $response = $this->handle($category); $this->assertSame(410, $response->getStatusCode()); } + /** + * @throws Exception + */ + public function testHandleReturnsGoneWhenVisibleCategoryHasNoPublishedPosts(): void + { + $category = $this->createStub(Category::class); + $category->method('isVisible')->willReturn(true); + $category->method('getPublishedPostsCount')->willReturn(0); + + $response = $this->handle($category); + + $this->assertSame(410, $response->getStatusCode()); + } + + /** + * A hidden category is listed on /categories/ when it has published posts, so its page must be served. + * + * @throws Exception + */ + public function testHandleRendersHiddenCategoryThatHasPublishedPosts(): void + { + $category = $this->createStub(Category::class); + $category->method('isVisible')->willReturn(false); + $category->method('getPublishedPostsCount')->willReturn(2); + + $this->assertSame(200, $this->handleRendering($category)->getStatusCode()); + } + + /** + * @throws Exception + */ + public function testHandleRendersVisibleCategoryThatHasPublishedPosts(): void + { + $category = $this->createStub(Category::class); + $category->method('isVisible')->willReturn(true); + $category->method('getPublishedPostsCount')->willReturn(1); + + $this->assertSame(200, $this->handleRendering($category)->getStatusCode()); + } + + /** + * @throws Exception + */ + private function handleRendering(Category $category): ResponseInterface + { + $query = $this->createStub(Query::class); + $query->method('getResult')->willReturn([]); + + $paginator = $this->createStub(DoctrinePaginator::class); + $paginator->method('count')->willReturn(0); + $paginator->method('getQuery')->willReturn($query); + + $categoryRepository = $this->createStub(CategoryRepository::class); + $categoryRepository->method('getCategories')->willReturn([]); + $categoryRepository->method('getCategoryResource')->willReturn($category); + $categoryRepository->method('getCategoryPost')->willReturn($paginator); + + $template = $this->createMock(TemplateRendererInterface::class); + $template->expects($this->once())->method('render') + ->with('page::category-resource', $this->anything()) + ->willReturn(''); + + $blogService = $this->createMock(BlogServiceInterface::class); + $blogService->expects($this->never())->method('gone'); + $blogService->expects($this->never())->method('notFound'); + + return (new GetCategoryResourceHandler($template, $categoryRepository, $blogService)) + ->handle((new ServerRequest())->withAttribute('slug', 'a-category')); + } + /** * @throws Exception */ diff --git a/test/Unit/Blog/Repository/CategoryRepositoryTest.php b/test/Unit/Blog/Repository/CategoryRepositoryTest.php new file mode 100644 index 00000000..bc906a3c --- /dev/null +++ b/test/Unit/Blog/Repository/CategoryRepositoryTest.php @@ -0,0 +1,151 @@ +createChainableQuery(); + $query->expects($this->once())->method('getResult')->willReturn($categories); + + $dql = null; + $entityManager = $this->createEntityManager(function (string $queryDql) use (&$dql, $query): Query { + $dql = $queryDql; + + return $query; + }); + + $result = $this->createRepository($entityManager)->getCategoriesWithPublishedPosts(); + + $this->assertSame($categories, $result); + $this->assertIsString($dql); + $this->assertStringNotContainsString('isVisible', $dql); + } + + /** + * Only published posts count: a category with just drafts, private or archived posts is not listed. + * + * @throws Exception + */ + public function testGetCategoriesWithPublishedPostsOnlyConsidersPublishedPosts(): void + { + $query = $this->createChainableQuery(); + $query->expects($this->once())->method('getResult')->willReturn([]); + + $dql = null; + $entityManager = $this->createEntityManager(function (string $queryDql) use (&$dql, $query): Query { + $dql = $queryDql; + + return $query; + }); + + $this->createRepository($entityManager)->getCategoriesWithPublishedPosts(); + + $this->assertIsString($dql); + $this->assertStringContainsString('post.status = :published', $dql); + $this->assertStringContainsString('categories.id IN', $dql); + } + + /** + * @throws Exception + */ + public function testGetCategoriesWithPublishedPostsBindsThePublishedStatus(): void + { + $query = $this->createChainableQuery(); + $query->method('getResult')->willReturn([]); + $query->expects($this->once())->method('setParameters')->with($this->callback( + function (ArrayCollection $parameters): bool { + $parameter = $parameters->first(); + + return $parameters->count() === 1 + && $parameter instanceof Parameter + && $parameter->getName() === 'published' + && $parameter->getValue() === PostStatusEnum::Published; + } + ))->willReturnSelf(); + + $entityManager = $this->createEntityManager(static fn (): Query => $query); + + $this->createRepository($entityManager)->getCategoriesWithPublishedPosts(); + } + + /** + * @throws Exception + */ + public function testGetCategoriesStillFiltersOnVisibility(): void + { + $query = $this->createChainableQuery(); + $query->expects($this->once())->method('getResult')->willReturn([]); + + $dql = null; + $entityManager = $this->createEntityManager(function (string $queryDql) use (&$dql, $query): Query { + $dql = $queryDql; + + return $query; + }); + + $this->createRepository($entityManager)->getCategories(); + + $this->assertIsString($dql); + $this->assertStringContainsString('categories.isVisible = :visible', $dql); + } + + /** + * Entity manager whose query builders are real (so DQL is built for real) and whose + * createQuery() hands the generated DQL to $onCreateQuery. + * + * @param callable(string): Query $onCreateQuery + * @throws Exception + */ + private function createEntityManager(callable $onCreateQuery): EntityManagerInterface + { + $entityManager = $this->createStub(EntityManagerInterface::class); + $entityManager->method('createQueryBuilder') + ->willReturnCallback(static fn (): QueryBuilder => new QueryBuilder($entityManager)); + $entityManager->method('getExpressionBuilder')->willReturn(new Expr()); + $entityManager->method('createQuery') + ->willReturnCallback(static fn (string $dql = ''): Query => $onCreateQuery($dql)); + + return $entityManager; + } + + /** + * @throws Exception + */ + private function createChainableQuery(): Query&MockObject + { + $query = $this->createMock(Query::class); + $query->method('setParameters')->willReturnSelf(); + $query->method('setParameter')->willReturnSelf(); + $query->method('setFirstResult')->willReturnSelf(); + $query->method('setMaxResults')->willReturnSelf(); + + return $query; + } + + private function createRepository(EntityManagerInterface $entityManager): CategoryRepository + { + return new CategoryRepository($entityManager, new ClassMetadata(Category::class)); + } +}