forked from fa/breadcrumb-the-shire
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.
New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering
New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
175 lines
7.1 KiB
PHP
175 lines
7.1 KiB
PHP
<?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 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);
|
|
}
|
|
}
|
|
}
|