diff --git a/bin/sitemap b/bin/sitemap new file mode 100755 index 0000000..64981aa --- /dev/null +++ b/bin/sitemap @@ -0,0 +1,23 @@ +#!/usr/bin/env php +get(SitemapGenerator::class); + +$count = $sitemapGenerator->write(); + +printf( + "Done. %d url%s written to %s%s", + $count, + $count === 1 ? '' : 's', + $sitemapGenerator->getSitemapFile(), + PHP_EOL +); diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist index 40881f0..0b9acc5 100644 --- a/config/autoload/local.php.dist +++ b/config/autoload/local.php.dist @@ -64,11 +64,14 @@ return [ * scopes at all; without a token the generator still runs, at a lower rate limit. * Non-credential settings (ignore list, output path) live in `packages.global.php`. */ - 'github' => [ + 'github' => [ 'userAgent' => 'dotkernel.com', 'authBearer' => '', 'org' => 'dotkernel', ], + 'sitemap' => [ + 'path' => realpath(__DIR__ . '/../../public/sitemap.xml'), + ], // `dotkernel-packages-oss-lifecycle` is intentionally absent: it has a dedicated handler // and is routed in Light\App\RoutesDelegator. Re-adding it here registers the path twice. 'routes' => [ diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..e69de29 diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index 5fae52c..754779a 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -17,17 +17,21 @@ use Light\App\Factory\GetIndexViewHandlerFactory; use Light\App\Factory\GetMarkdownArticleHandlerFactory; use Light\App\Factory\GetPackagesViewHandlerFactory; +use Light\App\Factory\GetSitemapViewHandlerFactory; use Light\App\Factory\GitHubClientFactory; use Light\App\Factory\PackageGeneratorFactory; +use Light\App\Factory\SitemapGeneratorFactory; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; +use Light\App\Handler\GetSitemapViewHandler; use Light\App\Resolver\EntityListenerResolver; use Light\App\Service\FeedGenerator; use Light\App\Service\GitHubClient; use Light\App\Service\GitHubClientInterface; use Light\App\Service\PackageGenerator; +use Light\App\Service\SitemapGenerator; use Mezzio\Application; use Roave\PsrContainerDoctrine\EntityManagerFactory; use Symfony\Component\Cache\Adapter\AdapterInterface; @@ -128,8 +132,10 @@ public function getDependencies(): array GetIndexViewHandler::class => GetIndexViewHandlerFactory::class, GetFeedViewHandler::class => GetFeedViewHandlerFactory::class, GetMarkdownArticleHandler::class => GetMarkdownArticleHandlerFactory::class, + GetSitemapViewHandler::class => GetSitemapViewHandlerFactory::class, GetPackagesViewHandler::class => GetPackagesViewHandlerFactory::class, FeedGenerator::class => FeedGeneratorFactory::class, + SitemapGenerator::class => SitemapGeneratorFactory::class, GitHubClient::class => GitHubClientFactory::class, PackageGenerator::class => PackageGeneratorFactory::class, ], diff --git a/src/App/src/Factory/FeedGeneratorFactory.php b/src/App/src/Factory/FeedGeneratorFactory.php index a05087a..154a5e5 100644 --- a/src/App/src/Factory/FeedGeneratorFactory.php +++ b/src/App/src/Factory/FeedGeneratorFactory.php @@ -9,7 +9,6 @@ use Psr\Container\ContainerInterface; use function assert; -use function rtrim; class FeedGeneratorFactory { @@ -23,7 +22,7 @@ public function __invoke(ContainerInterface $container): FeedGenerator return new FeedGenerator( $postRepository, $config['feed']['path'], - rtrim($config['application']['baseUrl'] ?? '', '/') . '/', + $config['application']['baseUrl'] ?? '', $config['application']['meta']['title'] ?? '', $config['application']['meta']['description'] ?? '', $config['application']['meta']['image'] ?? '', diff --git a/src/App/src/Factory/GetSitemapViewHandlerFactory.php b/src/App/src/Factory/GetSitemapViewHandlerFactory.php new file mode 100644 index 0000000..237f02a --- /dev/null +++ b/src/App/src/Factory/GetSitemapViewHandlerFactory.php @@ -0,0 +1,33 @@ +get(TemplateRendererInterface::class); + assert($template instanceof TemplateRendererInterface); + + $categoryRepository = $container->get(CategoryRepository::class); + assert($categoryRepository instanceof CategoryRepository); + + $sitemapGenerator = $container->get(SitemapGenerator::class); + assert($sitemapGenerator instanceof SitemapGenerator); + + return new GetSitemapViewHandler($template, $categoryRepository, $sitemapGenerator); + } +} diff --git a/src/App/src/Factory/SitemapGeneratorFactory.php b/src/App/src/Factory/SitemapGeneratorFactory.php new file mode 100644 index 0000000..488706c --- /dev/null +++ b/src/App/src/Factory/SitemapGeneratorFactory.php @@ -0,0 +1,28 @@ +get(PostRepository::class); + assert($postRepository instanceof PostRepository); + + $config = $container->get('config'); + + return new SitemapGenerator( + $postRepository, + $config['sitemap']['path'], + $config['application']['baseUrl'] ?? '', + ); + } +} diff --git a/src/App/src/Handler/GetSitemapViewHandler.php b/src/App/src/Handler/GetSitemapViewHandler.php new file mode 100644 index 0000000..fffdd17 --- /dev/null +++ b/src/App/src/Handler/GetSitemapViewHandler.php @@ -0,0 +1,66 @@ +sitemapGenerator->getSitemapFile(); + + if (! is_file($sitemapFile) || filesize($sitemapFile) === 0) { + try { + $this->sitemapGenerator->write(); + } catch (Throwable) { + } + } + + if (! is_file($sitemapFile) || filesize($sitemapFile) === 0) { + return $this->notFound($this->categoryRepository->getCategories()); + } + + return new XmlResponse( + (string) file_get_contents($sitemapFile), + StatusCodeInterface::STATUS_OK, + ['Content-Type' => SitemapGenerator::CONTENT_TYPE] + ); + } + + /** + * @param Category[] $categories + */ + private function notFound(array $categories): HtmlResponse + { + return new HtmlResponse( + $this->template->render('error::404', [ + 'categories' => $categories, + ]), + StatusCodeInterface::STATUS_NOT_FOUND + ); + } +} diff --git a/src/App/src/RoutesDelegator.php b/src/App/src/RoutesDelegator.php index 613b0c3..fd61ac2 100644 --- a/src/App/src/RoutesDelegator.php +++ b/src/App/src/RoutesDelegator.php @@ -9,6 +9,7 @@ use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; +use Light\App\Handler\GetSitemapViewHandler; use Mezzio\Application; use Psr\Container\ContainerInterface; @@ -22,6 +23,7 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal assert($app instanceof Application); $app->get('/', [GetIndexViewHandler::class], 'app::index'); $app->get('/feed/', [GetFeedViewHandler::class], 'app::feed'); + $app->get('/sitemap/', [GetSitemapViewHandler::class], 'app::sitemap'); $app->get('/{categorySlug}/{slug}.md', [GetMarkdownArticleHandler::class], 'app::markdown-article'); // Route name kept as `page::…` because `@layout/default.html.twig` links it by name. diff --git a/src/App/src/Service/FeedGenerator.php b/src/App/src/Service/FeedGenerator.php index 7b2b7be..c99de8e 100644 --- a/src/App/src/Service/FeedGenerator.php +++ b/src/App/src/Service/FeedGenerator.php @@ -55,7 +55,7 @@ public function write(): int $this->appendText($dom, $channel, 'lastBuildDate', (new DateTimeImmutable())->format(DateTimeInterface::RSS)); foreach ($posts as $post) { - $link = $this->baseUrl . $post->getCategory()->getSlug() . '/' . $post->getSlug() . '/'; + $link = $this->baseUrl . '/' . $post->getCategory()->getSlug() . '/' . $post->getSlug() . '/'; $item = $dom->createElement('item'); $channel->appendChild($item); diff --git a/src/App/src/Service/SitemapGenerator.php b/src/App/src/Service/SitemapGenerator.php new file mode 100644 index 0000000..9fdbd82 --- /dev/null +++ b/src/App/src/Service/SitemapGenerator.php @@ -0,0 +1,74 @@ +sitemapFile; + } + + public function write(): int + { + $posts = $this->postRepository->getPublishedPosts(); + + $dom = new DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + + $urlset = $dom->createElementNS(self::SITEMAP_NAMESPACE, 'urlset'); + $dom->appendChild($urlset); + + $this->appendUrl($dom, $urlset, $this->baseUrl); + + foreach ($posts as $post) { + $link = $this->baseUrl . '/' . $post->getCategory()->getSlug() . '/' . $post->getSlug() . '/'; + $this->appendUrl($dom, $urlset, $link, $post->getPostDate()->format(DateTimeInterface::W3C)); + } + + if ($dom->save($this->sitemapFile) === false) { + throw new RuntimeException('Unable to write sitemap.'); + } + + return count($posts) + 1; + } + + private function appendUrl(DOMDocument $dom, DOMElement $urlset, string $loc, ?string $lastmod = null): void + { + $url = $dom->createElement('url'); + $urlset->appendChild($url); + + $this->appendText($dom, $url, 'loc', $loc); + if ($lastmod !== null) { + $this->appendText($dom, $url, 'lastmod', $lastmod); + } + } + + private function appendText(DOMDocument $dom, DOMElement $parent, string $name, string $text): void + { + $el = $dom->createElement($name); + $el->appendChild($dom->createTextNode($text)); + $parent->appendChild($el); + } +} diff --git a/src/App/templates/JSON-LD/index.jsonld.twig b/src/App/templates/JSON-LD/index.jsonld.twig index 704e37d..50396ef 100644 --- a/src/App/templates/JSON-LD/index.jsonld.twig +++ b/src/App/templates/JSON-LD/index.jsonld.twig @@ -4,7 +4,7 @@ "@graph": [ { "@type": "WebSite", - "@id": "{{ absolute_url('/') }}#website", + "@id": "{{ absolute_url('/') }}#website ", "url": "{{ absolute_url('/') }}", "name": "Dotkernel", "description": "Dotkernel is a collection of open-source application skeletons built on Mezzio and Laminas - pre-configured and ready for anything from a presentation site to an enterprise-grade API.", diff --git a/test/Unit/App/Handler/GetFeedViewHandlerTest.php b/test/Unit/App/Handler/GetFeedViewHandlerTest.php new file mode 100644 index 0000000..0990bb0 --- /dev/null +++ b/test/Unit/App/Handler/GetFeedViewHandlerTest.php @@ -0,0 +1,174 @@ +feedFile = sprintf( + '%s%slight-feed-view-%s%sfeed.xml', + sys_get_temp_dir(), + DIRECTORY_SEPARATOR, + bin2hex(random_bytes(8)), + DIRECTORY_SEPARATOR + ); + } + + protected function tearDown(): void + { + if (is_file($this->feedFile)) { + unlink($this->feedFile); + } + + $directory = dirname($this->feedFile); + if (is_dir($directory)) { + rmdir($directory); + } + + parent::tearDown(); + } + + /** + * @throws Exception + */ + public function testHandleServesTheExistingFileWithoutRegenerating(): void + { + $this->writeFeedFile('cached'); + + $feedGenerator = $this->createMock(FeedGenerator::class); + $feedGenerator->method('getFeedFile')->willReturn($this->feedFile); + $feedGenerator->expects($this->never())->method('write'); + + $response = $this->createHandler($feedGenerator)->handle(new ServerRequest()); + + $this->assertInstanceOf(XmlResponse::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(FeedGenerator::CONTENT_TYPE, $response->getHeaderLine('Content-Type')); + $this->assertSame('cached', (string) $response->getBody()); + } + + /** + * @throws Exception + */ + public function testHandleGeneratesTheFeedWhenTheFileIsMissing(): void + { + $feedGenerator = $this->createMock(FeedGenerator::class); + $feedGenerator->method('getFeedFile')->willReturn($this->feedFile); + $feedGenerator->expects($this->once())->method('write') + ->willReturnCallback(function (): int { + $this->writeFeedFile('fresh'); + return 1; + }); + + $response = $this->createHandler($feedGenerator)->handle(new ServerRequest()); + + $this->assertInstanceOf(XmlResponse::class, $response); + $this->assertSame('fresh', (string) $response->getBody()); + } + + /** + * @throws Exception + */ + public function testHandleGeneratesTheFeedWhenTheExistingFileIsEmpty(): void + { + $this->writeFeedFile(''); + + $feedGenerator = $this->createMock(FeedGenerator::class); + $feedGenerator->method('getFeedFile')->willReturn($this->feedFile); + $feedGenerator->expects($this->once())->method('write') + ->willReturnCallback(function (): int { + $this->writeFeedFile('fresh'); + return 1; + }); + + $this->createHandler($feedGenerator)->handle(new ServerRequest()); + } + + /** + * @throws Exception + */ + public function testHandleReturns404WhenGenerationFailsAndNoFileExists(): void + { + $feedGenerator = $this->createStub(FeedGenerator::class); + $feedGenerator->method('getFeedFile')->willReturn($this->feedFile); + $feedGenerator->method('write')->willThrowException(new RuntimeException('boom')); + + $categories = [$this->createStub(Category::class)]; + $categoryRepository = $this->createStub(CategoryRepository::class); + $categoryRepository->method('getCategories')->willReturn($categories); + + $template = $this->createMock(TemplateRendererInterface::class); + $template->expects($this->once())->method('render') + ->willReturnCallback(function (string $name, mixed $parameters = []) use ($categories): string { + $this->assertSame('error::404', $name); + $this->assertIsArray($parameters); + $this->assertSame($categories, $parameters['categories']); + + return 'not found'; + }); + + $response = $this->createHandler($feedGenerator, $template, $categoryRepository) + ->handle(new ServerRequest()); + + $this->assertInstanceOf(HtmlResponse::class, $response); + $this->assertSame(404, $response->getStatusCode()); + } + + /** + * @throws Exception + */ + private function createHandler( + FeedGenerator $feedGenerator, + ?TemplateRendererInterface $template = null, + ?CategoryRepository $categoryRepository = null, + ): GetFeedViewHandler { + return new GetFeedViewHandler( + $template ?? $this->createStub(TemplateRendererInterface::class), + $categoryRepository ?? $this->createStub(CategoryRepository::class), + $feedGenerator, + ); + } + + private function writeFeedFile(string $contents): void + { + $directory = dirname($this->feedFile); + if (! is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents($this->feedFile, $contents); + } +} diff --git a/test/Unit/App/Handler/GetIndexViewHandlerTest.php b/test/Unit/App/Handler/GetIndexViewHandlerTest.php new file mode 100644 index 0000000..3ebb14e --- /dev/null +++ b/test/Unit/App/Handler/GetIndexViewHandlerTest.php @@ -0,0 +1,47 @@ +createStub(Post::class), $this->createStub(Post::class)]; + + $postRepository = $this->createMock(PostRepository::class); + $postRepository->expects($this->once())->method('getRecentPosts')->with(3)->willReturn($posts); + + $captured = []; + $template = $this->createMock(TemplateRendererInterface::class); + $template->expects($this->once())->method('render') + ->willReturnCallback(function (string $name, mixed $parameters = []) use (&$captured): string { + $this->assertSame('app::index', $name); + $this->assertIsArray($parameters); + $captured = $parameters; + + return ''; + }); + + $handler = new GetIndexViewHandler($template, $postRepository); + $response = $handler->handle(new ServerRequest()); + + $this->assertSame($posts, $captured['posts']); + $this->assertInstanceOf(HtmlResponse::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('', (string) $response->getBody()); + } +} diff --git a/test/Unit/App/Handler/GetMarkdownArticleHandlerTest.php b/test/Unit/App/Handler/GetMarkdownArticleHandlerTest.php new file mode 100644 index 0000000..ba87b2e --- /dev/null +++ b/test/Unit/App/Handler/GetMarkdownArticleHandlerTest.php @@ -0,0 +1,160 @@ +articlesPath = sprintf( + '%s%slight-articles-%s', + sys_get_temp_dir(), + DIRECTORY_SEPARATOR, + bin2hex(random_bytes(8)), + ); + + mkdir($this->articlesPath . DIRECTORY_SEPARATOR . 'news', 0775, true); + file_put_contents( + $this->articlesPath . DIRECTORY_SEPARATOR . 'news' . DIRECTORY_SEPARATOR . 'a-post.md', + '# A post' + ); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->articlesPath); + + parent::tearDown(); + } + + public function testHandleReturnsTheMarkdownFileContents(): void + { + $response = $this->createHandler()->handle($this->request('news', 'a-post')); + + $this->assertInstanceOf(TextResponse::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('text/markdown; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertSame('# A post', (string) $response->getBody()); + } + + /** + * @throws Exception + */ + public function testHandleReturns404WhenTheCategorySlugIsMissing(): void + { + $this->assertNotFound($this->createHandler()->handle($this->request('', 'a-post'))); + } + + /** + * @throws Exception + */ + public function testHandleReturns404WhenTheSlugIsMissing(): void + { + $this->assertNotFound($this->createHandler()->handle($this->request('news', ''))); + } + + /** + * @throws Exception + */ + public function testHandleReturns404WhenTheFileDoesNotExist(): void + { + $this->assertNotFound($this->createHandler()->handle($this->request('news', 'missing-post'))); + } + + /** + * @throws Exception + */ + public function testHandleReturns404WhenTheCategoryDoesNotExist(): void + { + $this->assertNotFound($this->createHandler()->handle($this->request('unknown-category', 'a-post'))); + } + + /** + * A slug containing ".." must never escape the configured articles directory. + * + * @throws Exception + */ + public function testHandleReturns404WhenTheSlugAttemptsToEscapeTheArticlesDirectory(): void + { + $this->assertNotFound($this->createHandler()->handle($this->request('news', '../../etc/passwd'))); + } + + /** + * @throws Exception + */ + private function createHandler(): GetMarkdownArticleHandler + { + $categoryRepository = $this->createStub(CategoryRepository::class); + $categoryRepository->method('getCategories')->willReturn([$this->createStub(Category::class)]); + + $template = $this->createStub(TemplateRendererInterface::class); + $template->method('render')->willReturn('not found'); + + return new GetMarkdownArticleHandler($template, $categoryRepository, $this->articlesPath); + } + + private function request(string $categorySlug, string $slug): ServerRequest + { + return (new ServerRequest()) + ->withAttribute('categorySlug', $categorySlug) + ->withAttribute('slug', $slug); + } + + private function assertNotFound(mixed $response): void + { + $this->assertInstanceOf(HtmlResponse::class, $response); + $this->assertSame(404, $response->getStatusCode()); + } + + private function removeDirectory(string $path): void + { + if (! is_dir($path)) { + return; + } + + $items = scandir($path); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $itemPath = $path . DIRECTORY_SEPARATOR . $item; + if (is_dir($itemPath)) { + $this->removeDirectory($itemPath); + } else { + unlink($itemPath); + } + } + + rmdir($path); + } +} diff --git a/test/Unit/App/Handler/GetSitemapViewHandlerTest.php b/test/Unit/App/Handler/GetSitemapViewHandlerTest.php new file mode 100644 index 0000000..87c9bac --- /dev/null +++ b/test/Unit/App/Handler/GetSitemapViewHandlerTest.php @@ -0,0 +1,156 @@ +sitemapFile = sprintf( + '%s%slight-sitemap-view-%s%ssitemap.xml', + sys_get_temp_dir(), + DIRECTORY_SEPARATOR, + bin2hex(random_bytes(8)), + DIRECTORY_SEPARATOR + ); + } + + protected function tearDown(): void + { + if (is_file($this->sitemapFile)) { + unlink($this->sitemapFile); + } + + $directory = dirname($this->sitemapFile); + if (is_dir($directory)) { + rmdir($directory); + } + + parent::tearDown(); + } + + /** + * @throws Exception + */ + public function testHandleServesTheExistingFileWithoutRegenerating(): void + { + $this->writeSitemapFile('cached'); + + $sitemapGenerator = $this->createMock(SitemapGenerator::class); + $sitemapGenerator->method('getSitemapFile')->willReturn($this->sitemapFile); + $sitemapGenerator->expects($this->never())->method('write'); + + $response = $this->createHandler($sitemapGenerator)->handle(new ServerRequest()); + + $this->assertInstanceOf(XmlResponse::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(SitemapGenerator::CONTENT_TYPE, $response->getHeaderLine('Content-Type')); + $this->assertSame('cached', (string) $response->getBody()); + } + + /** + * @throws Exception + */ + public function testHandleGeneratesTheSitemapWhenTheFileIsMissing(): void + { + $sitemapGenerator = $this->createMock(SitemapGenerator::class); + $sitemapGenerator->method('getSitemapFile')->willReturn($this->sitemapFile); + $sitemapGenerator->expects($this->once())->method('write') + ->willReturnCallback(function (): int { + $this->writeSitemapFile('fresh'); + return 1; + }); + + $response = $this->createHandler($sitemapGenerator)->handle(new ServerRequest()); + + $this->assertInstanceOf(XmlResponse::class, $response); + $this->assertSame('fresh', (string) $response->getBody()); + } + + /** + * @throws Exception + */ + public function testHandleReturns404WhenGenerationFailsAndNoFileExists(): void + { + $sitemapGenerator = $this->createStub(SitemapGenerator::class); + $sitemapGenerator->method('getSitemapFile')->willReturn($this->sitemapFile); + $sitemapGenerator->method('write')->willThrowException(new RuntimeException('boom')); + + $categories = [$this->createStub(Category::class)]; + $categoryRepository = $this->createStub(CategoryRepository::class); + $categoryRepository->method('getCategories')->willReturn($categories); + + $template = $this->createMock(TemplateRendererInterface::class); + $template->expects($this->once())->method('render') + ->willReturnCallback(function (string $name, mixed $parameters = []) use ($categories): string { + $this->assertSame('error::404', $name); + $this->assertIsArray($parameters); + $this->assertSame($categories, $parameters['categories']); + + return 'not found'; + }); + + $response = $this->createHandler($sitemapGenerator, $template, $categoryRepository) + ->handle(new ServerRequest()); + + $this->assertInstanceOf(HtmlResponse::class, $response); + $this->assertSame(404, $response->getStatusCode()); + } + + /** + * @throws Exception + */ + private function createHandler( + SitemapGenerator $sitemapGenerator, + ?TemplateRendererInterface $template = null, + ?CategoryRepository $categoryRepository = null, + ): GetSitemapViewHandler { + return new GetSitemapViewHandler( + $template ?? $this->createStub(TemplateRendererInterface::class), + $categoryRepository ?? $this->createStub(CategoryRepository::class), + $sitemapGenerator, + ); + } + + private function writeSitemapFile(string $contents): void + { + $directory = dirname($this->sitemapFile); + if (! is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents($this->sitemapFile, $contents); + } +} diff --git a/test/Unit/App/Helper/PaginatorTest.php b/test/Unit/App/Helper/PaginatorTest.php new file mode 100644 index 0000000..8f03147 --- /dev/null +++ b/test/Unit/App/Helper/PaginatorTest.php @@ -0,0 +1,225 @@ +assertSame([ + 'offset' => 0, + 'limit' => 10, + 'page' => 1, + 'sort' => 'title', + 'dir' => 'desc', + ], Paginator::getParams([], 'title')); + } + + public function testGetParamsUsesTheGivenDefaultDir(): void + { + $this->assertSame('asc', Paginator::getParams([], 'title', 'asc')['dir']); + } + + public function testGetParamsOverridesSortWhenProvided(): void + { + $this->assertSame('name', Paginator::getParams(['sort' => 'name'], 'title')['sort']); + } + + public function testGetParamsIgnoresAnEmptySort(): void + { + $this->assertSame('title', Paginator::getParams(['sort' => ''], 'title')['sort']); + } + + public function testGetParamsIgnoresANonStringSort(): void + { + $this->assertSame('title', Paginator::getParams(['sort' => 123], 'title')['sort']); + } + + public function testGetParamsOverridesDirWhenValid(): void + { + $this->assertSame('asc', Paginator::getParams(['dir' => 'asc'], 'title')['dir']); + } + + public function testGetParamsIgnoresAnInvalidDir(): void + { + $this->assertSame('desc', Paginator::getParams(['dir' => 'sideways'], 'title')['dir']); + } + + public function testGetParamsWithAllSetsALargeLimitAndIgnoresPagingParams(): void + { + $result = Paginator::getParams( + ['all' => '1', 'limit' => 5, 'offset' => 20, 'page' => 3], + 'title' + ); + + $this->assertSame([ + 'offset' => 0, + 'limit' => 1_000, + 'page' => 1, + 'sort' => 'title', + 'dir' => 'desc', + ], $result); + } + + public function testGetParamsUsesTheGivenLimit(): void + { + $this->assertSame(25, Paginator::getParams(['limit' => 25], 'title')['limit']); + } + + public function testGetParamsIgnoresANonPositiveLimit(): void + { + $this->assertSame(10, Paginator::getParams(['limit' => 0], 'title')['limit']); + $this->assertSame(10, Paginator::getParams(['limit' => -5], 'title')['limit']); + } + + public function testGetParamsComputesThePageFromAnEvenlyDivisibleOffset(): void + { + $result = Paginator::getParams(['limit' => 10, 'offset' => 20], 'title'); + + $this->assertSame(20, $result['offset']); + $this->assertSame(3, $result['page']); + } + + /** + * The implementation does not round the derived page, so an offset that is not a multiple + * of the limit produces a fractional page. + */ + public function testGetParamsProducesAFractionalPageForAnUnevenOffset(): void + { + $result = Paginator::getParams(['limit' => 10, 'offset' => 5], 'title'); + + $this->assertSame(1.5, $result['page']); + } + + public function testGetParamsIgnoresANonPositiveOffset(): void + { + $this->assertSame(0, Paginator::getParams(['offset' => -5], 'title')['offset']); + } + + public function testGetParamsComputesTheOffsetFromThePage(): void + { + $result = Paginator::getParams(['limit' => 10, 'page' => 3], 'title'); + + $this->assertSame(3, $result['page']); + $this->assertSame(20, $result['offset']); + } + + public function testGetParamsIgnoresANonPositivePage(): void + { + $this->assertSame(1, Paginator::getParams(['page' => 0], 'title')['page']); + } + + public function testGetParamsPageOverridesAnOffsetGivenAlongsideIt(): void + { + $result = Paginator::getParams(['limit' => 10, 'offset' => 50, 'page' => 2], 'title'); + + $this->assertSame(2, $result['page']); + $this->assertSame(10, $result['offset']); + } + + /** + * @throws Exception + */ + public function testWrapperAddsCountItemsAndFilters(): void + { + $paginator = $this->createPaginator(5, ['a', 'b']); + + $result = Paginator::wrapper( + $paginator, + ['offset' => 0, 'limit' => 10, 'page' => 1, 'sort' => 'title', 'dir' => 'desc'], + ['status' => 'active'] + ); + + $this->assertSame(5, $result['count']); + $this->assertSame(['a', 'b'], $result['items']); + $this->assertSame(['status' => 'active'], $result['filters']); + } + + /** + * @throws Exception + */ + public function testWrapperComputesPaginationMetadataForAMiddlePage(): void + { + $paginator = $this->createPaginator(25, []); + + $result = Paginator::wrapper( + $paginator, + ['offset' => 10, 'limit' => 10, 'page' => 2, 'sort' => 'title', 'dir' => 'desc'] + ); + + $this->assertSame(2, $result['currentPage']); + $this->assertSame(1, $result['firstPage']); + $this->assertSame(1, $result['previousPage']); + $this->assertSame(3, $result['lastPage']); + $this->assertFalse($result['isOutOfBounds']); + $this->assertSame(3, $result['nextPage']); + $this->assertFalse($result['isFirstPage']); + $this->assertFalse($result['isLastPage']); + $this->assertTrue($result['hasPreviousPage']); + $this->assertTrue($result['hasNextPage']); + $this->assertSame([1, 2, 3], $result['pages']); + } + + /** + * @throws Exception + */ + public function testWrapperMarksOutOfBoundsWhenCurrentPageExceedsLastPage(): void + { + $paginator = $this->createPaginator(5, []); + + $result = Paginator::wrapper( + $paginator, + ['offset' => 100, 'limit' => 10, 'page' => 11, 'sort' => 'title', 'dir' => 'desc'] + ); + + $this->assertTrue($result['isOutOfBounds']); + $this->assertTrue($result['isLastPage']); + $this->assertFalse($result['hasNextPage']); + $this->assertSame(1, $result['lastPage']); + $this->assertSame(1, $result['previousPage']); + $this->assertSame([1], $result['pages']); + $this->assertSame(0, $result['previousOffset']); + } + + /** + * @throws Exception + */ + public function testWrapperHandlesZeroResults(): void + { + $paginator = $this->createPaginator(0, []); + + $result = Paginator::wrapper( + $paginator, + ['offset' => 0, 'limit' => 10, 'page' => 1, 'sort' => 'title', 'dir' => 'desc'] + ); + + $this->assertSame(1, $result['lastPage']); + $this->assertTrue($result['isLastPage']); + $this->assertFalse($result['hasNextPage']); + } + + /** + * @param list $items + * @return DoctrinePaginator + * @throws Exception + */ + private function createPaginator(int $count, array $items): DoctrinePaginator + { + $query = $this->createStub(Query::class); + $query->method('getResult')->willReturn($items); + + $paginator = $this->createStub(DoctrinePaginator::class); + $paginator->method('count')->willReturn($count); + $paginator->method('getQuery')->willReturn($query); + + return $paginator; + } +} diff --git a/test/Unit/App/Repository/AbstractRepositoryTest.php b/test/Unit/App/Repository/AbstractRepositoryTest.php new file mode 100644 index 0000000..e3170a7 --- /dev/null +++ b/test/Unit/App/Repository/AbstractRepositoryTest.php @@ -0,0 +1,86 @@ +createStub(QueryBuilder::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects($this->once())->method('createQueryBuilder')->willReturn($queryBuilder); + + $repository = $this->createRepository($entityManager); + + $this->assertSame($queryBuilder, $repository->getQueryBuilder()); + } + + /** + * @throws Exception + */ + public function testSaveResourcePersistsThenFlushes(): void + { + $resource = $this->createStub(EntityInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + + $calls = []; + $entityManager->expects($this->once())->method('persist') + ->willReturnCallback(function (object $entity) use (&$calls, $resource): void { + $this->assertSame($resource, $entity); + $calls[] = 'persist'; + }); + $entityManager->expects($this->once())->method('flush') + ->willReturnCallback(function () use (&$calls): void { + $calls[] = 'flush'; + }); + + $this->createRepository($entityManager)->saveResource($resource); + + $this->assertSame(['persist', 'flush'], $calls); + } + + /** + * @throws Exception + */ + public function testDeleteResourceRemovesThenFlushes(): void + { + $resource = $this->createStub(EntityInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + + $calls = []; + $entityManager->expects($this->once())->method('remove') + ->willReturnCallback(function (object $entity) use (&$calls, $resource): void { + $this->assertSame($resource, $entity); + $calls[] = 'remove'; + }); + $entityManager->expects($this->once())->method('flush') + ->willReturnCallback(function () use (&$calls): void { + $calls[] = 'flush'; + }); + + $this->createRepository($entityManager)->deleteResource($resource); + + $this->assertSame(['remove', 'flush'], $calls); + } + + /** + * @throws Exception + */ + private function createRepository(EntityManagerInterface $entityManager): AbstractRepository + { + return new AbstractRepository($entityManager, new ClassMetadata(EntityInterface::class)); + } +} diff --git a/test/Unit/App/Service/FeedGeneratorTest.php b/test/Unit/App/Service/FeedGeneratorTest.php new file mode 100644 index 0000000..1e48e6d --- /dev/null +++ b/test/Unit/App/Service/FeedGeneratorTest.php @@ -0,0 +1,232 @@ +feedFile = sprintf( + '%s%slight-feed-%s%sfeed.xml', + sys_get_temp_dir(), + DIRECTORY_SEPARATOR, + bin2hex(random_bytes(8)), + DIRECTORY_SEPARATOR + ); + + mkdir(dirname($this->feedFile), 0775, true); + } + + protected function tearDown(): void + { + if (is_file($this->feedFile)) { + unlink($this->feedFile); + } + + $directory = dirname($this->feedFile); + if (is_dir($directory)) { + rmdir($directory); + } + + parent::tearDown(); + } + + public function testGetFeedFileReturnsTheConfiguredPath(): void + { + $this->assertSame($this->feedFile, $this->createGenerator([])->getFeedFile()); + } + + /** + * @throws Exception + */ + public function testWriteReturnsTheNumberOfPostsWritten(): void + { + $generator = $this->createGenerator([ + $this->createPost('First post', 'first-post', 'news'), + $this->createPost('Second post', 'second-post', 'news'), + ]); + + $this->assertSame(2, $generator->write()); + } + + /** + * @throws Exception + */ + public function testWriteProducesAChannelWithNoItemsWhenThereAreNoPosts(): void + { + $this->createGenerator([])->write(); + + $xml = $this->loadFeed(); + + $this->assertSame('Light Blog', (string) $xml->channel->title); + $this->assertSame('https://example.test', (string) $xml->channel->link); + $this->assertCount(0, $xml->channel->item); + } + + /** + * @throws Exception + */ + public function testWriteFallsBackToTheExcerptWhenTlDrIsMissing(): void + { + $post = $this->createPost('A post', 'a-post', 'news', tlDr: null, excerpt: 'The excerpt'); + $this->createGenerator([$post])->write(); + + $this->assertSame('The excerpt', (string) $this->loadFeed()->channel->item->description); + } + + /** + * @throws Exception + */ + public function testWritePrefersTlDrOverTheExcerptWhenBothArePresent(): void + { + $post = $this->createPost('A post', 'a-post', 'news', tlDr: 'The tl;dr', excerpt: 'The excerpt'); + $this->createGenerator([$post])->write(); + + $this->assertSame('The tl;dr', (string) $this->loadFeed()->channel->item->description); + } + + /** + * @throws Exception + */ + public function testWriteBuildsTheItemLinkFromCategoryAndPostSlugs(): void + { + $post = $this->createPost('A post', 'a-post', 'news'); + $this->createGenerator([$post])->write(); + + $this->assertSame('https://example.test/news/a-post/', (string) $this->loadFeed()->channel->item->link); + $this->assertSame('https://example.test/news/a-post/', (string) $this->loadFeed()->channel->item->guid); + } + + /** + * @throws Exception + */ + public function testWriteUsesTheConfiguredFallbackImageWhenThePostHasNone(): void + { + $post = $this->createPost('A post', 'a-post', 'news', openGraphImage: null); + $this->createGenerator([$post], image: 'https://example.test/default.png')->write(); + + $xml = $this->loadFeed(); + $media = $xml->channel->item->children('media', true)->content; + + $this->assertSame('https://example.test/default.png', (string) $media->attributes()['url']); + } + + /** + * @throws Exception + */ + public function testWriteQualifiesThePostsOwnOpenGraphImageWithTheBaseUrl(): void + { + $post = $this->createPost('A post', 'a-post', 'news', openGraphImage: '/uploads/a-post.png'); + $this->createGenerator([$post])->write(); + + $xml = $this->loadFeed(); + $media = $xml->channel->item->children('media', true)->content; + + $this->assertSame('https://example.test/uploads/a-post.png', (string) $media->attributes()['url']); + } + + /** + * DOMDocument::save() emits a native PHP warning on failure in addition to the return value + * this method already checks; suppress it so it doesn't surface as a spurious test warning. + * + * @throws Exception + */ + public function testWriteThrowsWhenTheFeedFileCannotBeWritten(): void + { + $generator = $this->createGenerator([], feedFile: '/nonexistent-directory/feed.xml'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to write RSS feed.'); + + @$generator->write(); + } + + /** + * @param list $posts + * @throws Exception + */ + private function createGenerator( + array $posts, + ?string $feedFile = null, + string $image = 'https://example.test/default.png', + ): FeedGenerator { + $postRepository = $this->createStub(PostRepository::class); + $postRepository->method('getPublishedPosts')->willReturn($posts); + + return new FeedGenerator( + $postRepository, + $feedFile ?? $this->feedFile, + 'https://example.test', + 'Light Blog', + 'A blog built with Dotkernel Light', + $image, + ); + } + + /** + * @throws Exception + */ + private function createPost( + string $title, + string $slug, + string $categorySlug, + ?string $tlDr = null, + string $excerpt = 'An excerpt', + ?string $openGraphImage = null, + ): Post { + $category = $this->createStub(Category::class); + $category->method('getSlug')->willReturn($categorySlug); + + $post = $this->createStub(Post::class); + $post->method('getTitle')->willReturn($title); + $post->method('getSlug')->willReturn($slug); + $post->method('getCategory')->willReturn($category); + $post->method('getTlDr')->willReturn($tlDr); + $post->method('getExcerpt')->willReturn($excerpt); + $post->method('getPostDate')->willReturn(new DateTimeImmutable('2026-08-01 10:00:00')); + $post->method('getUpdatedFormatted')->willReturn(null); + $post->method('getOpenGraphImage')->willReturn($openGraphImage); + + return $post; + } + + private function loadFeed(): SimpleXMLElement + { + $this->assertFileExists($this->feedFile); + + $xml = simplexml_load_file($this->feedFile); + $this->assertInstanceOf(SimpleXMLElement::class, $xml); + + return $xml; + } +} diff --git a/test/Unit/App/Service/SitemapGeneratorTest.php b/test/Unit/App/Service/SitemapGeneratorTest.php new file mode 100644 index 0000000..7ffc34b --- /dev/null +++ b/test/Unit/App/Service/SitemapGeneratorTest.php @@ -0,0 +1,172 @@ +sitemapFile = sprintf( + '%s%slight-sitemap-%s%ssitemap.xml', + sys_get_temp_dir(), + DIRECTORY_SEPARATOR, + bin2hex(random_bytes(8)), + DIRECTORY_SEPARATOR + ); + + mkdir(dirname($this->sitemapFile), 0775, true); + } + + protected function tearDown(): void + { + if (is_file($this->sitemapFile)) { + unlink($this->sitemapFile); + } + + $directory = dirname($this->sitemapFile); + if (is_dir($directory)) { + rmdir($directory); + } + + parent::tearDown(); + } + + public function testGetSitemapFileReturnsTheConfiguredPath(): void + { + $this->assertSame($this->sitemapFile, $this->createGenerator([])->getSitemapFile()); + } + + /** + * The count includes the homepage entry in addition to one entry per post. + * + * @throws Exception + */ + public function testWriteReturnsTheNumberOfPostsPlusTheHomepage(): void + { + $generator = $this->createGenerator([ + $this->createPost('first-post', 'news'), + $this->createPost('second-post', 'news'), + ]); + + $this->assertSame(3, $generator->write()); + } + + /** + * @throws Exception + */ + public function testWriteAlwaysIncludesTheHomepageEvenWithoutPosts(): void + { + $generator = $this->createGenerator([]); + + $this->assertSame(1, $generator->write()); + + $urls = $this->loadSitemap()->url; + $this->assertCount(1, $urls); + $this->assertSame('https://example.test', (string) $urls[0]->loc); + $this->assertCount(0, $urls[0]->lastmod); + } + + /** + * @throws Exception + */ + public function testWriteAddsOneUrlEntryPerPostWithACategoryQualifiedLink(): void + { + $post = $this->createPost('a-post', 'news', '2026-08-01 10:00:00'); + $this->createGenerator([$post])->write(); + + $urls = $this->loadSitemap()->url; + + $this->assertCount(2, $urls); + $this->assertSame('https://example.test/news/a-post/', (string) $urls[1]->loc); + $this->assertSame('2026-08-01T10:00:00+00:00', (string) $urls[1]->lastmod); + } + + /** + * DOMDocument::save() emits a native PHP warning on failure in addition to the return value + * this method already checks; suppress it so it doesn't surface as a spurious test warning. + * + * @throws Exception + */ + public function testWriteThrowsWhenTheSitemapFileCannotBeWritten(): void + { + $generator = $this->createGenerator([], sitemapFile: '/nonexistent-directory/sitemap.xml'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to write sitemap.'); + + @$generator->write(); + } + + /** + * @param list $posts + * @throws Exception + */ + private function createGenerator(array $posts, ?string $sitemapFile = null): SitemapGenerator + { + $postRepository = $this->createStub(PostRepository::class); + $postRepository->method('getPublishedPosts')->willReturn($posts); + + return new SitemapGenerator( + $postRepository, + $sitemapFile ?? $this->sitemapFile, + 'https://example.test', + ); + } + + /** + * @throws Exception + */ + private function createPost(string $slug, string $categorySlug, string $postDate = '2026-08-01 10:00:00'): Post + { + $category = $this->createStub(Category::class); + $category->method('getSlug')->willReturn($categorySlug); + + $post = $this->createStub(Post::class); + $post->method('getSlug')->willReturn($slug); + $post->method('getCategory')->willReturn($category); + $post->method('getPostDate')->willReturn(new DateTimeImmutable($postDate, new DateTimeZone('UTC'))); + + return $post; + } + + private function loadSitemap(): SimpleXMLElement + { + $this->assertFileExists($this->sitemapFile); + + $xml = simplexml_load_file($this->sitemapFile); + $this->assertInstanceOf(SimpleXMLElement::class, $xml); + + return $xml; + } +}