Files
netslim-core/tests/Architecture/Support/ArchitectureTestCase.php
2026-03-20 22:13:41 +01:00

339 lines
12 KiB
PHP

<?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);
}
}