diff --git a/tests/Architecture/AssetVersioningContractTest.php b/tests/Architecture/AssetVersioningContractTest.php new file mode 100644 index 0000000..f541c51 --- /dev/null +++ b/tests/Architecture/AssetVersioningContractTest.php @@ -0,0 +1,74 @@ +assertAssetVersionUsed('templates'); + } + + public function testCorePageAssetReferencesUseAssetVersion(): void + { + $this->assertAssetVersionUsed('pages'); + } + + public function testModulePageAssetReferencesUseAssetVersion(): void + { + $root = $this->projectRootPath(); + $modulesDir = $root . '/modules'; + if (!is_dir($modulesDir)) { + self::markTestSkipped('modules/ directory missing.'); + } + + foreach (scandir($modulesDir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { + continue; + } + + foreach (['pages', 'templates'] as $subdir) { + $rel = 'modules/' . $entry . '/' . $subdir; + if (is_dir($root . '/' . $rel)) { + $this->assertAssetVersionUsed($rel); + } + } + } + } + + private function assertAssetVersionUsed(string $relativeDirectory): void + { + $jsViolations = $this->findPatternMatchesInFiles($relativeDirectory, self::BARE_JS_PATTERN, ['phtml']); + $cssViolations = $this->findPatternMatchesInFiles($relativeDirectory, self::BARE_CSS_PATTERN, ['phtml']); + + $violations = array_unique(array_merge($jsViolations, $cssViolations)); + sort($violations); + + self::assertSame( + [], + $violations, + "Asset references must use assetVersion() for cache-busting. Violations:\n" . implode("\n", $violations) + ); + } +} diff --git a/tests/Architecture/ModuleSessionKeyPrefixContractTest.php b/tests/Architecture/ModuleSessionKeyPrefixContractTest.php new file mode 100644 index 0000000..44a285f --- /dev/null +++ b/tests/Architecture/ModuleSessionKeyPrefixContractTest.php @@ -0,0 +1,64 @@ +." to prevent + * collisions between modules and with core session state. + */ +final class ModuleSessionKeyPrefixContractTest extends AbstractModuleStructureContractTestCase +{ + /** + * Pattern matches direct $_SESSION access with a string key that does NOT + * start with "module.". + */ + private const SESSION_ACCESS_PATTERN = '/\$_SESSION\s*\[\s*[\'"](?!module\.)/'; + + public function testModulePhpFilesUseSessionPrefix(): void + { + $modulesDir = realpath(__DIR__ . '/../../modules'); + self::assertNotFalse($modulesDir); + + $violations = []; + + foreach (self::$modules as $module) { + $moduleId = $module['id']; + + foreach (['lib', 'pages'] as $subdir) { + $dir = $module['path'] . '/' . $subdir; + if (!is_dir($dir)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + $ext = $file->getExtension(); + if ($ext !== 'php' && $ext !== 'phtml') { + continue; + } + + $content = file_get_contents($file->getPathname()); + if ($content === false) { + continue; + } + + if (preg_match(self::SESSION_ACCESS_PATTERN, $content)) { + $relativePath = 'modules/' . $moduleId . '/' . $subdir . '/' . str_replace($dir . '/', '', $file->getPathname()); + $violations[] = $relativePath . " (module '{$moduleId}') — session keys must start with 'module.{$moduleId}.'"; + } + } + } + } + + self::assertSame( + [], + $violations, + "Module session keys must be prefixed with 'module..':\n" . implode("\n", $violations) + ); + } +} diff --git a/tests/Architecture/RepositoryLayerIsolationContractTest.php b/tests/Architecture/RepositoryLayerIsolationContractTest.php new file mode 100644 index 0000000..76c1ed4 --- /dev/null +++ b/tests/Architecture/RepositoryLayerIsolationContractTest.php @@ -0,0 +1,120 @@ + */ + private const FORBIDDEN_PATTERNS = [ + '/\$_SESSION\b/' => '$_SESSION', + '/\$_GET\b/' => '$_GET', + '/\$_POST\b/' => '$_POST', + '/\$_COOKIE\b/' => '$_COOKIE', + '/\$_REQUEST\b/' => '$_REQUEST', + '/\bheader\s*\(/' => 'header()', + '/\bhttp_response_code\s*\(/' => 'http_response_code()', + '/\bsetcookie\s*\(/' => 'setcookie()', + '/\bsession_start\s*\(/' => 'session_start()', + ]; + + public function testCoreRepositoriesDoNotAccessHttpSuperglobals(): void + { + $this->assertRepositoryIsolation('lib/Repository'); + } + + public function testModuleRepositoriesDoNotAccessHttpSuperglobals(): void + { + $root = $this->projectRootPath(); + $modulesDir = $root . '/modules'; + if (!is_dir($modulesDir)) { + self::markTestSkipped('modules/ directory missing.'); + } + + foreach (scandir($modulesDir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { + continue; + } + $libRel = 'modules/' . $entry . '/lib'; + if (!is_dir($root . '/' . $libRel)) { + continue; + } + + // Scan only Repository directories within module lib/ + $repoDir = $libRel . '/Repository'; + if (is_dir($root . '/' . $repoDir)) { + $this->assertRepositoryIsolation($repoDir); + } + + // Also scan files named *Repository.php anywhere in module lib/ + $this->assertRepositoryFilesIsolated($libRel); + } + } + + private function assertRepositoryIsolation(string $relativeDirectory): void + { + $violations = []; + + foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) { + foreach ($this->findPatternMatchesInPhpFiles($relativeDirectory, $pattern) as $file) { + $violations[] = "{$file} uses {$label}"; + } + } + + self::assertSame( + [], + $violations, + "Repository layer must not access HTTP state. Violations:\n" . implode("\n", $violations) + ); + } + + private function assertRepositoryFilesIsolated(string $relativeDirectory): void + { + $root = $this->projectRootPath(); + $basePath = $root . '/' . $relativeDirectory; + $violations = []; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if ($file->getExtension() !== 'php') { + continue; + } + if (!str_ends_with($file->getFilename(), 'Repository.php')) { + continue; + } + if (str_ends_with($file->getFilename(), 'RepositoryInterface.php')) { + continue; + } + + $content = file_get_contents($file->getPathname()); + if ($content === false) { + continue; + } + + $relativePath = str_replace($root . '/', '', $file->getPathname()); + foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) { + if (preg_match($pattern, $content)) { + $violations[] = "{$relativePath} uses {$label}"; + } + } + } + + self::assertSame( + [], + $violations, + "Repository files must not access HTTP state. Violations:\n" . implode("\n", $violations) + ); + } +} diff --git a/tests/Architecture/ServiceLayerNoHtmlContractTest.php b/tests/Architecture/ServiceLayerNoHtmlContractTest.php new file mode 100644 index 0000000..ca21533 --- /dev/null +++ b/tests/Architecture/ServiceLayerNoHtmlContractTest.php @@ -0,0 +1,94 @@ + */ + private const EXCLUDED_FILES = [ + 'lib/Service/User/UserAccessPdfService.php', + ]; + + /** + * Matches opening HTML tags that indicate rendering logic. + * Ignores comments and string literals containing tag descriptions. + */ + private const HTML_TAG_PATTERN = '/<(?:div|span|table|form|input|button|select|textarea|label|ul|ol|li|h[1-6]|thead|tbody|tr|td|th|section|article|nav|header|footer|main|aside)\b/'; + + public function testServicesContainNoHtmlTags(): void + { + $violations = $this->findHtmlViolations('lib/Service'); + $violations = array_merge($violations, $this->findHtmlViolations('lib/Http')); + + self::assertSame( + [], + $violations, + "Service layer must not contain HTML tags. Violations found:\n" . implode("\n", $violations) + ); + } + + public function testModuleServicesContainNoHtmlTags(): void + { + $root = $this->projectRootPath(); + $modulesDir = $root . '/modules'; + if (!is_dir($modulesDir)) { + self::markTestSkipped('modules/ directory missing.'); + } + + $violations = []; + foreach (scandir($modulesDir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { + continue; + } + $libRel = 'modules/' . $entry . '/lib'; + if (is_dir($root . '/' . $libRel)) { + $violations = array_merge($violations, $this->findHtmlViolations($libRel)); + } + } + + self::assertSame( + [], + $violations, + "Module service layer must not contain HTML tags. Violations found:\n" . implode("\n", $violations) + ); + } + + /** + * @return list + */ + private function findHtmlViolations(string $relativeDirectory): array + { + $root = $this->projectRootPath(); + $basePath = $root . '/' . $relativeDirectory; + if (!is_dir($basePath)) { + return []; + } + + $hits = $this->findPatternMatchesInPhpFiles($relativeDirectory, self::HTML_TAG_PATTERN); + + return array_values(array_filter( + $hits, + static function (string $path): bool { + // Exclude files that legitimately build HTML (PDF rendering) + foreach (self::EXCLUDED_FILES as $excluded) { + if ($path === $excluded) { + return false; + } + } + + // Only flag Service/Gateway/Policy/Factory files, not generic helpers + return (bool) preg_match('/(Service|Gateway|Policy|Factory)\.php$/', $path); + } + )); + } +} diff --git a/tests/Architecture/ViewLayerNoDbCallsContractTest.php b/tests/Architecture/ViewLayerNoDbCallsContractTest.php new file mode 100644 index 0000000..0298f96 --- /dev/null +++ b/tests/Architecture/ViewLayerNoDbCallsContractTest.php @@ -0,0 +1,76 @@ + */ + private const DB_PATTERNS = [ + '/->query\s*\(/', + '/->prepare\s*\(/', + '/->execute\s*\(/', + '/->fetch\s*\(/', + '/->fetchAll\s*\(/', + '/->fetchColumn\s*\(/', + '/\bnew\s+PDO\b/', + '/\$pdo\b/', + ]; + + public function testCorePagesViewsContainNoDbCalls(): void + { + $this->assertNoDbCallsInViews('pages'); + } + + public function testModuleViewsContainNoDbCalls(): void + { + $root = $this->projectRootPath(); + $modulesDir = $root . '/modules'; + if (!is_dir($modulesDir)) { + self::markTestSkipped('modules/ directory missing.'); + } + + foreach (scandir($modulesDir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { + continue; + } + $pagesRel = 'modules/' . $entry . '/pages'; + if (is_dir($root . '/' . $pagesRel)) { + $this->assertNoDbCallsInViews($pagesRel); + } + } + } + + public function testTemplatesContainNoDbCalls(): void + { + $this->assertNoDbCallsInViews('templates'); + } + + private function assertNoDbCallsInViews(string $relativeDirectory): void + { + $allViolations = []; + + foreach (self::DB_PATTERNS as $pattern) { + $hits = $this->findPatternMatchesInFiles($relativeDirectory, $pattern, ['phtml']); + foreach ($hits as $file) { + $allViolations[$file] = true; + } + } + + $violations = array_keys($allViolations); + sort($violations); + + self::assertSame( + [], + $violations, + "Views must not contain database calls. Violations found:\n" . implode("\n", $violations) + ); + } +}