Files
breadcrumb-the-shire/tests/Architecture/RepositoryInterfaceContractTest.php
2026-03-05 08:26:51 +01:00

133 lines
4.4 KiB
PHP

<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* Enforces that every instance-based repository class has a co-located interface
* and correctly implements it.
*
* Excluded from this contract (static-method-only classes, no instance interface possible):
* - TenantCustomFieldDefinitionRepository
* - TenantCustomFieldOptionRepository
* - UserCustomFieldValueRepository
* - UserCustomFieldValueOptionRepository
* - PageRepository
* - PageContentRepository
*
* Also excluded (utility, not a domain repository):
* - RepoQuery
*/
class RepositoryInterfaceContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/** @var list<string> */
private const EXCLUDED_CLASSES = [
'MintyPHP\\Repository\\Content\\PageContentRepository',
'MintyPHP\\Repository\\Content\\PageRepository',
'MintyPHP\\Repository\\CustomField\\TenantCustomFieldDefinitionRepository',
'MintyPHP\\Repository\\CustomField\\TenantCustomFieldOptionRepository',
'MintyPHP\\Repository\\CustomField\\UserCustomFieldValueRepository',
'MintyPHP\\Repository\\CustomField\\UserCustomFieldValueOptionRepository',
'MintyPHP\\Repository\\Support\\RepoQuery',
];
/**
* @return array<string, array{class: string, interfaceClass: string, interfaceFile: string}>
*/
private function collectRepositoryPairs(): array
{
$root = $this->projectRootPath();
$repoDir = $root . '/lib/Repository';
$pairs = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($repoDir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if ($file->getExtension() !== 'php') {
continue;
}
$filename = $file->getFilename();
// Only concrete repository files (not interfaces, not utilities)
if (!str_ends_with($filename, 'Repository.php')) {
continue;
}
if (str_ends_with($filename, 'RepositoryInterface.php')) {
continue;
}
// Derive PSR-4 class name: lib/Repository/Foo/Bar.php → MintyPHP\Repository\Foo\Bar
$relativePath = ltrim(str_replace($root, '', $file->getPathname()), '/');
$className = 'MintyPHP\\' . str_replace(['/', '.php'], ['\\', ''], substr($relativePath, strlen('lib/')));
if (in_array($className, self::EXCLUDED_CLASSES, true)) {
continue;
}
$interfaceClass = $className . 'Interface';
$interfaceFile = substr($relativePath, 0, -strlen('.php')) . 'Interface.php';
$pairs[$className] = [
'class' => $className,
'interfaceClass' => $interfaceClass,
'interfaceFile' => $interfaceFile,
];
}
ksort($pairs);
return $pairs;
}
public function testEveryRepositoryHasACoLocatedInterface(): void
{
$pairs = $this->collectRepositoryPairs();
$this->assertNotEmpty($pairs, 'No repository classes found — check path resolution.');
$missing = [];
foreach ($pairs as $className => $pair) {
$root = $this->projectRootPath();
if (!file_exists($root . '/' . $pair['interfaceFile'])) {
$missing[] = $pair['interfaceFile'] . ' (for ' . $className . ')';
}
}
$this->assertSame(
[],
$missing,
"Missing interface files:\n" . implode("\n", $missing)
);
}
public function testEveryRepositoryImplementsItsInterface(): void
{
$pairs = $this->collectRepositoryPairs();
$violations = [];
foreach ($pairs as $className => $pair) {
if (!class_exists($className)) {
$violations[] = $className . ' — class not found (autoload issue?)';
continue;
}
$implemented = class_implements($className);
if ($implemented === false || !in_array($pair['interfaceClass'], $implemented, true)) {
$violations[] = $className . ' does not implement ' . $pair['interfaceClass'];
}
}
$this->assertSame(
[],
$violations,
"Repository interface contract violations:\n" . implode("\n", $violations)
);
}
}