first commit
This commit is contained in:
45
tests/Architecture/ApplicationServiceDoctrineTest.php
Normal file
45
tests/Architecture/ApplicationServiceDoctrineTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ApplicationServiceDoctrineTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testAuthApplicationServiceKeepsSensitiveAuthenticationLogicInUseCases(): void
|
||||
{
|
||||
$file = $this->projectPath('src/Identity/Application/AuthApplicationService.php');
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\AuthenticateUser', $code);
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ChangePassword', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Repository\\UserRepositoryInterface', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Policy\\PasswordPolicy', $code);
|
||||
}
|
||||
|
||||
public function testPasswordResetApplicationServiceDelegatesWorkflowToUseCases(): void
|
||||
{
|
||||
$file = $this->projectPath('src/Identity/Application/PasswordResetApplicationService.php');
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\RequestPasswordReset', $code);
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ValidatePasswordResetToken', $code);
|
||||
self::assertStringContainsString('Netig\Netslim\\Identity\\Application\\UseCase\\ResetPassword', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Identity\\Domain\\Repository\\PasswordResetRepositoryInterface', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Kernel\\Mail\\Application\\MailServiceInterface', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Kernel\\Persistence\\Application\\TransactionManagerInterface', $code);
|
||||
}
|
||||
|
||||
public function testMediaApplicationServiceDelegatesMutationsToUseCases(): void
|
||||
{
|
||||
$file = $this->projectPath('src/Media/Application/MediaApplicationService.php');
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertStringContainsString('Netig\Netslim\\Media\\Application\\UseCase\\StoreMedia', $code);
|
||||
self::assertStringContainsString('Netig\Netslim\\Media\\Application\\UseCase\\DeleteMedia', $code);
|
||||
self::assertStringNotContainsString('Netig\Netslim\\Media\\Domain\\Service\\MediaStorageInterface', $code);
|
||||
self::assertStringNotContainsString('PDOException', $code);
|
||||
}
|
||||
}
|
||||
91
tests/Architecture/ApplicationServiceVocabularyTest.php
Normal file
91
tests/Architecture/ApplicationServiceVocabularyTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Netig\Netslim\AuditLog\Application\AuditLogServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\AuthServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\PasswordResetServiceInterface;
|
||||
use Netig\Netslim\Identity\Application\UserServiceInterface;
|
||||
use Netig\Netslim\Media\Application\MediaServiceInterface;
|
||||
use Netig\Netslim\Notifications\Application\NotificationServiceInterface;
|
||||
use Netig\Netslim\Settings\Application\SettingsServiceInterface;
|
||||
use Netig\Netslim\Taxonomy\Application\TaxonomyServiceInterface;
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ApplicationServiceVocabularyTest extends ArchitectureTestCase
|
||||
{
|
||||
/**
|
||||
* @return iterable<string, array{0: class-string, 1: list<string>}>
|
||||
*/
|
||||
public static function serviceInterfaces(): iterable
|
||||
{
|
||||
yield 'auth service vocabulary' => [
|
||||
AuthServiceInterface::class,
|
||||
['checkRateLimit', 'recordFailure', 'resetRateLimit', 'checkPasswordResetRateLimit', 'recordPasswordResetAttempt', 'authenticate', 'changePassword', 'isLoggedIn', 'login', 'logout'],
|
||||
];
|
||||
|
||||
yield 'password reset service vocabulary' => [
|
||||
PasswordResetServiceInterface::class,
|
||||
['requestReset', 'validateToken', 'resetPassword'],
|
||||
];
|
||||
|
||||
yield 'authorization service vocabulary' => [
|
||||
AuthorizationServiceInterface::class,
|
||||
['canRole', 'canUser', 'permissionsForRole'],
|
||||
];
|
||||
|
||||
yield 'settings service vocabulary' => [
|
||||
SettingsServiceInterface::class,
|
||||
['all', 'delete', 'get', 'getBool', 'getInt', 'getString', 'has', 'set'],
|
||||
];
|
||||
|
||||
yield 'audit log service vocabulary' => [
|
||||
AuditLogServiceInterface::class,
|
||||
['listRecent', 'record'],
|
||||
];
|
||||
|
||||
yield 'notification service vocabulary' => [
|
||||
NotificationServiceInterface::class,
|
||||
['recent', 'send', 'sendTemplate'],
|
||||
];
|
||||
|
||||
yield 'taxonomy service vocabulary' => [
|
||||
TaxonomyServiceInterface::class,
|
||||
['findAll', 'findPaginated', 'findById', 'findBySlug', 'create', 'delete'],
|
||||
];
|
||||
|
||||
yield 'media service vocabulary' => [
|
||||
MediaServiceInterface::class,
|
||||
['findAll', 'findPaginated', 'findByUserId', 'findByUserIdPaginated', 'findById', 'store', 'getUsageSummary', 'getUsageSummaries', 'delete'],
|
||||
];
|
||||
|
||||
|
||||
yield 'user service vocabulary' => [
|
||||
UserServiceInterface::class,
|
||||
['findAll', 'findPaginated', 'findById', 'delete', 'deleteFromAdministration', 'updateRole', 'updateRoleFromAdministration', 'create'],
|
||||
];
|
||||
}
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('serviceInterfaces')]
|
||||
public function testApplicationServiceInterfacesExposeAnExplicitVocabulary(string $interface, array $expectedMethods): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($interface);
|
||||
$methods = array_values(array_map(
|
||||
static fn (\ReflectionMethod $method): string => $method->getName(),
|
||||
$reflection->getMethods(\ReflectionMethod::IS_PUBLIC),
|
||||
));
|
||||
sort($methods);
|
||||
|
||||
$expected = $expectedMethods;
|
||||
sort($expected);
|
||||
|
||||
self::assertSame(
|
||||
$expected,
|
||||
$methods,
|
||||
sprintf('%s must keep its agreed application vocabulary to stay aligned with the other modules.', $interface),
|
||||
);
|
||||
}
|
||||
}
|
||||
93
tests/Architecture/ApplicationWorkflowShapeTest.php
Normal file
93
tests/Architecture/ApplicationWorkflowShapeTest.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
use ReflectionMethod;
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ApplicationWorkflowShapeTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testUseCasesFollowTheProjectShapeConvention(): void
|
||||
{
|
||||
$files = $this->phpFilesUnder('src/*/Application/UseCase');
|
||||
self::assertNotSame([], $files, 'Expected at least one use case file.');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$class = $this->reflectProjectClass($file);
|
||||
$basename = pathinfo($file, PATHINFO_FILENAME);
|
||||
|
||||
self::assertSame(
|
||||
$basename,
|
||||
$class->getShortName(),
|
||||
$this->fileRuleMessage('Use cases must be named after their file', $file),
|
||||
);
|
||||
self::assertTrue($class->isFinal(), $this->fileRuleMessage('Use cases must be final classes', $file));
|
||||
self::assertTrue($class->isReadOnly(), $this->fileRuleMessage('Use cases must be readonly classes', $file));
|
||||
|
||||
$publicMethods = array_values(array_map(
|
||||
static fn (ReflectionMethod $method): string => $method->getName(),
|
||||
array_filter(
|
||||
$class->getMethods(ReflectionMethod::IS_PUBLIC),
|
||||
static fn (ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $class->getName()
|
||||
&& !$method->isConstructor()
|
||||
&& !$method->isDestructor(),
|
||||
),
|
||||
));
|
||||
sort($publicMethods);
|
||||
|
||||
self::assertSame(
|
||||
['handle'],
|
||||
$publicMethods,
|
||||
$this->fileRuleMessage('Use cases must expose a single public handle(...) entrypoint', $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testCommandsRemainImmutableInputDtos(): void
|
||||
{
|
||||
$files = $this->phpFilesUnder('src/*/Application/Command');
|
||||
self::assertNotSame([], $files, 'Expected at least one command file.');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$basename = pathinfo($file, PATHINFO_FILENAME);
|
||||
$class = $this->reflectProjectClass($file);
|
||||
|
||||
self::assertStringEndsWith('Command', $basename, $this->fileRuleMessage('Command files must end with Command', $file));
|
||||
self::assertSame(
|
||||
$basename,
|
||||
$class->getShortName(),
|
||||
$this->fileRuleMessage('Commands must be named after their file', $file),
|
||||
);
|
||||
self::assertTrue($class->isFinal(), $this->fileRuleMessage('Commands must be final classes', $file));
|
||||
self::assertTrue($class->isReadOnly(), $this->fileRuleMessage('Commands must be readonly classes', $file));
|
||||
}
|
||||
}
|
||||
|
||||
public function testUiLayerDoesNotReferenceUseCasesOrCommandsDirectly(): void
|
||||
{
|
||||
$files = $this->uiFiles();
|
||||
self::assertNotSame([], $files, 'Expected UI files to exist.');
|
||||
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$files,
|
||||
['\\Application\\UseCase\\', '\\Application\\Command\\'],
|
||||
'UI layer must enter a domain through its application service, not through use cases or commands',
|
||||
);
|
||||
}
|
||||
|
||||
private function reflectProjectClass(string $file): ReflectionClass
|
||||
{
|
||||
$relativePath = $this->relativePath($file);
|
||||
$className = 'Netig\Netslim\\' . str_replace(['/', '.php'], ['\\', ''], substr($relativePath, 4));
|
||||
|
||||
try {
|
||||
return new ReflectionClass($className);
|
||||
} catch (ReflectionException $exception) {
|
||||
self::fail($this->fileRuleMessage('Unable to reflect class ' . $className . ': ' . $exception->getMessage(), $file));
|
||||
}
|
||||
}
|
||||
}
|
||||
40
tests/Architecture/HttpRuntimeBoundaryTest.php
Normal file
40
tests/Architecture/HttpRuntimeBoundaryTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class HttpRuntimeBoundaryTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testKernelHttpApplicationContainsOnlyTransverseHttpContracts(): void
|
||||
{
|
||||
$path = $this->projectPath('src/Kernel/Http/Application');
|
||||
self::assertDirectoryExists($path);
|
||||
|
||||
$entries = array_values(array_filter(scandir($path) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame([
|
||||
'Flash',
|
||||
'Session',
|
||||
], $entries, 'Kernel/Http/Application should only expose transverse HTTP contracts.');
|
||||
}
|
||||
|
||||
public function testKernelRuntimeHttpContainsOnlyApprovedAssemblyClasses(): void
|
||||
{
|
||||
$path = $this->projectPath('src/Kernel/Runtime/Http');
|
||||
self::assertDirectoryExists($path);
|
||||
|
||||
$entries = array_values(array_filter(scandir($path) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame([
|
||||
'DefaultErrorHandler.php',
|
||||
'ErrorHandlerConfigurator.php',
|
||||
'HttpApplicationFactory.php',
|
||||
'MiddlewareRegistrar.php',
|
||||
], $entries, 'Kernel/Runtime/Http should contain only the approved HTTP runtime assembly classes.');
|
||||
}
|
||||
}
|
||||
74
tests/Architecture/InstantiationBoundaryTest.php
Normal file
74
tests/Architecture/InstantiationBoundaryTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class InstantiationBoundaryTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testApplicationServicesDoNotInstantiateUseCasesOrPoliciesDirectly(): void
|
||||
{
|
||||
foreach ($this->applicationServiceFiles() as $file) {
|
||||
$violations = $this->collectInstantiationViolations(
|
||||
$file,
|
||||
self::USE_CASE_OR_POLICY_FRAGMENTS,
|
||||
$this->isUseCaseOrPolicyClass(...),
|
||||
);
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
$this->fileRuleMessage(self::MESSAGE_APPLICATION_SERVICE_DI_ONLY, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testControllersDoNotInstantiateApplicationOrDomainDependenciesDirectly(): void
|
||||
{
|
||||
foreach ($this->uiControllerFiles() as $file) {
|
||||
$violations = $this->collectInstantiationViolations(
|
||||
$file,
|
||||
self::APPLICATION_OR_DOMAIN_FRAGMENTS,
|
||||
$this->isApplicationOrDomainClass(...),
|
||||
);
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
$this->fileRuleMessage(self::MESSAGE_CONTROLLER_DI_ONLY, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testUiLayerDoesNotInstantiateInfrastructureOrRepositoryImplementationsDirectly(): void
|
||||
{
|
||||
foreach ($this->uiFiles() as $file) {
|
||||
$violations = $this->collectInstantiationViolations(
|
||||
$file,
|
||||
self::INFRASTRUCTURE_INSTANTIATION_FRAGMENTS,
|
||||
$this->isInfrastructureOrRepositoryClass(...),
|
||||
);
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
$this->fileRuleMessage(self::MESSAGE_UI_NO_INFRA_INSTANTIATION, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testApplicationLayerDoesNotInstantiateInfrastructureOrRepositoryImplementationsDirectly(): void
|
||||
{
|
||||
foreach ($this->applicationFiles() as $file) {
|
||||
$violations = $this->collectInstantiationViolations(
|
||||
$file,
|
||||
self::INFRASTRUCTURE_INSTANTIATION_FRAGMENTS,
|
||||
$this->isInfrastructureOrRepositoryClass(...),
|
||||
);
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
$this->fileRuleMessage(self::MESSAGE_APPLICATION_NO_INFRA_INSTANTIATION, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
tests/Architecture/KernelStructureTest.php
Normal file
54
tests/Architecture/KernelStructureTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class KernelStructureTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testKernelUsesOnlyApprovedTopLevelEntries(): void
|
||||
{
|
||||
$sharedPath = $this->projectPath('src/Kernel');
|
||||
self::assertDirectoryExists($sharedPath);
|
||||
|
||||
$entries = array_values(array_filter(scandir($sharedPath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame([
|
||||
'Html',
|
||||
'Http',
|
||||
'Mail',
|
||||
'Pagination',
|
||||
'Persistence',
|
||||
'Runtime',
|
||||
'Support',
|
||||
], $entries, 'Kernel must expose only approved transverse modules at top level.');
|
||||
}
|
||||
|
||||
public function testKernelPathsDoNotUseFeatureModuleNames(): void
|
||||
{
|
||||
$sharedPath = $this->projectPath('src/Kernel');
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($sharedPath, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST,
|
||||
);
|
||||
|
||||
foreach ($iterator as $fileInfo) {
|
||||
$relativePath = substr($fileInfo->getPathname(), strlen($sharedPath) + 1);
|
||||
$pathSegments = preg_split('~/+~', $relativePath) ?: [];
|
||||
|
||||
foreach ($pathSegments as $segment) {
|
||||
$name = pathinfo($segment, PATHINFO_FILENAME);
|
||||
|
||||
foreach (self::FEATURE_MODULES as $module) {
|
||||
self::assertFalse(
|
||||
str_starts_with($name, $module),
|
||||
sprintf('Kernel should not host feature-specific files or directories: %s', 'src/Kernel/' . $relativePath),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
67
tests/Architecture/LayerDependencyTest.php
Normal file
67
tests/Architecture/LayerDependencyTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class LayerDependencyTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testDomainLayerDoesNotDependOnApplicationInfrastructureOrUi(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->domainFiles(),
|
||||
array_merge(
|
||||
self::APPLICATION_NAMESPACES,
|
||||
self::INFRASTRUCTURE_NAMESPACES,
|
||||
self::UI_NAMESPACES,
|
||||
self::FRAMEWORK_NAMESPACE_FRAGMENTS,
|
||||
self::HTTP_MESSAGE_DEPENDENCIES,
|
||||
),
|
||||
self::MESSAGE_DOMAIN_FRAMEWORK_AGNOSTIC,
|
||||
);
|
||||
}
|
||||
|
||||
public function testApplicationLayerDoesNotDependOnUiInfrastructureOrHttpUploadedFiles(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->applicationFiles(),
|
||||
array_merge(
|
||||
$this->applicationMustNotDependOnInfrastructureFragments(),
|
||||
self::UI_NAMESPACES,
|
||||
self::APPLICATION_RUNTIME_DETAIL_FRAGMENTS,
|
||||
self::HTTP_MESSAGE_DEPENDENCIES,
|
||||
self::FRAMEWORK_NAMESPACE_FRAGMENTS,
|
||||
),
|
||||
self::MESSAGE_APPLICATION_PORTS_ONLY,
|
||||
);
|
||||
}
|
||||
|
||||
public function testApplicationLayerDoesNotDependOnInfrastructureImplementations(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->applicationFiles(),
|
||||
$this->applicationMustNotDependOnInfrastructureFragments(),
|
||||
self::MESSAGE_APPLICATION_NO_INFRA_DEPENDENCY,
|
||||
);
|
||||
}
|
||||
|
||||
public function testUiLayerDoesNotDependOnInfrastructureImplementations(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->uiFiles(),
|
||||
$this->uiMustNotDependOnInfrastructureFragments(),
|
||||
self::MESSAGE_UI_NO_INFRA_DEPENDENCY,
|
||||
);
|
||||
}
|
||||
|
||||
public function testInfrastructureLayerDoesNotDependOnUi(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->nonWiringInfrastructureFiles(),
|
||||
$this->infrastructureMustNotDependOnUiFragments(),
|
||||
self::MESSAGE_INFRA_NO_UI_DEPENDENCY,
|
||||
);
|
||||
}
|
||||
}
|
||||
112
tests/Architecture/ModuleBoundaryGovernanceTest.php
Normal file
112
tests/Architecture/ModuleBoundaryGovernanceTest.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ModuleBoundaryGovernanceTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testKernelDoesNotImportFeatureModules(): void
|
||||
{
|
||||
$violations = [];
|
||||
$featurePrefixes = [
|
||||
'Netig\\Netslim\\Identity\\',
|
||||
'Netig\\Netslim\\Settings\\',
|
||||
'Netig\\Netslim\\AuditLog\\',
|
||||
'Netig\\Netslim\\Notifications\\',
|
||||
'Netig\\Netslim\\Taxonomy\\',
|
||||
'Netig\\Netslim\\Media\\',
|
||||
];
|
||||
|
||||
foreach ($this->phpFilesUnder('src/Kernel') as $file) {
|
||||
foreach ($this->importedClasses($file) as $import) {
|
||||
$importsFeatureModule = false;
|
||||
foreach ($featurePrefixes as $prefix) {
|
||||
if (str_starts_with($import, $prefix)) {
|
||||
$importsFeatureModule = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$importsFeatureModule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = $this->relativePath($file) . ' -> ' . $import;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
'Kernel must not import feature modules directly',
|
||||
);
|
||||
}
|
||||
|
||||
public function testApplicationDomainAndInfrastructureUseOnlyApprovedCrossModuleContracts(): void
|
||||
{
|
||||
$violations = [];
|
||||
$allowedExternalImports = [
|
||||
'Identity' => [],
|
||||
'Settings' => [],
|
||||
'AuditLog' => [],
|
||||
'Notifications' => [],
|
||||
'Taxonomy' => [],
|
||||
'Media' => [],
|
||||
];
|
||||
|
||||
foreach (self::FEATURE_MODULES as $module) {
|
||||
$directories = [
|
||||
$this->projectPath('src/' . $module . '/Application'),
|
||||
$this->projectPath('src/' . $module . '/Domain'),
|
||||
$this->projectPath('src/' . $module . '/Infrastructure'),
|
||||
];
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory));
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $fileInfo->getPathname();
|
||||
$imports = $this->importedClasses($file);
|
||||
|
||||
foreach ($imports as $import) {
|
||||
if (!str_starts_with($import, 'Netig\\Netslim\\')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($import, 'Netig\\Netslim\\Kernel\\') || str_starts_with($import, 'Netig\\Netslim\\' . $module . '\\')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isAllowed = false;
|
||||
foreach ($allowedExternalImports[$module] as $allowedPrefix) {
|
||||
if (str_starts_with($import, $allowedPrefix)) {
|
||||
$isAllowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isAllowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = $this->relativePath($file) . ' -> ' . $import;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNoArchitectureViolations(
|
||||
$violations,
|
||||
'Application, Domain and Infrastructure layers must only cross module boundaries through approved public contracts',
|
||||
);
|
||||
}
|
||||
}
|
||||
34
tests/Architecture/ModuleStructureTest.php
Normal file
34
tests/Architecture/ModuleStructureTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class ModuleStructureTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testFeatureModulesUseExpectedTopLevelStructure(): void
|
||||
{
|
||||
foreach (self::FEATURE_MODULES as $module) {
|
||||
$modulePath = $this->modulePath($module);
|
||||
self::assertDirectoryExists($modulePath, $this->moduleRuleMessage(self::MESSAGE_MODULE_DIRECTORY_MISSING, $module));
|
||||
|
||||
$entries = array_values(array_filter(scandir($modulePath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame($this->expectedModuleEntries($module), $entries, $this->moduleRuleMessage(self::MESSAGE_STANDARD_MODULE_STRUCTURE, $module));
|
||||
}
|
||||
}
|
||||
|
||||
public function testLegacyGlobalViewsDirectoryHasBeenRemoved(): void
|
||||
{
|
||||
self::assertDirectoryDoesNotExist($this->projectPath('views'));
|
||||
}
|
||||
|
||||
public function testFixtureApplicationExposesAModuleManifest(): void
|
||||
{
|
||||
self::assertDirectoryExists($this->projectPath('tests/Fixtures/Application/config'));
|
||||
self::assertFileExists($this->projectPath('tests/Fixtures/Application/config/modules.php'));
|
||||
}
|
||||
}
|
||||
338
tests/Architecture/Support/ArchitectureTestCase.php
Normal file
338
tests/Architecture/Support/ArchitectureTestCase.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture\Support;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations]
|
||||
abstract class ArchitectureTestCase extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
protected const FEATURE_MODULES = ['Identity', 'Settings', 'AuditLog', 'Notifications', 'Taxonomy', 'Media'];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const APPLICATION_NAMESPACES = [
|
||||
'Netig\\Netslim\\Identity\\Application\\',
|
||||
'Netig\\Netslim\\Settings\\Application\\',
|
||||
'Netig\\Netslim\\AuditLog\\Application\\',
|
||||
'Netig\\Netslim\\Notifications\\Application\\',
|
||||
'Netig\\Netslim\\Taxonomy\\Application\\',
|
||||
'Netig\\Netslim\\Media\\Application\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const INFRASTRUCTURE_NAMESPACES = [
|
||||
'Netig\\Netslim\\Identity\\Infrastructure\\',
|
||||
'Netig\\Netslim\\Settings\\Infrastructure\\',
|
||||
'Netig\\Netslim\\AuditLog\\Infrastructure\\',
|
||||
'Netig\\Netslim\\Notifications\\Infrastructure\\',
|
||||
'Netig\\Netslim\\Taxonomy\\Infrastructure\\',
|
||||
'Netig\\Netslim\\Media\\Infrastructure\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const UI_NAMESPACES = [
|
||||
'Netig\\Netslim\\Identity\\UI\\',
|
||||
'Netig\\Netslim\\Taxonomy\\UI\\',
|
||||
'Netig\\Netslim\\Media\\UI\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const FRAMEWORK_NAMESPACE_FRAGMENTS = [
|
||||
'Slim\\',
|
||||
'Twig\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const HTTP_MESSAGE_DEPENDENCIES = [
|
||||
'Psr\\Http\\Message\\ServerRequestInterface',
|
||||
'Psr\\Http\\Message\\ResponseInterface',
|
||||
'Psr\\Http\\Message\\UploadedFileInterface',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const APPLICATION_RUNTIME_DETAIL_FRAGMENTS = [
|
||||
'Netig\\Netslim\\Kernel\\Http\\Application\\Session\\SessionManagerInterface',
|
||||
'usePDO;',
|
||||
'newPDO(',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const USE_CASE_OR_POLICY_FRAGMENTS = [
|
||||
'\\Application\\UseCase\\',
|
||||
'\\Domain\\Policy\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const APPLICATION_OR_DOMAIN_FRAGMENTS = [
|
||||
'\\Application\\',
|
||||
'\\Domain\\',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected const INFRASTRUCTURE_INSTANTIATION_FRAGMENTS = [
|
||||
'\\Infrastructure\\',
|
||||
];
|
||||
|
||||
protected const MESSAGE_DOMAIN_FRAMEWORK_AGNOSTIC = 'Domain layer must stay framework-agnostic';
|
||||
protected const MESSAGE_APPLICATION_PORTS_ONLY = 'Application layer must depend on ports, not framework/runtime details';
|
||||
protected const MESSAGE_APPLICATION_NO_INFRA_DEPENDENCY = 'Application layer should depend on ports and injected services, not infrastructure implementations';
|
||||
protected const MESSAGE_UI_NO_INFRA_DEPENDENCY = 'UI layer should talk to interfaces and request adapters, not infrastructure implementations';
|
||||
protected const MESSAGE_INFRA_NO_UI_DEPENDENCY = 'Infrastructure implementation must not depend on UI classes';
|
||||
protected const MESSAGE_APPLICATION_SERVICE_DI_ONLY = 'Application services must rely on DI for use cases and policies, not instantiate them directly';
|
||||
protected const MESSAGE_CONTROLLER_DI_ONLY = 'Controllers must rely on DI and request adapters for application/domain dependencies, not instantiate them directly';
|
||||
protected const MESSAGE_UI_NO_INFRA_INSTANTIATION = 'UI layer must rely on DI and ports, not instantiate infrastructure or repository implementations directly';
|
||||
protected const MESSAGE_APPLICATION_NO_INFRA_INSTANTIATION = 'Application layer must rely on interfaces and injected collaborators, not instantiate infrastructure or repository implementations directly';
|
||||
protected const MESSAGE_WIRING_ONLY_UI_REFERENCE = 'Only infrastructure wiring files may reference UI classes';
|
||||
protected const MESSAGE_STANDARD_MODULE_STRUCTURE = '%s should only contain the standard modular structure';
|
||||
protected const MESSAGE_MODULE_DIRECTORY_MISSING = 'Module directory missing for %s';
|
||||
protected const MESSAGE_NO_ARCHITECTURE_VIOLATIONS = '%s';
|
||||
|
||||
/** @return list<string> */
|
||||
protected function domainFiles(): array
|
||||
{
|
||||
return $this->phpFilesUnder('src/*/Domain');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function applicationFiles(): array
|
||||
{
|
||||
return $this->phpFilesUnder('src/*/Application');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function applicationServiceFiles(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->applicationFiles(),
|
||||
static fn (string $file): bool => str_ends_with($file, 'ApplicationService.php'),
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function uiFiles(): array
|
||||
{
|
||||
return $this->phpFilesUnder('src/*/UI');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function uiControllerFiles(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->phpFilesUnder('src/*/UI/Http'),
|
||||
static fn (string $file): bool => str_ends_with($file, 'Controller.php'),
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function infrastructureFiles(): array
|
||||
{
|
||||
return $this->phpFilesUnder('src/*/Infrastructure');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function nonWiringInfrastructureFiles(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->infrastructureFiles(),
|
||||
static fn (string $file): bool => basename($file) !== 'dependencies.php',
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function phpFilesUnder(string $relativeDirectory): array
|
||||
{
|
||||
$directories = glob($this->projectPath($relativeDirectory), GLOB_ONLYDIR) ?: [];
|
||||
$files = [];
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory));
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = $fileInfo->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return array_values(array_unique($files));
|
||||
}
|
||||
|
||||
protected function projectPath(string $suffix = ''): string
|
||||
{
|
||||
$root = dirname(__DIR__, 3);
|
||||
|
||||
return $suffix === '' ? $root : $root . '/' . $suffix;
|
||||
}
|
||||
|
||||
protected function relativePath(string $absolutePath): string
|
||||
{
|
||||
return substr($absolutePath, strlen($this->projectPath()) + 1);
|
||||
}
|
||||
|
||||
protected function modulePath(string $module): string
|
||||
{
|
||||
return $this->projectPath('src/' . $module);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function expectedModuleEntries(string $module): array
|
||||
{
|
||||
$entries = match ($module) {
|
||||
'Taxonomy' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'TaxonomyModule.php', 'UI'],
|
||||
'Media' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'MediaModule.php', 'Migrations', 'UI'],
|
||||
'Settings' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'SettingsModule.php'],
|
||||
'AuditLog' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'AuditLogModule.php', 'Migrations'],
|
||||
'Notifications' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'NotificationsModule.php'],
|
||||
default => ['Application', 'Domain', 'Infrastructure', 'Migrations', 'UI', $module . 'Module.php'],
|
||||
};
|
||||
sort($entries);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
protected function normalizedCode(string $file): string
|
||||
{
|
||||
$code = file_get_contents($file);
|
||||
self::assertNotFalse($code, 'Unable to read ' . $file);
|
||||
|
||||
$normalized = '';
|
||||
foreach (token_get_all($code) as $token) {
|
||||
if (is_array($token)) {
|
||||
if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT, T_WHITESPACE], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized .= $token[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized .= $token;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param list<string> $needles */
|
||||
protected function containsAny(string $haystack, array $needles): bool
|
||||
{
|
||||
foreach ($needles as $needle) {
|
||||
if (str_contains($haystack, $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
* @param list<string> $forbiddenFragments
|
||||
*/
|
||||
protected function assertFilesDoNotReferenceFragments(array $files, array $forbiddenFragments, string $message): void
|
||||
{
|
||||
foreach ($files as $file) {
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertFalse(
|
||||
$this->containsAny($code, $forbiddenFragments),
|
||||
$this->fileRuleMessage($message, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
protected function importedClasses(string $file): array
|
||||
{
|
||||
$code = file_get_contents($file);
|
||||
self::assertNotFalse($code, 'Unable to read ' . $file);
|
||||
|
||||
preg_match_all('/^use\s+([^;]+);/m', $code, $matches);
|
||||
|
||||
return array_values(array_unique(array_map('trim', $matches[1] ?? [])));
|
||||
}
|
||||
|
||||
protected function applicationMustNotDependOnInfrastructureFragments(): array
|
||||
{
|
||||
return array_filter(array_map(
|
||||
static fn (string $namespace): string => str_replace('\\\\', '\\', $namespace),
|
||||
self::INFRASTRUCTURE_NAMESPACES,
|
||||
));
|
||||
}
|
||||
|
||||
protected function uiMustNotDependOnInfrastructureFragments(): array
|
||||
{
|
||||
return $this->applicationMustNotDependOnInfrastructureFragments();
|
||||
}
|
||||
|
||||
protected function infrastructureMustNotDependOnUiFragments(): array
|
||||
{
|
||||
return array_filter(array_map(
|
||||
static fn (string $namespace): string => str_replace('\\\\', '\\', $namespace),
|
||||
self::UI_NAMESPACES,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $namespaceFragments
|
||||
* @param callable(string): bool $classFilter
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function collectInstantiationViolations(string $file, array $namespaceFragments, callable $classFilter): array
|
||||
{
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
if (!str_contains($code, 'new')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$violations = [];
|
||||
foreach ($this->importedClasses($file) as $import) {
|
||||
if (!$classFilter($import) || !$this->containsAny($import, $namespaceFragments)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$shortName = substr($import, (int) strrpos($import, '\\') + 1);
|
||||
if (str_contains($code, 'new' . $shortName . '(') || str_contains($code, 'new\\' . $import . '(')) {
|
||||
$violations[] = $this->relativePath($file) . ' instantiates ' . $import;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($violations));
|
||||
}
|
||||
|
||||
protected function isUseCaseOrPolicyClass(string $class): bool
|
||||
{
|
||||
return str_contains($class, '\\Application\\UseCase\\') || str_contains($class, '\\Domain\\Policy\\');
|
||||
}
|
||||
|
||||
protected function isApplicationOrDomainClass(string $class): bool
|
||||
{
|
||||
return str_contains($class, '\\Application\\') || str_contains($class, '\\Domain\\');
|
||||
}
|
||||
|
||||
protected function isInfrastructureOrRepositoryClass(string $class): bool
|
||||
{
|
||||
return str_contains($class, '\\Infrastructure\\') || str_contains($class, '\\Repository\\');
|
||||
}
|
||||
|
||||
protected function assertNoArchitectureViolations(array $violations, string $message): void
|
||||
{
|
||||
self::assertSame([], $violations, sprintf(self::MESSAGE_NO_ARCHITECTURE_VIOLATIONS, $message . ($violations !== [] ? "\n- " . implode("\n- ", $violations) : '')));
|
||||
}
|
||||
|
||||
protected function fileRuleMessage(string $message, string $file): string
|
||||
{
|
||||
return sprintf('%s: %s', $message, $this->relativePath($file));
|
||||
}
|
||||
|
||||
protected function moduleRuleMessage(string $message, string $module): string
|
||||
{
|
||||
return sprintf($message, $module);
|
||||
}
|
||||
}
|
||||
90
tests/Architecture/SupportGovernanceTest.php
Normal file
90
tests/Architecture/SupportGovernanceTest.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class SupportGovernanceTest extends ArchitectureTestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const APPROVED_SUPPORT_TOP_LEVEL_ENTRIES = [
|
||||
'Exception',
|
||||
'Util',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const APPROVED_SUPPORT_FILES = [
|
||||
'Exception/NotFoundException.php',
|
||||
'Util/DateParser.php',
|
||||
'Util/SlugHelper.php',
|
||||
];
|
||||
|
||||
public function testSupportUsesOnlyApprovedTopLevelEntries(): void
|
||||
{
|
||||
$supportPath = $this->projectPath('src/Kernel/Support');
|
||||
self::assertDirectoryExists($supportPath);
|
||||
|
||||
$entries = array_values(array_filter(scandir($supportPath) ?: [], static fn (string $entry): bool => !in_array($entry, ['.', '..'], true)));
|
||||
sort($entries);
|
||||
|
||||
self::assertSame(
|
||||
self::APPROVED_SUPPORT_TOP_LEVEL_ENTRIES,
|
||||
$entries,
|
||||
'Kernel/Support is frozen by default: only explicitly approved subdirectories may exist.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSupportContainsOnlyExplicitlyApprovedFiles(): void
|
||||
{
|
||||
$supportPath = $this->projectPath('src/Kernel/Support');
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($supportPath, \FilesystemIterator::SKIP_DOTS),
|
||||
);
|
||||
|
||||
$files = [];
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = substr($fileInfo->getPathname(), strlen($supportPath) + 1);
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
self::assertSame(
|
||||
self::APPROVED_SUPPORT_FILES,
|
||||
$files,
|
||||
'Any new file under Kernel/Support must be a deliberate architectural decision: update the allow list, tests and documentation together.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSupportStaysIndependentFromFeatureModulesAndFrameworks(): void
|
||||
{
|
||||
$this->assertFilesDoNotReferenceFragments(
|
||||
$this->phpFilesUnder('src/Kernel/Support'),
|
||||
array_merge(
|
||||
self::APPLICATION_NAMESPACES,
|
||||
self::INFRASTRUCTURE_NAMESPACES,
|
||||
self::UI_NAMESPACES,
|
||||
self::FRAMEWORK_NAMESPACE_FRAGMENTS,
|
||||
self::HTTP_MESSAGE_DEPENDENCIES,
|
||||
),
|
||||
'Kernel/Support must stay pure, transverse and framework-agnostic',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSupportClassesAreFinal(): void
|
||||
{
|
||||
foreach ($this->phpFilesUnder('src/Kernel/Support') as $file) {
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
self::assertTrue(
|
||||
str_contains($code, 'finalclass'),
|
||||
$this->fileRuleMessage('Kernel/Support classes must be final to avoid becoming extension points by accident', $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
tests/Architecture/WiringBoundaryTest.php
Normal file
27
tests/Architecture/WiringBoundaryTest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Architecture;
|
||||
|
||||
use Tests\Architecture\Support\ArchitectureTestCase;
|
||||
|
||||
final class WiringBoundaryTest extends ArchitectureTestCase
|
||||
{
|
||||
public function testOnlyInfrastructureWiringFilesMayReferenceUiLayer(): void
|
||||
{
|
||||
foreach ($this->infrastructureFiles() as $file) {
|
||||
$code = $this->normalizedCode($file);
|
||||
|
||||
if (!$this->containsAny($code, self::UI_NAMESPACES)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::assertSame(
|
||||
'dependencies.php',
|
||||
basename($file),
|
||||
$this->fileRuleMessage(self::MESSAGE_WIRING_ONLY_UI_REFERENCE, $file),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user