diff --git a/composer.json b/composer.json index 3e854fd..aba8b2e 100644 --- a/composer.json +++ b/composer.json @@ -109,6 +109,7 @@ "require-dev": { "drupal/core-dev": "^10", "fenetikm/autoload-drupal": "dev-autoload-tests", - "phpspec/prophecy-phpunit": "^2" + "phpspec/prophecy-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^1.5" } } diff --git a/composer.lock b/composer.lock index 66ff841..6abbdd8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b7efed8e16e9b99d9aba5afbdb499669", + "content-hash": "2d3f22a75cc98e2996a5d6b9f3934faf", "packages": [ { "name": "asm89/stack-cors", @@ -8218,6 +8218,55 @@ }, "time": "2023-05-26T11:05:59+00:00" }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "b21c03d4f6f3a446e4311155f4be9d65048218e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/b21c03d4f6f3a446e4311155f4be9d65048218e6", + "reference": "b21c03d4f6f3a446e4311155f4be9d65048218e6", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.10" + }, + "require-dev": { + "nikic/php-parser": "^4.13.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.5.1" + }, + "time": "2023-03-29T14:47:40+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "9.2.27", diff --git a/web/modules/custom/example/tests/src/Functional/AdminPageTest.php b/web/modules/custom/example/tests/src/Functional/AdminPageTest.php new file mode 100644 index 0000000..e5253b6 --- /dev/null +++ b/web/modules/custom/example/tests/src/Functional/AdminPageTest.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\example\Functional; + +use Drupal\Tests\BrowserTestBase; +use Symfony\Component\HttpFoundation\Response; + +final class AdminPageTest extends BrowserTestBase { + + public $defaultTheme = 'stark'; + + /** @test */ + public function the_admin_page_is_not_accessible_to_anonymous_users(): void { + $this->drupalGet(path: '/admin'); + + $assert = $this->assertSession(); + + $assert->statusCodeEquals(Response::HTTP_FORBIDDEN); + } + + /** @test */ + public function the_admin_page_is_accessible_by_admin_users(): void { + $adminUser = $this->createUser( + permissions: [ + 'access administration pages', + ], + ); + + $this->drupalLogin(account: $adminUser); + + $this->drupalGet(path: '/admin'); + + $assert = $this->assertSession(); + + $assert->statusCodeEquals(Response::HTTP_OK); + } + +} diff --git a/web/modules/custom/example/tests/src/Functional/ExamplePageTest.php b/web/modules/custom/example/tests/src/Functional/ExamplePageTest.php deleted file mode 100644 index 5ecb1b8..0000000 --- a/web/modules/custom/example/tests/src/Functional/ExamplePageTest.php +++ /dev/null @@ -1,31 +0,0 @@ -<?php - -namespace Drupal\Tests\example\Functional; - -use Drupal\Tests\BrowserTestBase; -use Symfony\Component\HttpFoundation\Response; - -final class ExamplePageTest extends BrowserTestBase { - - public $defaultTheme = 'stark'; - - protected static $modules = [ - // Core. - 'node', - - // Custom. - "example" - ]; - - /** @test */ - public function should_load_the_example_page_for_anonymous_users(): void { - // Arrange. - - // Act. - $this->drupalGet('/@opdavies/drupal-module-template'); - - // Assert. - $this->assertSession()->statusCodeEquals(Response::HTTP_OK); - } - -} diff --git a/web/modules/custom/example/tests/src/Functional/FrontPageTest.php b/web/modules/custom/example/tests/src/Functional/FrontPageTest.php new file mode 100644 index 0000000..24ae108 --- /dev/null +++ b/web/modules/custom/example/tests/src/Functional/FrontPageTest.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\example\Functional; + +use Drupal\Tests\BrowserTestBase; +use Symfony\Component\HttpFoundation\Response; + +final class FrontPageTest extends BrowserTestBase { + + public $defaultTheme = 'stark'; + + protected static $modules = ['node', 'views']; + + /** @test */ + public function the_front_page_loads_for_anonymous_users(): void { + $this->config('system.site') + ->set('page.front', '/node') + ->save(TRUE); + + $this->drupalGet('<front>'); + + $assert = $this->assertSession(); + + $assert->statusCodeEquals(Response::HTTP_OK); + $assert->pageTextContains('No front page content has been created yet.'); + } + +} diff --git a/web/modules/custom/my_module/my_module.info.yml b/web/modules/custom/my_module/my_module.info.yml new file mode 100644 index 0000000..60e27aa --- /dev/null +++ b/web/modules/custom/my_module/my_module.info.yml @@ -0,0 +1,3 @@ +name: My Module +type: module +core_version_requirement: ^10 diff --git a/web/modules/custom/my_module/my_module.routing.yml b/web/modules/custom/my_module/my_module.routing.yml new file mode 100644 index 0000000..9683277 --- /dev/null +++ b/web/modules/custom/my_module/my_module.routing.yml @@ -0,0 +1,7 @@ +blog.page: + path: /blog + defaults: + _controller: Drupal\my_module\Controller\BlogPageController + _title: Blog + requirements: + _permission: access content diff --git a/web/modules/custom/my_module/my_module.services.yml b/web/modules/custom/my_module/my_module.services.yml new file mode 100644 index 0000000..a97d210 --- /dev/null +++ b/web/modules/custom/my_module/my_module.services.yml @@ -0,0 +1,6 @@ +services: + Drupal\my_module\Controller\BlogPageController: + autowire: true + + Drupal\my_module\Repository\ArticleRepository: + autowire: true diff --git a/web/modules/custom/my_module/src/Controller/BlogPageController.php b/web/modules/custom/my_module/src/Controller/BlogPageController.php new file mode 100644 index 0000000..59b6599 --- /dev/null +++ b/web/modules/custom/my_module/src/Controller/BlogPageController.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\my_module\Controller; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\EntityViewBuilderInterface; +use Drupal\Core\Render\RendererInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\my_module\Repository\ArticleRepository; + +final class BlogPageController { + + use StringTranslationTrait; + + private EntityViewBuilderInterface $nodeViewBuilder; + + public function __construct( + private RendererInterface $renderer, + EntityTypeManagerInterface $entityTypeManager, + private ArticleRepository $articleRepository, + ) { + $this->nodeViewBuilder = $entityTypeManager->getViewBuilder(entity_type_id: 'node'); + } + + /** + * @return array<string, mixed> + */ + public function __invoke(): array { + $articles = $this->articleRepository->getAll(); + + if ($articles === []) { + return ['#markup' => $this->t('Welcome to my blog!')]; + } + + $build = []; + + foreach ($articles as $article) { + $build[] = $this->nodeViewBuilder->view( + entity: $article, + view_mode: 'teaser', + ); + } + + return [ + '#markup' => $this->renderer->render($build), + ]; + } + +} diff --git a/web/modules/custom/my_module/src/Repository/ArticleRepository.php b/web/modules/custom/my_module/src/Repository/ArticleRepository.php new file mode 100644 index 0000000..4cacbb0 --- /dev/null +++ b/web/modules/custom/my_module/src/Repository/ArticleRepository.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\my_module\Repository; + +use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\node\NodeInterface; + +final class ArticleRepository { + + private EntityStorageInterface $nodeStorage; + + public function __construct(EntityTypeManagerInterface $entityTypeManager) { + $this->nodeStorage = $entityTypeManager->getStorage(entity_type_id: 'node'); + } + + /** + * @return array<int, NodeInterface> + */ + public function getAll(): array { + /** @var array<int, NodeInterface> */ + $articles = $this->nodeStorage->loadByProperties([ + 'status' => NodeInterface::PUBLISHED, + 'type' => 'article', + ]); + + // Sort the articles by their created time. + uasort($articles, function (NodeInterface $a, NodeInterface $b): int { + return $a->getCreatedTime() < $b->getCreatedTime() ? 1 : -1; + }); + + return $articles; + } + +} diff --git a/web/modules/custom/my_module/tests/src/Functional/BlogPageTest.php b/web/modules/custom/my_module/tests/src/Functional/BlogPageTest.php new file mode 100644 index 0000000..f1c9764 --- /dev/null +++ b/web/modules/custom/my_module/tests/src/Functional/BlogPageTest.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\my_module\Functional; + +use Drupal\Tests\BrowserTestBase; +use Symfony\Component\HttpFoundation\Response; + +final class BlogPageTest extends BrowserTestBase { + + public $defaultTheme = 'stark'; + + protected static $modules = [ + // Core. + 'node', + + // Custom. + 'my_module', + ]; + + /** @test */ + public function the_blog_page_loads_for_anonymous_users_and_contains_the_right_text(): void { + $this->drupalGet('/blog'); + + $assert = $this->assertSession(); + + $assert->statusCodeEquals(Response::HTTP_OK); + $assert->responseContains('<h1>Blog</h1>'); + $assert->pageTextContains('Welcome to my blog!'); + } + + /** @test */ + public function it_shows_articles(): void { + $this->createContentType(['type' => 'article']); + + $this->createNode([ + 'title' => 'This is a test article', + 'type' => 'article', + ]); + + $this->drupalGet('/blog'); + + $assert = $this->assertSession(); + + $assert->statusCodeEquals(Response::HTTP_OK); + $assert->pageTextContains('This is a test article'); + } + +} diff --git a/web/modules/custom/my_module/tests/src/Kernel/Repository/ArticleRepositoryTest.php b/web/modules/custom/my_module/tests/src/Kernel/Repository/ArticleRepositoryTest.php new file mode 100644 index 0000000..91799e3 --- /dev/null +++ b/web/modules/custom/my_module/tests/src/Kernel/Repository/ArticleRepositoryTest.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\my_module\Kernel\Repository; + +use Drupal\Core\Datetime\DrupalDateTime; +use Drupal\KernelTests\Core\Entity\EntityKernelTestBase; +use Drupal\my_module\Repository\ArticleRepository; +use Drupal\node\Entity\Node; +use Drupal\Tests\node\Traits\NodeCreationTrait; + +final class ArticleRepositoryTest extends EntityKernelTestBase { + + use NodeCreationTrait; + + protected $strictConfigSchema = FALSE; + + public static $modules = [ + 'node', + 'my_module', + ]; + + protected function setUp(): void { + parent::setUp(); + + $this->installSchema(module: 'node', tables: ['node_access']); + + $this->installConfig([ + 'filter', + ]); + } + + /** @test */ + public function nodes_that_are_not_articles_are_not_returned() { + $this->createNode(['type' => 'article'])->save(); + $this->createNode(['type' => 'page'])->save(); + $this->createNode(['type' => 'article'])->save(); + $this->createNode(['type' => 'page'])->save(); + $this->createNode(['type' => 'article'])->save(); + + $this->assertCount(5, Node::loadMultiple()); + + $repository = $this->container->get(ArticleRepository::class); + $articles = $repository->getAll(); + + $this->assertCount(3, $articles); + } + + /** @test */ + public function only_published_articles_are_returned() { + $this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save(); + $this->createNode(['type' => 'article', 'status' => Node::NOT_PUBLISHED])->save(); + $this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save(); + $this->createNode(['type' => 'article', 'status' => Node::NOT_PUBLISHED])->save(); + $this->createNode(['type' => 'article', 'status' => Node::PUBLISHED])->save(); + + $repository = $this->container->get(ArticleRepository::class); + $articles = $repository->getAll(); + + $this->assertCount(3, $articles); + } + + /** @test */ + public function nodes_are_ordered_by_date_and_returned_newest_first() { + $this->createNode([ + 'type' => 'article', + 'created' => (new DrupalDateTime('-2 days'))->getTimestamp(), + ]); + + $this->createNode([ + 'type' => 'article', + 'created' => (new DrupalDateTime('-1 week'))->getTimestamp(), + ]); + + $this->createNode([ + 'type' => 'article', + 'created' => (new DrupalDateTime('-1 hour'))->getTimestamp(), + ]); + + $this->createNode([ + 'type' => 'article', + 'created' => (new DrupalDateTime('-1 year'))->getTimestamp(), + ]); + + $this->createNode([ + 'type' => 'article', + 'created' => (new DrupalDateTime('-1 month'))->getTimestamp(), + ]); + + $repository = $this->container->get(ArticleRepository::class); + $nodes = $repository->getAll(); + $nodeIds = array_keys($nodes); + + $this->assertSame([3, 1, 2, 5, 4], $nodeIds); + } + +}