diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab37e216..f629db424 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - name: 🏁 Static analysis run: make static-analysis + - name: 🏗️ Architecture + run: make test-architecture + - name: 🦭 Wait for the database to get up run: | while ! make ping-mysql &>/dev/null; do diff --git a/Makefile b/Makefile index aa5c306bb..c47502189 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ static-analysis: lint: docker exec codely-php_ddd_skeleton-mooc_backend-php ./vendor/bin/ecs check +test-architecture: + docker exec codely-php_ddd_skeleton-mooc_backend-php php -d memory_limit=4G ./vendor/bin/phpstan analyse + start: @if [ ! -f .env.local ]; then echo '' > .env.local; fi UID=${shell id -u} GID=${shell id -g} docker compose up --build -d diff --git a/composer.json b/composer.json index 16cbae255..0028fa52f 100644 --- a/composer.json +++ b/composer.json @@ -57,7 +57,9 @@ "rector/rector": "^0.18.4", "psalm/plugin-mockery": "^1.1", "psalm/plugin-symfony": "^5.0", - "psalm/plugin-phpunit": "^0.18.4" + "psalm/plugin-phpunit": "^0.18.4", + "phpstan/phpstan": "^1.10", + "phpat/phpat": "dev-add-has_one_public_method" }, "autoload": { "psr-4": { @@ -80,5 +82,11 @@ "allow-plugins": { "ocramius/package-versions": true } - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/CodelyTV/phpat" + } + ] } diff --git a/composer.lock b/composer.lock index 4c584ce32..3369971a0 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": "aee8db87d99805f8edc889d34f98ff95", + "content-hash": "cc21804655685b24332ea14a43ca7875", "packages": [ { "name": "brick/math", @@ -7234,6 +7234,66 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpat/phpat", + "version": "dev-add-has_one_public_method", + "source": { + "type": "git", + "url": "https://github.com/CodelyTV/phpat.git", + "reference": "5d530db9735aa52ca702db9e6d91493b1e06c990" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CodelyTV/phpat/zipball/5d530db9735aa52ca702db9e6d91493b1e06c990", + "reference": "5d530db9735aa52ca702db9e6d91493b1e06c990", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^1.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "kubawerlos/php-cs-fixer-custom-fixers": "^3.16", + "phpunit/phpunit": "^9.0", + "vimeo/psalm": "^4.0 || ^5.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPat\\": "src/" + }, + "files": [ + "helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\PHPat\\": "tests/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Carlos Alandete Sastre", + "email": "carlos.alandete@gmail.com" + } + ], + "description": "PHP Architecture Tester", + "support": { + "source": "https://github.com/CodelyTV/phpat/tree/add-has_one_public_method" + }, + "time": "2023-10-03T14:53:59+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -7451,16 +7511,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.35", + "version": "1.10.37", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "e730e5facb75ffe09dfb229795e8c01a459f26c3" + "reference": "058ba07e92f744d4dcf6061ae75283d0c6456f2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e730e5facb75ffe09dfb229795e8c01a459f26c3", - "reference": "e730e5facb75ffe09dfb229795e8c01a459f26c3", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/058ba07e92f744d4dcf6061ae75283d0c6456f2e", + "reference": "058ba07e92f744d4dcf6061ae75283d0c6456f2e", "shasum": "" }, "require": { @@ -7509,7 +7569,7 @@ "type": "tidelift" } ], - "time": "2023-09-19T15:27:56+00:00" + "time": "2023-10-02T16:18:37+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10516,7 +10576,8 @@ "aliases": [], "minimum-stability": "RC", "stability-flags": { - "roave/security-advisories": 20 + "roave/security-advisories": 20, + "phpat/phpat": 20 }, "prefer-stable": false, "prefer-lowest": false, diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..4404d69b2 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,25 @@ +includes: + - vendor/phpat/phpat/extension.neon + +parameters: + level: 0 + paths: + - ./apps + - ./src + - ./tests + excludePaths: + - ./apps/backoffice/backend/var + - ./apps/backoffice/frontend/var + - ./apps/mooc/backend/var + - ./apps/mooc/frontend/var + +services: + - + class: CodelyTv\Tests\Shared\SharedArchitectureTest + tags: + - phpat.test + + - + class: CodelyTv\Tests\Mooc\MoocArchitectureTest + tags: + - phpat.test diff --git a/src/Shared/Domain/Utils.php b/src/Shared/Domain/Utils.php index c7825a25b..df64bfb87 100644 --- a/src/Shared/Domain/Utils.php +++ b/src/Shared/Domain/Utils.php @@ -6,9 +6,7 @@ use DateTimeImmutable; use DateTimeInterface; -use ReflectionClass; use RuntimeException; - use function Lambdish\Phunctional\filter; final class Utils @@ -81,13 +79,6 @@ public static function filesIn(string $path, string $fileType): array ); } - public static function extractClassName(object $object): string - { - $reflect = new ReflectionClass($object); - - return $reflect->getShortName(); - } - public static function iterableToArray(iterable $iterable): array { if (is_array($iterable)) { diff --git a/src/Shared/Infrastructure/Symfony/ApiExceptionListener.php b/src/Shared/Infrastructure/Symfony/ApiExceptionListener.php index a18ea1e20..80ef312df 100644 --- a/src/Shared/Infrastructure/Symfony/ApiExceptionListener.php +++ b/src/Shared/Infrastructure/Symfony/ApiExceptionListener.php @@ -6,6 +6,7 @@ use CodelyTv\Shared\Domain\DomainError; use CodelyTv\Shared\Domain\Utils; +use ReflectionClass; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Throwable; @@ -35,6 +36,13 @@ private function exceptionCodeFor(Throwable $error): string return $error instanceof $domainErrorClass ? $error->errorCode() - : Utils::toSnakeCase(Utils::extractClassName($error)); + : Utils::toSnakeCase($this->extractClassName($error)); + } + + private function extractClassName(object $object): string + { + $reflect = new ReflectionClass($object); + + return $reflect->getShortName(); } } diff --git a/tests/Mooc/MoocArchitectureTest.php b/tests/Mooc/MoocArchitectureTest.php new file mode 100644 index 000000000..a1cf75f57 --- /dev/null +++ b/tests/Mooc/MoocArchitectureTest.php @@ -0,0 +1,56 @@ +classes(Selector::inNamespace('/^CodelyTv\\\\Mooc\\\\.+\\\\Domain/', true)) + ->canOnlyDependOn() + ->classes(...array_merge(ArchitectureTest::languageClasses(), [ + // Itself + Selector::inNamespace('/^CodelyTv\\\\Mooc\\\\.+\\\\Domain/', true), + // Shared + Selector::inNamespace('CodelyTv\Shared\Domain'), + ])) + ->because('mooc domain can only import itself and shared domain'); + } + + public function test_mooc_application_should_only_import_itself_and_domain(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('/^CodelyTv\\\\Mooc\\\\.+\\\\Application/', true)) + ->canOnlyDependOn() + ->classes(...array_merge(ArchitectureTest::languageClasses(), [ + // Itself + Selector::inNamespace('/^CodelyTv\\\\Mooc\\\\.+\\\\Application/', true), + Selector::inNamespace('/^CodelyTv\\\\Mooc\\\\.+\\\\Domain/', true), + // Shared + Selector::inNamespace('CodelyTv\Shared'), + ])) + ->because('mooc application can only import itself and shared'); + } + + public function test_mooc_infrastructure_should_not_import_other_contexts_beside_shared(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('CodelyTv\Mooc')) + ->shouldNotDependOn() + ->classes(Selector::inNamespace('CodelyTv')) + ->excluding( + // Itself + Selector::inNamespace('CodelyTv\Mooc'), + // Shared + Selector::inNamespace('CodelyTv\Shared'), + ); + } +} diff --git a/tests/Shared/Infrastructure/ArchitectureTest.php b/tests/Shared/Infrastructure/ArchitectureTest.php new file mode 100644 index 000000000..17d97198b --- /dev/null +++ b/tests/Shared/Infrastructure/ArchitectureTest.php @@ -0,0 +1,40 @@ +classes(Selector::inNamespace('CodelyTv\Shared\Domain')) + ->canOnlyDependOn() + ->classes(...array_merge(ArchitectureTest::languageClasses(), [ + // Itself + Selector::inNamespace('CodelyTv\Shared\Domain'), + // Dependencies treated as domain + Selector::classname(Uuid::class), + ])) + ->because('shared domain cannot import from outside'); + } + + public function test_shared_infrastructure_should_not_import_from_other_contexts(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('CodelyTv\Shared\Infrastructure')) + ->shouldNotDependOn() + ->classes(Selector::inNamespace('CodelyTv')) + ->excluding( + // Itself + Selector::inNamespace('CodelyTv\Shared'), + // This need to be refactored + Selector::classname(MySqlDatabaseCleaner::class), + Selector::classname(AuthenticateUserCommand::class), + ); + } + + public function test_all_use_cases_can_only_have_one_public_method(): Rule + { + return PHPat::rule() + ->classes( + Selector::classname('/^CodelyTv\\\\.+\\\\.+\\\\Application\\\\.+\\\\(?!.*(?:Command|Query)$).*$/', true) + ) + ->excluding( + Selector::implements(Response::class), + Selector::implements(DomainEventSubscriber::class), + Selector::inNamespace('/.*\\\\Tests\\\\.*/', true) + ) + ->shouldHaveOnlyOnePublicMethod(); + } +}