Files
breadcrumb-the-shire/tests/Architecture/ModuleRegistryContractTest.php
fs ef72b34c40 feat: module architecture improvements — session keys, dependency graph, event dispatcher, deactivation hooks
Six targeted improvements to the modular monolith platform:

1. Fix AddressBook session key prefix to follow module.<id>.* convention
2. Move ADDRESS_BOOK_VIEW permission constant from core PermissionService into module
3. Add declarative JSON schema for module manifests (.agents/contracts/)
4. Add `requires` field with missing-dependency and circular-dependency detection
5. Add lightweight fire-and-forget event dispatcher (user.created/deleted/login/logout)
6. Add module deactivation hook interface and CLI script (bin/module-deactivate.php)

Includes 15 new architecture/unit tests covering all new functionality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:20:13 +01:00

226 lines
9.0 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 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', '<?php return ' . var_export([
'id' => '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', '<?php return ' . var_export([
'id' => 'mod-x',
'requires' => ['mod-y'],
], true) . ';');
file_put_contents($fixturesDir . '/mod-y/module.php', '<?php return ' . var_export([
'id' => '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', '<?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);
}
}
}