From fe322ceba85eaae3a7833fae4677ce3fbbe43989 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Mon, 16 Mar 2026 18:40:57 +0100 Subject: [PATCH] Add layer tests based on deptrac layers. This is the next step into removing deptrac in favor of phpat --- phpstan-baseline.neon | 84 ++++-- phpstan.neon.dist | 5 + tests/Architecture/LayerDependenciesTest.php | 273 +++++++++++++++++++ 3 files changed, 338 insertions(+), 24 deletions(-) create mode 100644 tests/Architecture/LayerDependenciesTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f7e40e355..0666b510c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,35 @@ parameters: ignoreErrors: + - + message: '#^Patchlevel\\EventSourcing\\Aggregate\\AggregateRootId should not depend on Patchlevel\\EventSourcing\\Serializer\\Normalizer\\IdNormalizer$#' + identifier: phpat.testAggregateCanOnlyDependOnAllowedLayers + count: 1 + path: src/Aggregate/AggregateRootId.php + + - + message: '#^Patchlevel\\EventSourcing\\Attribute\\Processor should not depend on Patchlevel\\EventSourcing\\Subscription\\RunMode$#' + identifier: phpat.testAttributeCanOnlyDependOnAllowedLayers + count: 2 + path: src/Attribute/Processor.php + + - + message: '#^Patchlevel\\EventSourcing\\Attribute\\Projector should not depend on Patchlevel\\EventSourcing\\Subscription\\RunMode$#' + identifier: phpat.testAttributeCanOnlyDependOnAllowedLayers + count: 2 + path: src/Attribute/Projector.php + + - + message: '#^Patchlevel\\EventSourcing\\Attribute\\SharedApplyContext should not depend on Patchlevel\\EventSourcing\\Aggregate\\AggregateRoot$#' + identifier: phpat.testAttributeCanOnlyDependOnAllowedLayers + count: 1 + path: src/Attribute/SharedApplyContext.php + + - + message: '#^Patchlevel\\EventSourcing\\Attribute\\Subscriber should not depend on Patchlevel\\EventSourcing\\Subscription\\RunMode$#' + identifier: phpat.testAttributeCanOnlyDependOnAllowedLayers + count: 1 + path: src/Attribute/Subscriber.php + - message: '#^Cannot unset offset ''url'' on array\{application_name\?\: string, charset\?\: string, defaultTableOptions\?\: array\, driver\?\: ''ibm_db2''\|''mysqli''\|''oci8''\|''pdo_mysql''\|''pdo_oci''\|''pdo_pgsql''\|''pdo_sqlite''\|''pdo_sqlsrv''\|''pgsql''\|''sqlite3''\|''sqlsrv'', driverClass\?\: class\-string\, driverOptions\?\: array\, host\?\: string, keepReplica\?\: bool, \.\.\.\}\.$#' identifier: unset.offset @@ -84,6 +114,12 @@ parameters: count: 1 path: src/Store/Criteria/StreamCriterion.php + - + message: '#^Patchlevel\\EventSourcing\\Store\\DoctrineDbalStore should not depend on Patchlevel\\EventSourcing\\Aggregate\\AggregateHeader$#' + identifier: phpat.testStoreCanOnlyDependOnAllowedLayers + count: 2 + path: src/Store/DoctrineDbalStore.php + - message: '#^Method Patchlevel\\EventSourcing\\Store\\DoctrineDbalStoreStream\:\:current\(\) never returns null so it can be removed from the return type\.$#' identifier: return.unusedType @@ -102,12 +138,36 @@ parameters: count: 1 path: src/Store/DoctrineDbalStoreStream.php + - + message: '#^Patchlevel\\EventSourcing\\Store\\DoctrineDbalStoreStream should not depend on Patchlevel\\EventSourcing\\Aggregate\\AggregateHeader$#' + identifier: phpat.testStoreCanOnlyDependOnAllowedLayers + count: 1 + path: src/Store/DoctrineDbalStoreStream.php + - message: '#^Ternary operator condition is always true\.$#' identifier: ternary.alwaysTrue count: 1 path: src/Store/DoctrineDbalStoreStream.php + - + message: '#^Patchlevel\\EventSourcing\\Store\\InMemoryStore should not depend on Patchlevel\\EventSourcing\\Aggregate\\AggregateHeader$#' + identifier: phpat.testStoreCanOnlyDependOnAllowedLayers + count: 5 + path: src/Store/InMemoryStore.php + + - + message: '#^Patchlevel\\EventSourcing\\Store\\InMemoryStore should not depend on Patchlevel\\EventSourcing\\Metadata\\Event\\EventRegistry$#' + identifier: phpat.testStoreCanOnlyDependOnAllowedLayers + count: 1 + path: src/Store/InMemoryStore.php + + - + message: '#^Patchlevel\\EventSourcing\\Store\\MissingEventRegistry should not depend on Patchlevel\\EventSourcing\\Metadata\\Event\\EventRegistry$#' + identifier: phpat.testStoreCanOnlyDependOnAllowedLayers + count: 1 + path: src/Store/MissingEventRegistry.php + - message: '#^Method Patchlevel\\EventSourcing\\Store\\StreamDoctrineDbalStoreStream\:\:current\(\) never returns null so it can be removed from the return type\.$#' identifier: return.unusedType @@ -336,18 +396,6 @@ parameters: count: 1 path: tests/Unit/Aggregate/AggregateRootTest.php - - - message: '#^Cannot access offset 0 on iterable\\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Unit/CommandBus/AggregateHandlerProviderTest.php - - - - message: '#^Cannot call method callable\(\) on mixed\.$#' - identifier: method.nonObject - count: 2 - path: tests/Unit/CommandBus/AggregateHandlerProviderTest.php - - message: '#^Parameter \#2 \$aggregateClass of class Patchlevel\\EventSourcing\\CommandBus\\Handler\\CreateAggregateHandler constructor expects class\-string\, string given\.$#' identifier: argument.type @@ -366,18 +414,6 @@ parameters: count: 5 path: tests/Unit/CommandBus/InstantRetryCommandBusTest.php - - - message: '#^Cannot access offset 0 on iterable\\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Unit/CommandBus/ServiceHandlerProviderTest.php - - - - message: '#^Cannot call method callable\(\) on mixed\.$#' - identifier: method.nonObject - count: 2 - path: tests/Unit/CommandBus/ServiceHandlerProviderTest.php - - message: '#^Parameter \#1 \$data of static method Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\Message\:\:fromArray\(\) expects array\{id\: string, text\: string, createdAt\: string\}, array\ given\.$#' identifier: argument.type diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 689e71a10..5f54b81e9 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -18,3 +18,8 @@ services: class: Patchlevel\EventSourcing\Tests\Architecture\FinalClassesTest tags: - phpat.test + + - + class: Patchlevel\EventSourcing\Tests\Architecture\LayerDependenciesTest + tags: + - phpat.test diff --git a/tests/Architecture/LayerDependenciesTest.php b/tests/Architecture/LayerDependenciesTest.php new file mode 100644 index 000000000..6e22895cd --- /dev/null +++ b/tests/Architecture/LayerDependenciesTest.php @@ -0,0 +1,273 @@ +layerCanOnlyDependOnAllowedLayers( + $this->layer('Aggregate'), + [ + $this->layer('Attribute'), + $this->layer('Metadata\AggregateRoot'), + ], + ); + } + + public function testAttributeCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers($this->layer('Attribute')); + } + + public function testClockCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers($this->layer('Clock')); + } + + public function testCommandBusCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('CommandBus'), + [ + $this->layer('Aggregate'), + $this->layer('Attribute'), + $this->layer('Metadata\AggregateRoot'), + $this->layer('Repository'), + ], + ); + } + + public function testConsoleCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Console'), + [ + $this->layer('Aggregate'), + $this->layer('Message'), + $this->layer('Metadata\AggregateRoot'), + $this->layer('Metadata\Event'), + $this->layer('Schema'), + $this->layer('Serializer'), + $this->layer('Store'), + $this->layer('Subscription'), + ], + ); + } + + public function testCryptographyCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Cryptography'), + [$this->layer('Schema')], + ); + } + + public function testEventBusCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('EventBus'), + [ + $this->layer('Attribute'), + $this->layer('Message'), + ], + ); + } + + public function testMessageCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Message'), + [ + $this->layer('Aggregate'), + $this->layer('Metadata\Message'), + $this->layer('Serializer'), + $this->layer('Store'), + ], + ); + } + + public function testMetadataCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers($this->metadataLayer()); + } + + public function testMetadataAggregateCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Metadata\AggregateRoot'), + [ + $this->layer('Aggregate'), + $this->layer('Attribute'), + $this->metadataLayer(), + ], + ); + } + + public function testMetadataEventCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Metadata\Event'), + [ + $this->layer('Attribute'), + $this->metadataLayer(), + ], + ); + } + + public function testMetadataMessageCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Metadata\Message'), + [ + $this->layer('Aggregate'), + $this->layer('Attribute'), + $this->metadataLayer(), + $this->layer('Store'), + ], + ); + } + + public function testMetadataSubscriberCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Metadata\Subscriber'), + [ + $this->layer('Attribute'), + $this->metadataLayer(), + $this->layer('Subscription'), + ], + ); + } + + public function testQueryBusCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('QueryBus'), + [$this->layer('Attribute')], + ); + } + + public function testRepositoryCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Repository'), + [ + $this->layer('Aggregate'), + $this->layer('Clock'), + $this->layer('Message'), + $this->layer('Metadata\AggregateRoot'), + $this->layer('Metadata\Event'), + $this->layer('EventBus'), + $this->layer('Snapshot'), + $this->layer('Store'), + ], + ); + } + + public function testSchemaCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers($this->layer('Schema')); + } + + public function testSerializerCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Serializer'), + [ + $this->layer('Aggregate'), + $this->layer('Cryptography'), + $this->layer('Metadata\Event'), + ], + ); + } + + public function testSnapshotCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Snapshot'), + [ + $this->layer('Aggregate'), + $this->layer('Cryptography'), + $this->layer('Metadata\AggregateRoot'), + ], + ); + } + + public function testStoreCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Store'), + [ + $this->layer('Clock'), + $this->layer('Message'), + $this->metadataLayer(), + $this->layer('Schema'), + $this->layer('Serializer'), + ], + ); + } + + public function testSubscriptionCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Subscription'), + [ + $this->layer('Aggregate'), + $this->layer('Attribute'), + $this->layer('Clock'), + $this->layer('Message'), + $this->layer('Metadata\Event'), + $this->layer('Metadata\Subscriber'), + $this->layer('Repository'), + $this->layer('Schema'), + $this->layer('Store'), + ], + ); + } + + public function testTestCanOnlyDependOnAllowedLayers(): Rule + { + return $this->layerCanOnlyDependOnAllowedLayers($this->layer('Test')); + } + + /** @param array $allowedInternalLayers */ + private function layerCanOnlyDependOnAllowedLayers( + SelectorInterface $layer, + array $allowedInternalLayers = [], + ): Rule { + return PHPat::rule() + ->classes($layer) + ->canOnlyDependOn() + ->classes( + Selector::NOT(Selector::inNamespace('Patchlevel\EventSourcing')), // only internal deps + $layer, // allow itself + ...$allowedInternalLayers, + ); + } + + private function layer(string $layer): SelectorInterface + { + return Selector::inNamespace(sprintf('Patchlevel\\EventSourcing\\%s', $layer)); + } + + private function metadataLayer(): SelectorInterface + { + return Selector::AllOf( + $this->layer('Metadata'), + Selector::NOT($this->layer('Metadata\AggregateRoot')), + Selector::NOT($this->layer('Metadata\Event')), + Selector::NOT($this->layer('Metadata\Message')), + Selector::NOT($this->layer('Metadata\Subscriber')), + ); + } +}