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>
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use MintyPHP\App\Module\Contracts\EventListener;
|
||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||
use MintyPHP\App\Module\ModuleManifest;
|
||||
@@ -128,6 +130,26 @@ final class ModuleStructureContractTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testRequiresFieldContainsOnlyValidModuleIds(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->requires as $index => $requiredId) {
|
||||
self::assertIsString($requiredId);
|
||||
self::assertNotEmpty(
|
||||
trim($requiredId),
|
||||
"Module '{$module['id']}' requires[{$index}] must be a non-empty string"
|
||||
);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^[a-z][a-z0-9_-]*$/',
|
||||
$requiredId,
|
||||
"Module '{$module['id']}' requires[{$index}] has invalid module id format: '{$requiredId}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── File structure ──────────────────────────────────────────────
|
||||
|
||||
public function testModuleHasLibDirectory(): void
|
||||
@@ -542,6 +564,113 @@ final class ModuleStructureContractTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event listener contract ────────────────────────────────────────
|
||||
|
||||
public function testEventListenerClassesImplementContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->eventListeners as $eventName => $handlers) {
|
||||
foreach ($handlers as $index => $handler) {
|
||||
$class = $handler['class'];
|
||||
self::assertTrue(
|
||||
class_exists($class),
|
||||
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' not found"
|
||||
);
|
||||
self::assertTrue(
|
||||
is_subclass_of($class, EventListener::class)
|
||||
|| in_array(EventListener::class, class_implements($class) ?: [], true),
|
||||
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' "
|
||||
. "must implement EventListener interface"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── Deactivation handler contract ──────────────────────────────
|
||||
|
||||
public function testDeactivationHandlerClassImplementsContract(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
$handler = $module['manifest']->deactivationHandler;
|
||||
if ($handler === null) {
|
||||
continue;
|
||||
}
|
||||
self::assertTrue(
|
||||
class_exists($handler),
|
||||
"Module '{$module['id']}' deactivation_handler class '{$handler}' not found"
|
||||
);
|
||||
self::assertTrue(
|
||||
is_subclass_of($handler, ModuleDeactivationHandler::class)
|
||||
|| in_array(ModuleDeactivationHandler::class, class_implements($handler) ?: [], true),
|
||||
"Module '{$module['id']}' deactivation_handler class '{$handler}' "
|
||||
. "must implement ModuleDeactivationHandler interface"
|
||||
);
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── Session key convention ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Module session providers must use the 'module.<id>.' prefix for all session keys.
|
||||
*
|
||||
* Scans session provider classes for $_SESSION[ and ->set( patterns and verifies
|
||||
* that the key argument starts with 'module.<module_id>.'.
|
||||
*/
|
||||
public function testSessionProvidersUseModulePrefixedKeys(): void
|
||||
{
|
||||
foreach (self::$modules as $module) {
|
||||
foreach ($module['manifest']->sessionProviders as $providerClass) {
|
||||
if (!class_exists($providerClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($providerClass);
|
||||
$file = $reflection->getFileName();
|
||||
if ($file === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedPrefix = 'module.' . $module['id'] . '.';
|
||||
|
||||
// Check $_SESSION['...'] writes and unset($_SESSION['...'])
|
||||
if (preg_match_all('/\$_SESSION\[\'([^\']+)\'\]/', $content, $matches)) {
|
||||
foreach ($matches[1] as $key) {
|
||||
self::assertStringStartsWith(
|
||||
$expectedPrefix,
|
||||
$key,
|
||||
"Module '{$module['id']}' session provider '{$providerClass}' uses session key "
|
||||
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check ->set('...', ...) calls on session store
|
||||
if (preg_match_all('/->set\(\s*\'([^\']+)\'/', $content, $matches)) {
|
||||
foreach ($matches[1] as $key) {
|
||||
self::assertStringStartsWith(
|
||||
$expectedPrefix,
|
||||
$key,
|
||||
"Module '{$module['id']}' session provider '{$providerClass}' uses session store key "
|
||||
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ─── No hardcoded Core ability imports in module pages ───────────
|
||||
|
||||
public function testModulePagesDoNotImportCoreAbilityConstants(): void
|
||||
|
||||
Reference in New Issue
Block a user