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,72 @@
<?php
namespace MintyPHP\Tests\App\Module;
use InvalidArgumentException;
use MintyPHP\App\Module\ModuleManifest;
use PHPUnit\Framework\TestCase;
final class ModuleManifestTest extends TestCase
{
public function testMinimalManifest(): void
{
$manifest = ModuleManifest::fromArray(['id' => 'testmod'], '/tmp/modules/testmod');
self::assertSame('testmod', $manifest->id);
self::assertSame('0.0.0', $manifest->version);
self::assertFalse($manifest->enabledByDefault);
self::assertSame(100, $manifest->loadOrder);
self::assertSame([], $manifest->routes);
self::assertSame([], $manifest->permissions);
self::assertSame([], $manifest->searchResources);
self::assertNull($manifest->migrationsPath);
}
public function testFullManifest(): void
{
$raw = [
'id' => 'addressbook',
'version' => '1.0.0',
'enabled_by_default' => true,
'load_order' => 10,
'routes' => [['path' => '/address-book', 'target' => 'address-book/index']],
'public_paths' => [],
'container_registrars' => ['App\\Registrar'],
'ui_slots' => ['aside.tab_panel' => [['key' => 'people', 'label' => 'People']]],
'search_resources' => ['App\\SearchProvider'],
'asset_groups' => ['address-book' => ['css/view.css']],
'scheduler_jobs' => [],
'layout_context_providers' => ['App\\LayoutProvider'],
'session_providers' => ['App\\SessionProvider'],
'permissions' => ['address_book.view'],
'migrations_path' => 'db/updates',
];
$manifest = ModuleManifest::fromArray($raw, '/srv/modules/addressbook');
self::assertSame('addressbook', $manifest->id);
self::assertSame('1.0.0', $manifest->version);
self::assertTrue($manifest->enabledByDefault);
self::assertSame(10, $manifest->loadOrder);
self::assertCount(1, $manifest->routes);
self::assertSame(['address_book.view'], $manifest->permissions);
self::assertSame('/srv/modules/addressbook/db/updates', $manifest->migrationsPath);
self::assertSame(['App\\LayoutProvider'], $manifest->layoutContextProviders);
self::assertSame(['App\\SessionProvider'], $manifest->sessionProviders);
}
public function testMissingIdThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('non-empty string "id"');
ModuleManifest::fromArray([], '/tmp/modules/broken');
}
public function testEmptyIdThrows(): void
{
$this->expectException(InvalidArgumentException::class);
ModuleManifest::fromArray(['id' => ' '], '/tmp/modules/broken');
}
}

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

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

View File

@@ -0,0 +1,163 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use FilesystemIterator;
/**
* Architecture test: verifies that no address-book-specific hardcodings remain
* in the search infrastructure, layout context builder, or aside templates.
*
* Intentionally allowed in Core (not flagged):
* - PermissionService::ADDRESS_BOOK_VIEW (general people-visibility gate)
* - OperationsAuthorizationPolicy / UiCapabilityMap / UserAuthorizationPolicy (authorization)
* - AddressBookService / AddressBookServicesFactory (business logic, V1 shared-db)
* - pages/address-book/* (page files, not hardcoded integrations)
* - config/assets.php (asset pipeline not yet modular)
* - i18n translation files
* - Module directory (lib/Module/, modules/)
* - Test files
*/
final class NoAddressBookHardcodingTest extends TestCase
{
/**
* Search infrastructure must not contain hardcoded address-book references.
* The module's AddressBookSearchProvider is the sole source now.
*/
public function testSearchInfrastructureHasNoAddressBookHardcoding(): void
{
$searchDir = dirname(__DIR__, 2) . '/lib/Support/Search';
$hits = $this->grepDir($searchDir, '/address.book/i', ['*.php']);
self::assertSame([], $hits, sprintf(
"Search infrastructure still contains address-book hardcodings:\n%s",
implode("\n", $hits)
));
}
/**
* SearchConfig facade must not contain hardcoded address-book references
* (module provider bridge handles it dynamically).
*/
public function testSearchConfigHasNoAddressBookHardcoding(): void
{
$file = dirname(__DIR__, 2) . '/lib/Support/SearchConfig.php';
$content = file_get_contents($file);
self::assertIsString($content);
self::assertStringNotContainsString('address-book', $content);
self::assertStringNotContainsString('address_book', $content);
self::assertStringNotContainsString('addressBook', $content);
}
/**
* Layout context builder (appBuildLayoutNavContext) must not contain
* hardcoded addressBook data assembly.
*/
public function testLayoutContextHasNoHardcodedAddressBookBlock(): void
{
$file = dirname(__DIR__, 2) . '/lib/Support/helpers/app.php';
$content = file_get_contents($file);
self::assertIsString($content);
// The function should not contain 'addressBook' as an array key in the $layoutNav builder
// (the module LayoutContextProvider provides it dynamically)
self::assertDoesNotMatchRegularExpression(
"/'\s*addressBook\s*'\s*=>\s*\[/",
$content,
'appBuildLayoutNavContext() still has hardcoded addressBook array block'
);
}
/**
* Aside icon-bar template must not have hardcoded People tab.
*/
public function testIconBarHasNoHardcodedPeopleTab(): void
{
$file = dirname(__DIR__, 2) . '/templates/partials/app-main-aside-icon-bar.phtml';
$content = file_get_contents($file);
self::assertIsString($content);
self::assertStringNotContainsString('aside-tab-people', $content,
'Icon-bar still has hardcoded People tab (should come from module slot)');
self::assertStringNotContainsString('$canViewAddressBook', $content,
'Icon-bar still references $canViewAddressBook variable');
self::assertStringNotContainsString('$addressBookUrl', $content,
'Icon-bar still references $addressBookUrl variable');
}
/**
* Aside panel template must not have hardcoded People panel.
*
* Note: The search panel legitimately contains data-search-key="address-book"
* for the global search results — that is NOT a hardcoded People panel.
*/
public function testAsideHasNoHardcodedPeoplePanel(): void
{
$file = dirname(__DIR__, 2) . '/templates/partials/app-main-aside.phtml';
$content = file_get_contents($file);
self::assertIsString($content);
// The hardcoded People panel used id="aside-panel-people" with data-aside-panel="people"
self::assertStringNotContainsString('id="aside-panel-people"', $content,
'Aside still has hardcoded People panel element');
// The hardcoded People panel extracted $canViewAddressBook as a standalone variable
self::assertDoesNotMatchRegularExpression(
'/^\$canViewAddressBook\s*=/m',
$content,
'Aside still defines $canViewAddressBook as a standalone variable'
);
// The hardcoded People panel extracted $peopleGroups as a standalone variable
self::assertDoesNotMatchRegularExpression(
'/^\$peopleGroups\s*=/m',
$content,
'Aside still defines $peopleGroups as a standalone variable'
);
}
/**
* @return list<string> Lines matching the pattern
*/
private function grepDir(string $dir, string $pattern, array $extensions): array
{
$hits = [];
if (!is_dir($dir)) {
return $hits;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
$ext = $file->getExtension();
$matchesExt = false;
foreach ($extensions as $allowedExt) {
if ('*.' . $ext === $allowedExt) {
$matchesExt = true;
break;
}
}
if (!$matchesExt) {
continue;
}
$lines = file($file->getPathname(), FILE_IGNORE_NEW_LINES);
if ($lines === false) {
continue;
}
foreach ($lines as $lineNum => $line) {
if (preg_match($pattern, $line)) {
$relative = str_replace(dirname(__DIR__, 2) . '/', '', $file->getPathname());
$hits[] = sprintf('%s:%d: %s', $relative, $lineNum + 1, trim($line));
}
}
}
return $hits;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider;
use PHPUnit\Framework\TestCase;
/**
* Verifies that the AddressBookLayoutProvider correctly contributes
* the 'addressBook' key to the layout context.
*/
final class LayoutContextProviderTest extends TestCase
{
protected function setUp(): void
{
// Guard against other tests overwriting the global container
if (!($GLOBALS['minty_app_container'] ?? null) instanceof AppContainer
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\App\Module\ModuleRegistry::class)) {
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
setAppContainer($container);
}
}
public function testProviderReturnsAddressBookKey(): void
{
$provider = new AddressBookLayoutProvider();
$container = new AppContainer();
$session = [
'available_departments_by_tenant' => [
[
'tenant' => ['uuid' => 'abc', 'description' => 'Tenant A'],
'departments' => [['id' => 1, 'description' => 'Dept 1']],
],
],
];
$result = $provider->provide($session, $container);
self::assertArrayHasKey('addressBook', $result);
$ab = $result['addressBook'];
self::assertArrayHasKey('url', $ab);
self::assertArrayHasKey('activeSearch', $ab);
self::assertArrayHasKey('activeTenants', $ab);
self::assertArrayHasKey('activeDepartments', $ab);
self::assertArrayHasKey('activeRoles', $ab);
self::assertArrayHasKey('activeCustomFilters', $ab);
self::assertArrayHasKey('peopleGroups', $ab);
self::assertCount(1, $ab['peopleGroups']);
}
public function testProviderReturnsEmptyPeopleGroupsWhenSessionEmpty(): void
{
$provider = new AddressBookLayoutProvider();
$container = new AppContainer();
$result = $provider->provide([], $container);
self::assertArrayHasKey('addressBook', $result);
self::assertSame([], $result['addressBook']['peopleGroups']);
}
public function testProviderIsIncludedInLayoutNavWhenModuleActive(): void
{
// When the module is active, appBuildLayoutNavContext() should include
// addressBook via the provider loop
$layoutAuth = app(\MintyPHP\Service\Access\UiAccessService::class)->layoutCapabilities(0);
$layoutNav = appBuildLayoutNavContext($layoutAuth, [], []);
self::assertArrayHasKey('addressBook', $layoutNav,
'addressBook key must be present in layoutNav when module is active');
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider;
use MintyPHP\Support\SearchConfig;
use PHPUnit\Framework\TestCase;
/**
* Verifies that module search providers are correctly integrated into SearchConfig.
*/
final class SearchProviderCollectionTest extends TestCase
{
private static ?\MintyPHP\App\AppContainer $originalContainer = null;
protected function setUp(): void
{
// Guard against other tests overwriting the global container (e.g. ThemeResolutionTest)
if (self::$originalContainer === null) {
self::$originalContainer = $GLOBALS['minty_app_container'] ?? null;
}
if (!($GLOBALS['minty_app_container'] ?? null) instanceof \MintyPHP\App\AppContainer
|| !($GLOBALS['minty_app_container'])->has(ModuleRegistry::class)) {
// Restore the bootstrap container that has the module registry
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
setAppContainer($container);
}
SearchConfig::resetModuleProviders();
}
protected function tearDown(): void
{
SearchConfig::resetModuleProviders();
}
public function testAddressBookModuleIsActiveInRegistry(): void
{
/** @var ModuleRegistry $registry */
$registry = app(ModuleRegistry::class);
self::assertTrue($registry->hasModule('addressbook'));
self::assertNotEmpty($registry->getSearchResources());
}
public function testCoreResourcesDoNotContainAddressBook(): void
{
$coreResources = SearchConfig::resources('test', 'en');
$coreKeys = array_column($coreResources, 'key');
self::assertNotContains('address-book', $coreKeys,
'address-book must not be in Core search resources (moved to module)');
}
public function testAddressBookSearchProviderDirectly(): void
{
$provider = new AddressBookSearchProvider();
$resources = $provider->resources();
self::assertArrayHasKey('address-book', $resources);
$resource = $resources['address-book'];
self::assertSame('address_book.view', $resource['permission']);
self::assertStringContainsString('select count', $resource['count_sql']);
self::assertStringContainsString('select uuid', $resource['preview_sql']);
self::assertStringContainsString('select uuid', $resource['result_sql']);
self::assertArrayHasKey('tenant_filter', $resource);
}
public function testAddressBookSearchProviderMapResultItem(): void
{
$provider = new AddressBookSearchProvider();
$result = $provider->mapResultItem('address-book', [
'uuid' => 'abc-123',
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'max@example.com',
]);
self::assertNotNull($result);
self::assertSame('Max Mustermann', $result['title']);
self::assertSame('max@example.com', $result['subtitle']);
self::assertStringContainsString('address-book/view/abc-123', $result['url']);
self::assertSame('bi-people', $result['icon']);
}
public function testAddressBookSearchProviderListUrl(): void
{
$provider = new AddressBookSearchProvider();
$url = $provider->listUrl('address-book', 'test%20query');
self::assertStringContainsString('address-book', $url);
self::assertStringContainsString('search=test%20query', $url);
}
public function testAddressBookSearchProviderIgnoresUnknownKey(): void
{
$provider = new AddressBookSearchProvider();
self::assertNull($provider->mapResultItem('unknown', []));
self::assertSame('', $provider->listUrl('unknown', 'test'));
}
public function testModuleResourcesMergedIntoSearchConfig(): void
{
$moduleResources = SearchConfig::moduleResources();
self::assertArrayHasKey('address-book', $moduleResources,
'address-book resource must come from module provider via SearchConfig');
}
public function testTenantFilterMergedFromModule(): void
{
$filters = SearchConfig::tenantScopeFilters();
self::assertArrayHasKey('address-book', $filters,
'address-book tenant scope filter must be provided by module');
}
public function testSearchConfigListUrlDelegatesToModule(): void
{
$url = SearchConfig::listUrl('address-book', 'test query');
self::assertStringContainsString('address-book', $url);
self::assertStringContainsString('search=', $url);
}
}