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:
2026-03-22 14:15:41 +01:00
parent 555199b217
commit be8bf496cb
5 changed files with 428 additions and 0 deletions

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