diff --git a/phpunit.xml b/phpunit.xml
index 6907bc4..c44e5a5 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -21,7 +21,8 @@
tests/Architecture/I18nKeyCompletenessContractTest.php
tests/Architecture/ModuleBoundaryContractTest.php
tests/Architecture/ModuleManifestContractTest.php
- tests/Architecture/ModuleRegistryContractTest.php
+ tests/Architecture/ModuleRegistryBootstrapContractTest.php
+ tests/Architecture/ModuleRegistryFailFastContractTest.php
tests/Architecture/ModuleProviderContractTest.php
tests/Architecture/ModuleUiContractTest.php
tests/Architecture/RepositoryInterfaceContractTest.php
@@ -66,7 +67,8 @@
tests/Architecture/AddressBookModuleIsolationContractTest.php
tests/Architecture/BookmarksModuleIsolationContractTest.php
- tests/Architecture/ModuleRegistryContractTest.php
+ tests/Architecture/ModuleRegistryBootstrapContractTest.php
+ tests/Architecture/ModuleRegistryFailFastContractTest.php
tests/Architecture/ModuleBoundaryContractTest.php
tests/Architecture/ModuleManifestContractTest.php
tests/Architecture/ModuleProviderContractTest.php
diff --git a/tests/Architecture/AbstractModuleRegistryContractTestCase.php b/tests/Architecture/AbstractModuleRegistryContractTestCase.php
new file mode 100644
index 0000000..a067808
--- /dev/null
+++ b/tests/Architecture/AbstractModuleRegistryContractTestCase.php
@@ -0,0 +1,60 @@
+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 $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',
+ 'isDir()) {
+ rmdir($item->getPathname());
+ continue;
+ }
+
+ unlink($item->getPathname());
+ }
+
+ rmdir($dir);
+ }
+}
diff --git a/tests/Architecture/ModuleRegistryBootstrapContractTest.php b/tests/Architecture/ModuleRegistryBootstrapContractTest.php
new file mode 100644
index 0000000..1ca5c47
--- /dev/null
+++ b/tests/Architecture/ModuleRegistryBootstrapContractTest.php
@@ -0,0 +1,54 @@
+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'));
+ }
+}
diff --git a/tests/Architecture/ModuleRegistryContractTest.php b/tests/Architecture/ModuleRegistryContractTest.php
deleted file mode 100644
index d7cac45..0000000
--- a/tests/Architecture/ModuleRegistryContractTest.php
+++ /dev/null
@@ -1,225 +0,0 @@
-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', ' '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', ' 'mod-x',
- 'requires' => ['mod-y'],
- ], true) . ';');
- file_put_contents($fixturesDir . '/mod-y/module.php', ' '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', ' 'mod-a',
- 'permissions' => [['key' => 'shared.perm', 'description' => 'Shared']],
- ], true) . ';');
- file_put_contents($fixturesDir . '/mod-b/module.php', ' '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);
- }
- }
-}
diff --git a/tests/Architecture/ModuleRegistryFailFastContractTest.php b/tests/Architecture/ModuleRegistryFailFastContractTest.php
new file mode 100644
index 0000000..6054466
--- /dev/null
+++ b/tests/Architecture/ModuleRegistryFailFastContractTest.php
@@ -0,0 +1,56 @@
+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']);
+ }
+}
diff --git a/tests/README.md b/tests/README.md
index 30ac456..cc9fd18 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -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`