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) <noreply@anthropic.com>
This commit is contained in:
74
tests/Architecture/AssetVersioningContractTest.php
Normal file
74
tests/Architecture/AssetVersioningContractTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* All CSS and JS asset references in templates and views must use
|
||||
* assetVersion() for cache-busting. Bare paths without assetVersion()
|
||||
* risk serving stale cached assets after deploys.
|
||||
*
|
||||
* Excluded: inline scripts, data URIs, external CDN URLs, and empty src attributes.
|
||||
*/
|
||||
class AssetVersioningContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/**
|
||||
* Matches src="..." or href="..." pointing to local .js/.css files
|
||||
* that are NOT wrapped in assetVersion().
|
||||
*
|
||||
* The negative lookbehind ensures we don't flag assetVersion() usages.
|
||||
* Excludes external URLs (http://, https://, //) and empty attributes.
|
||||
*/
|
||||
private const BARE_JS_PATTERN = '/src=["\'](?!https?:\/\/|\/\/)(?!<?php\s+e\(assetVersion)[^"\']*\.js["\']/';
|
||||
private const BARE_CSS_PATTERN = '/href=["\'](?!https?:\/\/|\/\/)(?!<?php\s+e\(assetVersion)[^"\']*\.css["\']/';
|
||||
|
||||
public function testTemplateAssetReferencesUseAssetVersion(): void
|
||||
{
|
||||
$this->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)
|
||||
);
|
||||
}
|
||||
}
|
||||
64
tests/Architecture/ModuleSessionKeyPrefixContractTest.php
Normal file
64
tests/Architecture/ModuleSessionKeyPrefixContractTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
/**
|
||||
* Module code must prefix session keys with "module.<id>." 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.<id>.':\n" . implode("\n", $violations)
|
||||
);
|
||||
}
|
||||
}
|
||||
120
tests/Architecture/RepositoryLayerIsolationContractTest.php
Normal file
120
tests/Architecture/RepositoryLayerIsolationContractTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Repository layer must not access HTTP superglobals or emit HTTP responses.
|
||||
* Repositories are pure data access — no session, request, or response handling.
|
||||
*/
|
||||
class RepositoryLayerIsolationContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/** @var array<string, string> */
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
94
tests/Architecture/ServiceLayerNoHtmlContractTest.php
Normal file
94
tests/Architecture/ServiceLayerNoHtmlContractTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Service layer (Service, Gateway, Policy) must not contain HTML output.
|
||||
* HTML rendering belongs in views (.phtml) and templates only.
|
||||
*
|
||||
* Excluded: PDF services that legitimately build HTML for Dompdf rendering.
|
||||
*/
|
||||
class ServiceLayerNoHtmlContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/** @var list<string> */
|
||||
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<string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
76
tests/Architecture/ViewLayerNoDbCallsContractTest.php
Normal file
76
tests/Architecture/ViewLayerNoDbCallsContractTest.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Views (.phtml) must be pure rendering — no database calls allowed.
|
||||
* All data must be prepared in actions and passed via view variables.
|
||||
*/
|
||||
class ViewLayerNoDbCallsContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/** @var list<string> */
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user