From be8bf496cb6708462506d1044552aef491de1dcc Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 22 Mar 2026 14:15:41 +0100 Subject: [PATCH] test: add architecture contract tests for layering, sessions, and asset versioning New contracts enforce repository layer isolation, no HTML in services, no DB calls in views, module session key prefixes, and asset versioning. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AssetVersioningContractTest.php | 74 +++++++++++ .../ModuleSessionKeyPrefixContractTest.php | 64 ++++++++++ .../RepositoryLayerIsolationContractTest.php | 120 ++++++++++++++++++ .../ServiceLayerNoHtmlContractTest.php | 94 ++++++++++++++ .../ViewLayerNoDbCallsContractTest.php | 76 +++++++++++ 5 files changed, 428 insertions(+) create mode 100644 tests/Architecture/AssetVersioningContractTest.php create mode 100644 tests/Architecture/ModuleSessionKeyPrefixContractTest.php create mode 100644 tests/Architecture/RepositoryLayerIsolationContractTest.php create mode 100644 tests/Architecture/ServiceLayerNoHtmlContractTest.php create mode 100644 tests/Architecture/ViewLayerNoDbCallsContractTest.php 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) + ); + } +}