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:
2026-03-18 15:43:25 +01:00
parent d9805c45d3
commit c364e2b46d
33 changed files with 2639 additions and 167 deletions

View File

@@ -0,0 +1,233 @@
<?php
namespace MintyPHP\Tests\App\Module;
use MintyPHP\App\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ModuleRegistryTest extends TestCase
{
private string $fixturesDir;
protected function setUp(): void
{
$this->fixturesDir = sys_get_temp_dir() . '/corecore-module-test-' . uniqid();
mkdir($this->fixturesDir, 0777, true);
}
protected function tearDown(): void
{
$this->removeDir($this->fixturesDir);
}
public function testBootWithNoModules(): void
{
$registry = ModuleRegistry::boot($this->fixturesDir, []);
self::assertSame([], $registry->getModules());
self::assertSame([], $registry->getRoutes());
self::assertSame([], $registry->getPermissions());
}
public function testBootWithNonExistentDir(): void
{
$registry = ModuleRegistry::boot(sys_get_temp_dir() . '/does-not-exist-' . uniqid(), ['foo']);
self::assertSame([], $registry->getModules());
}
public function testBootLoadsSingleModule(): void
{
$this->createModuleManifest('testmod', [
'id' => 'testmod',
'version' => '1.0.0',
'load_order' => 10,
'permissions' => ['test.view', 'test.edit'],
'search_resources' => [],
]);
$registry = ModuleRegistry::boot($this->fixturesDir, ['testmod']);
self::assertTrue($registry->hasModule('testmod'));
self::assertSame('1.0.0', $registry->getModule('testmod')->version);
self::assertSame(['test.view', 'test.edit'], $registry->getPermissions());
}
public function testDeterministicOrdering(): void
{
$this->createModuleManifest('beta', ['id' => 'beta', 'load_order' => 20]);
$this->createModuleManifest('alpha', ['id' => 'alpha', 'load_order' => 10]);
$this->createModuleManifest('gamma', ['id' => 'gamma', 'load_order' => 10]);
$registry = ModuleRegistry::boot($this->fixturesDir, ['beta', 'alpha', 'gamma']);
$ids = array_keys($registry->getModules());
// load_order 10: alpha, gamma (alpha < gamma by id); then load_order 20: beta
self::assertSame(['alpha', 'gamma', 'beta'], $ids);
}
public function testDuplicateModuleIdThrows(): void
{
$this->createModuleManifest('dup', ['id' => 'dup']);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("Duplicate module id: 'dup'");
// Pass same ID twice — registry should fail-fast
ModuleRegistry::boot($this->fixturesDir, ['dup', 'dup']);
}
public function testPermissionConflictThrows(): void
{
$this->createModuleManifest('mod-a', ['id' => 'mod-a', 'load_order' => 1, 'permissions' => ['shared.perm']]);
$this->createModuleManifest('mod-b', ['id' => 'mod-b', 'load_order' => 2, 'permissions' => ['shared.perm']]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("Permission conflict: 'shared.perm'");
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
}
public function testRouteConflictThrows(): void
{
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'load_order' => 1,
'routes' => [['path' => '/shared', 'target' => 'shared/index']],
]);
$this->createModuleManifest('mod-b', [
'id' => 'mod-b',
'load_order' => 2,
'routes' => [['path' => '/shared-alt', 'target' => 'shared/index']],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Route target conflict');
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
}
public function testMissingManifestFileThrows(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("manifest not found");
ModuleRegistry::boot($this->fixturesDir, ['nonexistent']);
}
public function testManifestIdMismatchThrows(): void
{
$this->createModuleManifest('wrongdir', ['id' => 'actualid']);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("does not match manifest id 'actualid'");
ModuleRegistry::boot($this->fixturesDir, ['wrongdir']);
}
public function testResolveEnabledModulesFromConfig(): void
{
$result = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['mod-a', 'mod-b'],
]);
self::assertSame(['mod-a', 'mod-b'], $result);
}
public function testResolveEnabledModulesFromEnv(): void
{
putenv('APP_ENABLED_MODULES=env-a,env-b');
try {
$result = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['config-a'],
]);
self::assertSame(['env-a', 'env-b'], $result);
} finally {
putenv('APP_ENABLED_MODULES');
}
}
public function testResolveEnabledModulesDeduplicates(): void
{
$result = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['mod-a', 'mod-a', 'mod-b'],
]);
self::assertSame(['mod-a', 'mod-b'], $result);
}
public function testGetNonExistentModuleThrows(): void
{
$registry = ModuleRegistry::boot($this->fixturesDir, []);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("Module 'nope' is not registered");
$registry->getModule('nope');
}
public function testMergedContributions(): void
{
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'load_order' => 1,
'layout_context_providers' => ['ProviderA'],
'session_providers' => ['SessionA'],
'search_resources' => ['SearchA'],
'asset_groups' => ['group-a' => ['a.css']],
]);
$this->createModuleManifest('mod-b', [
'id' => 'mod-b',
'load_order' => 2,
'layout_context_providers' => ['ProviderB'],
'session_providers' => ['SessionB'],
'search_resources' => ['SearchB'],
'asset_groups' => ['group-b' => ['b.css']],
]);
$registry = ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
self::assertSame(['ProviderA', 'ProviderB'], $registry->getLayoutContextProviders());
self::assertSame(['SessionA', 'SessionB'], $registry->getSessionProviders());
self::assertSame(['SearchA', 'SearchB'], $registry->getSearchResources());
self::assertSame(['group-a' => ['a.css'], 'group-b' => ['b.css']], $registry->getAssetGroups());
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* @param array<string, mixed> $manifest
*/
private 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',
'<?php return ' . var_export($manifest, true) . ';'
);
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}
rmdir($dir);
}
}