Split module registry contracts

This commit is contained in:
2026-03-19 19:00:29 +01:00
parent 475524bfd4
commit 95ab6b8c6c
6 changed files with 175 additions and 228 deletions

View File

@@ -21,7 +21,8 @@
<file>tests/Architecture/I18nKeyCompletenessContractTest.php</file>
<file>tests/Architecture/ModuleBoundaryContractTest.php</file>
<file>tests/Architecture/ModuleManifestContractTest.php</file>
<file>tests/Architecture/ModuleRegistryContractTest.php</file>
<file>tests/Architecture/ModuleRegistryBootstrapContractTest.php</file>
<file>tests/Architecture/ModuleRegistryFailFastContractTest.php</file>
<file>tests/Architecture/ModuleProviderContractTest.php</file>
<file>tests/Architecture/ModuleUiContractTest.php</file>
<file>tests/Architecture/RepositoryInterfaceContractTest.php</file>
@@ -66,7 +67,8 @@
<testsuite name="ArchitectureModules">
<file>tests/Architecture/AddressBookModuleIsolationContractTest.php</file>
<file>tests/Architecture/BookmarksModuleIsolationContractTest.php</file>
<file>tests/Architecture/ModuleRegistryContractTest.php</file>
<file>tests/Architecture/ModuleRegistryBootstrapContractTest.php</file>
<file>tests/Architecture/ModuleRegistryFailFastContractTest.php</file>
<file>tests/Architecture/ModuleBoundaryContractTest.php</file>
<file>tests/Architecture/ModuleManifestContractTest.php</file>
<file>tests/Architecture/ModuleProviderContractTest.php</file>

View File

@@ -0,0 +1,60 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
abstract class AbstractModuleRegistryContractTestCase extends TestCase
{
protected string $fixturesDir;
protected function setUp(): void
{
$this->fixturesDir = sys_get_temp_dir() . '/corecore-module-architecture-' . uniqid('', true);
mkdir($this->fixturesDir, 0777, true);
}
protected function tearDown(): void
{
$this->removeDir($this->fixturesDir);
}
/**
* @param array<string, mixed> $manifest
*/
protected function createModuleManifest(string $dirName, array $manifest): void
{
$dir = $this->fixturesDir . '/' . $dirName;
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents(
$dir . '/module.php',
'<?php return ' . var_export($manifest, true) . ';'
);
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
continue;
}
unlink($item->getPathname());
}
rmdir($dir);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace MintyPHP\Tests\Architecture;
use MintyPHP\App\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
final class ModuleRegistryBootstrapContractTest extends TestCase
{
public function testRegistryIsAvailableInContainer(): void
{
$registry = app(ModuleRegistry::class);
self::assertInstanceOf(ModuleRegistry::class, $registry);
}
public function testAddressBookModuleLoadsExpectedRuntimeContributions(): void
{
$modulesDir = dirname(__DIR__, 2) . '/modules';
$registry = ModuleRegistry::boot($modulesDir, ['addressbook']);
self::assertTrue($registry->hasModule('addressbook'));
self::assertNotEmpty($registry->getPermissions());
self::assertNotEmpty($registry->getSearchResources());
self::assertNotEmpty($registry->getLayoutContextProviders());
self::assertNotEmpty($registry->getSessionProviders());
self::assertNotEmpty($registry->getSlotContributions('aside.tab_panel'));
}
public function testEmptyEnvDisablesAllConfiguredModules(): void
{
$modulesDir = dirname(__DIR__, 2) . '/modules';
putenv('APP_ENABLED_MODULES=');
try {
$enabledModules = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['addressbook', 'bookmarks'],
]);
$registry = ModuleRegistry::boot($modulesDir, $enabledModules);
} finally {
putenv('APP_ENABLED_MODULES');
}
self::assertSame([], $enabledModules, 'APP_ENABLED_MODULES empty string must disable all modules.');
self::assertSame([], $registry->getModules());
self::assertSame([], $registry->getRoutes());
self::assertSame([], $registry->getPermissions());
self::assertSame([], $registry->getSearchResources());
self::assertSame([], $registry->getLayoutContextProviders());
self::assertSame([], $registry->getSessionProviders());
self::assertSame([], $registry->getSlotContributions('aside.tab_panel'));
}
}

View File

@@ -1,225 +0,0 @@
<?php
namespace MintyPHP\Tests\Architecture;
use MintyPHP\App\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
/**
* Architecture test: verifies module registry contract in both module-on and module-off modes.
*/
final class ModuleRegistryContractTest extends TestCase
{
public function testRegistryIsAvailableInContainer(): void
{
$registry = app(ModuleRegistry::class);
self::assertInstanceOf(ModuleRegistry::class, $registry);
}
public function testAddressBookModuleLoadsWhenEnabled(): void
{
$modulesDir = dirname(__DIR__, 2) . '/modules';
$registry = ModuleRegistry::boot($modulesDir, ['addressbook']);
self::assertTrue($registry->hasModule('addressbook'));
self::assertNotEmpty($registry->getSearchResources());
self::assertNotEmpty($registry->getLayoutContextProviders());
self::assertNotEmpty($registry->getSessionProviders());
}
public function testModuleOffModeReturnsEmptyRegistry(): void
{
$modulesDir = dirname(__DIR__, 2) . '/modules';
putenv('APP_ENABLED_MODULES=');
try {
$enabledModules = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['addressbook', 'bookmarks'],
]);
self::assertSame([], $enabledModules, 'APP_ENABLED_MODULES empty string must disable all modules.');
$registry = ModuleRegistry::boot($modulesDir, $enabledModules);
} finally {
putenv('APP_ENABLED_MODULES');
}
self::assertSame([], $registry->getModules());
self::assertSame([], $registry->getRoutes());
self::assertSame([], $registry->getPermissions());
self::assertSame([], $registry->getSearchResources());
self::assertSame([], $registry->getLayoutContextProviders());
self::assertSame([], $registry->getSessionProviders());
self::assertSame([], $registry->getSlotContributions('aside.tab_panel'));
}
public function testAddressBookModuleManifestIsValid(): void
{
$modulesDir = dirname(__DIR__, 2) . '/modules';
$manifestFile = $modulesDir . '/addressbook/module.php';
self::assertFileExists($manifestFile, 'Address book module manifest must exist');
$raw = require $manifestFile;
self::assertIsArray($raw);
self::assertSame('addressbook', $raw['id']);
self::assertSame([[
'key' => 'address_book.view',
'description' => 'Can view address book',
'active' => true,
'is_system' => true,
]], $raw['permissions']);
self::assertArrayHasKey('address-book', $raw['asset_groups']);
self::assertSame(
['modules/addressbook/css/pages/address-book-view.css'],
$raw['asset_groups']['address-book']
);
self::assertNotEmpty($raw['search_resources']);
self::assertNotEmpty($raw['layout_context_providers']);
self::assertNotEmpty($raw['session_providers']);
self::assertArrayHasKey('aside.tab_panel', $raw['ui_slots']);
self::assertArrayHasKey('search.resource_item', $raw['ui_slots']);
self::assertArrayHasKey('user.edit.aside_action', $raw['ui_slots']);
$routes = is_array($raw['routes'] ?? null) ? $raw['routes'] : [];
$routeByPath = [];
foreach ($routes as $route) {
if (!is_array($route)) {
continue;
}
$routeByPath[(string) ($route['path'] ?? '')] = (string) ($route['target'] ?? '');
}
self::assertSame('address-book', $routeByPath['address-book'] ?? null);
self::assertSame('address-book/data', $routeByPath['address-book/data'] ?? null);
self::assertSame('address-book', $routeByPath['addressbook'] ?? null);
self::assertSame('address-book', $routeByPath['adressbook'] ?? null);
}
public function testBookmarksModuleManifestIsValid(): void
{
$modulesDir = dirname(__DIR__, 2) . '/modules';
$manifestFile = $modulesDir . '/bookmarks/module.php';
self::assertFileExists($manifestFile, 'Bookmarks module manifest must exist');
$raw = require $manifestFile;
self::assertIsArray($raw);
self::assertSame('bookmarks', $raw['id']);
self::assertArrayHasKey('aside.tab_panel', $raw['ui_slots']);
self::assertArrayHasKey('topbar.right_item', $raw['ui_slots']);
self::assertArrayHasKey('layout.body_end_template', $raw['ui_slots']);
self::assertArrayHasKey('layout.head_style', $raw['ui_slots']);
self::assertArrayHasKey('runtime.component', $raw['ui_slots']);
}
public function testModuleProviderClassesExist(): void
{
self::assertTrue(
class_exists(\MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider::class),
'AddressBookSearchProvider class must be autoloadable'
);
self::assertTrue(
class_exists(\MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider::class),
'AddressBookLayoutProvider class must be autoloadable'
);
self::assertTrue(
class_exists(\MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider::class),
'AddressBookSessionProvider class must be autoloadable'
);
}
public function testProviderClassesImplementContracts(): void
{
self::assertInstanceOf(
\MintyPHP\App\Module\Contracts\SearchResourceProvider::class,
new \MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider()
);
self::assertInstanceOf(
\MintyPHP\App\Module\Contracts\LayoutContextProvider::class,
new \MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider()
);
self::assertInstanceOf(
\MintyPHP\App\Module\Contracts\SessionProvider::class,
new \MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider()
);
}
public function testMissingDependencyThrowsException(): void
{
$fixturesDir = sys_get_temp_dir() . '/module-dep-test-' . uniqid();
mkdir($fixturesDir . '/mod-dep', 0777, true);
file_put_contents($fixturesDir . '/mod-dep/module.php', '<?php return ' . var_export([
'id' => 'mod-dep',
'requires' => ['nonexistent-module'],
], true) . ';');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage("requires module 'nonexistent-module' which is not enabled");
try {
ModuleRegistry::boot($fixturesDir, ['mod-dep']);
} finally {
@unlink($fixturesDir . '/mod-dep/module.php');
@rmdir($fixturesDir . '/mod-dep');
@rmdir($fixturesDir);
}
}
public function testCircularDependencyThrowsException(): void
{
$fixturesDir = sys_get_temp_dir() . '/module-circular-test-' . uniqid();
mkdir($fixturesDir . '/mod-x', 0777, true);
mkdir($fixturesDir . '/mod-y', 0777, true);
file_put_contents($fixturesDir . '/mod-x/module.php', '<?php return ' . var_export([
'id' => 'mod-x',
'requires' => ['mod-y'],
], true) . ';');
file_put_contents($fixturesDir . '/mod-y/module.php', '<?php return ' . var_export([
'id' => 'mod-y',
'requires' => ['mod-x'],
], true) . ';');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Circular module dependency');
try {
ModuleRegistry::boot($fixturesDir, ['mod-x', 'mod-y']);
} finally {
@unlink($fixturesDir . '/mod-x/module.php');
@unlink($fixturesDir . '/mod-y/module.php');
@rmdir($fixturesDir . '/mod-x');
@rmdir($fixturesDir . '/mod-y');
@rmdir($fixturesDir);
}
}
public function testConflictDetectionIsStrict(): void
{
$fixturesDir = sys_get_temp_dir() . '/module-arch-test-' . uniqid();
mkdir($fixturesDir . '/mod-a', 0777, true);
mkdir($fixturesDir . '/mod-b', 0777, true);
file_put_contents($fixturesDir . '/mod-a/module.php', '<?php return ' . var_export([
'id' => 'mod-a',
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared']],
], true) . ';');
file_put_contents($fixturesDir . '/mod-b/module.php', '<?php return ' . var_export([
'id' => 'mod-b',
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared duplicate']],
], true) . ';');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Permission conflict');
try {
ModuleRegistry::boot($fixturesDir, ['mod-a', 'mod-b']);
} finally {
array_map('unlink', glob($fixturesDir . '/mod-a/*') ?: []);
array_map('unlink', glob($fixturesDir . '/mod-b/*') ?: []);
rmdir($fixturesDir . '/mod-a');
rmdir($fixturesDir . '/mod-b');
rmdir($fixturesDir);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace MintyPHP\Tests\Architecture;
use MintyPHP\App\Module\ModuleRegistry;
use RuntimeException;
final class ModuleRegistryFailFastContractTest extends AbstractModuleRegistryContractTestCase
{
public function testMissingDependencyThrowsException(): void
{
$this->createModuleManifest('mod-dep', [
'id' => 'mod-dep',
'requires' => ['nonexistent-module'],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("requires module 'nonexistent-module' which is not enabled");
ModuleRegistry::boot($this->fixturesDir, ['mod-dep']);
}
public function testCircularDependencyThrowsException(): void
{
$this->createModuleManifest('mod-x', [
'id' => 'mod-x',
'requires' => ['mod-y'],
]);
$this->createModuleManifest('mod-y', [
'id' => 'mod-y',
'requires' => ['mod-x'],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Circular module dependency');
ModuleRegistry::boot($this->fixturesDir, ['mod-x', 'mod-y']);
}
public function testPermissionConflictThrowsException(): void
{
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared']],
]);
$this->createModuleManifest('mod-b', [
'id' => 'mod-b',
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared duplicate']],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Permission conflict');
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
}
}

View File

@@ -32,7 +32,7 @@ ohne dass jede Aenderung immer die komplette Suite im Kopf behalten muss.
## Naechste Refactor-Kandidaten
- `tests/Architecture/ModuleRegistryContractTest.php`
- `tests/Architecture/DetailValidationSummaryContractTest.php`
- `tests/Architecture/DetailActionPolicyContractTest.php`
- `tests/Architecture/ContainerFactoryContractTest.php`
- `tests/Architecture/SecurityLoggingContractTest.php`