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`