Files
breadcrumb-the-shire/tests/Architecture/RepositoryLayerIsolationContractTest.php
fs be8bf496cb 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>
2026-03-22 14:15:41 +01:00

121 lines
3.9 KiB
PHP

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