feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to contribute routes, UI slots, search providers, layout context, session lifecycle, and permissions. Modules are activated via config/modules.php or APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions, slot keys) cause a fail-fast with a clear error message. Extract the address book from hardcoded Core integration points into the first module (modules/addressbook/). The module provides: - Aside icon-bar tab + People panel via UI slot system - Global search resource via AddressBookSearchProvider - Layout context data via AddressBookLayoutProvider - Session lifecycle via AddressBookSessionProvider Core cleanup removes address-book hardcodings from SearchSqlResourceProvider, SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(), and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic (AddressBookService) remain in Core as they gate general user visibility. Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture tests verifying zero hardcoded address-book references in search/templates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
127
tests/Architecture/ModuleRegistryContractTest.php
Normal file
127
tests/Architecture/ModuleRegistryContractTest.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?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
|
||||
{
|
||||
$registry = app(ModuleRegistry::class);
|
||||
|
||||
// config/modules.php has addressbook enabled by default
|
||||
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';
|
||||
|
||||
// Boot a fresh registry with no modules enabled (simulates APP_ENABLED_MODULES='')
|
||||
$registry = ModuleRegistry::boot($modulesDir, []);
|
||||
|
||||
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']);
|
||||
// Permissions owned by Core PermissionService, not module
|
||||
self::assertSame([], $raw['permissions']);
|
||||
// Asset groups owned by config/assets.php, not module
|
||||
self::assertSame([], $raw['asset_groups']);
|
||||
self::assertNotEmpty($raw['search_resources']);
|
||||
self::assertNotEmpty($raw['layout_context_providers']);
|
||||
self::assertNotEmpty($raw['session_providers']);
|
||||
self::assertArrayHasKey('aside.tab_panel', $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' => ['shared.perm'],
|
||||
], true) . ';');
|
||||
file_put_contents($fixturesDir . '/mod-b/module.php', '<?php return ' . var_export([
|
||||
'id' => 'mod-b',
|
||||
'permissions' => ['shared.perm'],
|
||||
], 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user