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